Compare commits

...

107 Commits

Author SHA1 Message Date
dependabot[bot]
e7884cb505 Chore(deps): Bump the actions group with 9 updates (#12490)
Bumps the actions group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [dorny/paths-filter](https://github.com/dorny/paths-filter) | `3.0.2` | `4.0.1` |
| [actions/cache](https://github.com/actions/cache) | `5.0.3` | `5.0.4` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.0` | `8.0.1` |
| [actions/configure-pages](https://github.com/actions/configure-pages) | `5.0.0` | `6.0.0` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4.0.5` | `5.0.0` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `4.2.0` | `5.0.0` |
| [j178/prek-action](https://github.com/j178/prek-action) | `1.1.1` | `2.0.1` |
| [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) | `6.2.0` | `7.1.1` |
| [shogo82148/actions-upload-release-asset](https://github.com/shogo82148/actions-upload-release-asset) | `1.9.2` | `1.10.0` |


Updates `dorny/paths-filter` from 3.0.2 to 4.0.1
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](de90cc6fb3...fbd0ab8f3e)

Updates `actions/cache` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](cdf6c1fa76...668228422a)

Updates `actions/download-artifact` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](70fc10c6e5...3e5f45b2cf)

Updates `actions/configure-pages` from 5.0.0 to 6.0.0
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](983d7736d9...45bfe0192c)

Updates `actions/deploy-pages` from 4.0.5 to 5.0.0
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](d6db90164a...cd2ce8fcbc)

Updates `pnpm/action-setup` from 4.2.0 to 5.0.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](41ff726559...fc06bc1257)

Updates `j178/prek-action` from 1.1.1 to 2.0.1
- [Release notes](https://github.com/j178/prek-action/releases)
- [Commits](0bb87d7f00...53276d8b0d)

Updates `release-drafter/release-drafter` from 6.2.0 to 7.1.1
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](6db134d15f...139054aeaa)

Updates `shogo82148/actions-upload-release-asset` from 1.9.2 to 1.10.0
- [Release notes](https://github.com/shogo82148/actions-upload-release-asset/releases)
- [Commits](8f6863c6c8...96bc1f0cb8)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/cache
  dependency-version: 5.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/configure-pages
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/deploy-pages
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: j178/prek-action
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: shogo82148/actions-upload-release-asset
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 04:24:47 +00:00
GitHub Actions
63f4e939d5 Auto translate strings 2026-04-02 04:04:11 +00:00
dependabot[bot]
c813a1846d Chore(deps): Bump the frontend-angular-dependencies group (#12491)
Bumps the frontend-angular-dependencies group in /src-ui with 20 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `21.2.2` | `21.2.4` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.4` | `21.2.6` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.4` | `21.2.6` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.4` | `21.2.6` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.4` | `21.2.6` |
| [@angular/localize](https://github.com/angular/angular) | `21.2.4` | `21.2.6` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.4` | `21.2.6` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `21.2.4` | `21.2.6` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.4` | `21.2.6` |
| [ngx-cookie-service](https://github.com/stevermeister/ngx-cookie-service) | `21.1.0` | `21.3.1` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `21.2.2` | `21.2.3` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `21.2.2` | `21.2.3` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `21.3.0` | `21.3.1` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `21.3.0` | `21.3.1` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `21.3.0` | `21.3.1` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `21.3.0` | `21.3.1` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `21.3.0` | `21.3.1` |
| [@angular/build](https://github.com/angular/angular-cli) | `21.2.2` | `21.2.3` |
| [@angular/cli](https://github.com/angular/angular-cli) | `21.2.2` | `21.2.3` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.4` | `21.2.6` |


Updates `@angular/cdk` from 21.2.2 to 21.2.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/v21.2.2...v21.2.4)

Updates `@angular/common` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/common)

Updates `@angular/compiler` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/compiler)

Updates `@angular/core` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/core)

Updates `@angular/forms` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/forms)

Updates `@angular/localize` from 21.2.4 to 21.2.6
- [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/v21.2.4...v21.2.6)

Updates `@angular/platform-browser` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/platform-browser)

Updates `@angular/platform-browser-dynamic` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/platform-browser-dynamic)

Updates `@angular/router` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/router)

Updates `ngx-cookie-service` from 21.1.0 to 21.3.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/v21.1.0...v21.3.1)

Updates `@angular-devkit/core` from 21.2.2 to 21.2.3
- [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/v21.2.2...v21.2.3)

Updates `@angular-devkit/schematics` from 21.2.2 to 21.2.3
- [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/v21.2.2...v21.2.3)

Updates `@angular-eslint/builder` from 21.3.0 to 21.3.1
- [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/v21.3.1/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 21.3.0 to 21.3.1
- [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/v21.3.1/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 21.3.0 to 21.3.1
- [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/v21.3.1/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 21.3.0 to 21.3.1
- [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/v21.3.1/packages/schematics)

Updates `@angular-eslint/template-parser` from 21.3.0 to 21.3.1
- [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/v21.3.1/packages/template-parser)

Updates `@angular/build` from 21.2.2 to 21.2.3
- [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/v21.2.2...v21.2.3)

Updates `@angular/cli` from 21.2.2 to 21.2.3
- [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/v21.2.2...v21.2.3)

Updates `@angular/compiler-cli` from 21.2.4 to 21.2.6
- [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/v21.2.6/packages/compiler-cli)

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-version: 21.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser-dynamic"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-version: 21.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: ngx-cookie-service
  dependency-version: 21.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/core"
  dependency-version: 21.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/schematics"
  dependency-version: 21.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/builder"
  dependency-version: 21.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin"
  dependency-version: 21.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin-template"
  dependency-version: 21.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/schematics"
  dependency-version: 21.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/template-parser"
  dependency-version: 21.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/build"
  dependency-version: 21.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/cli"
  dependency-version: 21.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 21.2.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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>
2026-04-02 04:02:33 +00:00
dependabot[bot]
045afa7419 Chore(deps-dev): Bump @types/node from 25.4.0 to 25.5.0 in /src-ui (#12494)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.4.0 to 25.5.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: 25.5.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>
2026-04-02 03:49:37 +00:00
dependabot[bot]
e827581f2a Chore(deps-dev): Bump the frontend-eslint-dependencies group (#12493)
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.57.0 to 8.57.2
- [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.57.2/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.57.0 to 8.57.2
- [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.57.2/packages/parser)

Updates `@typescript-eslint/utils` from 8.57.0 to 8.57.2
- [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.57.2/packages/utils)

Updates `eslint` from 10.0.3 to 10.1.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.3...v10.1.0)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-eslint-dependencies
- dependency-name: eslint
  dependency-version: 10.1.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>
2026-04-01 20:41:52 -07:00
GitHub Actions
2aa0c9f0b4 Auto translate strings 2026-03-31 18:25:03 +00:00
shamoon
d2328b776a Performance: support bulk edit without id lists (#12355) 2026-03-31 18:23:28 +00:00
Trenton H
2bb7c7ae17 Chore: Document the parser plugin system (#12423)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-31 09:16:43 -07:00
GitHub Actions
e1da2a1efe Auto translate strings 2026-03-31 14:57:34 +00:00
shamoon
245514ad10 Performance: deprecate and remove usage of all in API results (#12309) 2026-03-31 07:55:59 -07:00
GitHub Actions
020057e1a4 Auto translate strings 2026-03-30 16:40:47 +00:00
shamoon
f715533770 Performance: support passing selection data with filtered document requests (#12300) 2026-03-30 16:38:52 +00:00
Jan Kleine
0292edbee7 Fixhancement: include trashed documents in document exporter/importer (#12425) 2026-03-30 16:30:22 +00:00
dependabot[bot]
5b755528da Chore(deps): Bump cryptography in the uv group across 1 directory (#12458)
Bumps the uv group with 1 update in the / directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.5 to 46.0.6
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 08:51:24 -07:00
shamoon
3d4353dc2b Security: pin GitHub Actions to specific SHAs (#12465) 2026-03-29 17:16:44 -07:00
Andreas Schneider
85e0d1842a Tests: add regression test for redis URL with empty username (#12460)
* Tests: add regression test for redis URL with empty username and password

Covers the unix://:SECRET@/path.sock format (empty username, password only),
which was missing from the existing test cases for PR #12239.

* Update src/paperless/tests/settings/test_custom_parsers.py

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-29 06:31:18 -07:00
GitHub Actions
62f79c088e Auto translate strings 2026-03-28 21:00:05 +00:00
shamoon
129da3ade7 Tweakhancement: show file extension in StoragePath test (#12452) 2026-03-28 13:58:33 -07:00
Trenton H
9383471fa0 Feature: Transition all checksums to use SHA256 (#12432) 2026-03-26 11:28:02 -07:00
dependabot[bot]
0060b46c8b Chore(deps): Bump requests in the uv group across 1 directory (#12441)
Bumps the uv group with 1 update in the / directory: [requests](https://github.com/psf/requests).


Updates `requests` from 2.32.5 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 09:04:20 -07:00
GitHub Actions
b153ec803b Auto translate strings 2026-03-26 14:38:10 +00:00
shamoon
38dba60ceb Enhancement: auto-hide the search bar on mobile (#12404) 2026-03-26 07:36:32 -07:00
shamoon
ae0474450f Chore: logger, response and template sanitization cleanup (#12439) 2026-03-26 07:36:02 -07:00
Trenton H
8efb01010c fix: Don't silently drop the change_groups and switch to a couple slightly more efficient implementations (#12431) 2026-03-26 14:15:42 +00:00
Trenton H
d18bbfa9c3 Chore: Instead of manual temporary directory management, use a context manager (#12430) 2026-03-26 14:05:58 +00:00
GitHub Actions
ec76d3c762 Auto translate strings 2026-03-26 12:45:29 +00:00
shamoon
bdc0a58242 Security: prevent prototype pollution in settings and list view (#12438) 2026-03-26 05:43:49 -07:00
dependabot[bot]
b049ad9626 Chore(deps): Bump cbor2 in the uv group across 1 directory (#12424)
Bumps the uv group with 1 update in the / directory: [cbor2](https://github.com/agronholm/cbor2).


Updates `cbor2` from 5.8.0 to 5.9.0
- [Release notes](https://github.com/agronholm/cbor2/releases)
- [Commits](https://github.com/agronholm/cbor2/compare/5.8.0...5.9.0)

---
updated-dependencies:
- dependency-name: cbor2
  dependency-version: 5.9.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 09:26:24 -07:00
GitHub Actions
79def8a200 Auto translate strings 2026-03-22 13:55:02 +00:00
Trenton H
701735f6e5 Chore: Drop old signal and unneeded apps, transition to parser registry instead (#12405)
* refactor: switch consumer and callers to ParserRegistry (Phase 4)

Replace all Django signal-based parser discovery with direct registry
calls. Removes `_parser_cleanup`, `parser_is_new_style` shims, and all
old-style isinstance checks. All parser instantiation now uses the
`with parser_class() as parser:` context manager pattern.

- documents/parsers.py: delegate to get_parser_registry(); drop lru_cache
- documents/consumer.py: use registry + context manager; remove shims
- documents/tasks.py: same pattern
- documents/management/commands/document_thumbnails.py: same pattern
- documents/views.py: get_metadata uses context manager
- documents/checks.py: use get_parser_registry().all_parsers()
- paperless/parsers/registry.py: add all_parsers() public method
- tests: update mocks to target documents.consumer.get_parser_class_for_mime_type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: drop get_parser_class_for_mime_type; callers use registry directly

All callers now call get_parser_registry().get_parser_for_file() with
the actual filename and path, enabling score() to use file extension
hints. The MIME-only helper is removed.

- consumer.py: passes self.filename + self.working_copy
- tasks.py: passes document.original_filename + document.source_path
- document_thumbnails.py: same pattern
- views.py: passes Path(file).name + Path(file)
- parsers.py: internal helpers inline the registry call with filename=""
- test_parsers.py: drop TestParserDiscovery (was testing mock behavior);
  TestParserAvailability uses registry directly
- test_consumer.py: mocks switch to documents.consumer.get_parser_registry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: remove document_consumer_declaration signal infrastructure

Remove the document_consumer_declaration signal that was previously used
for parser registration. Each parser app no longer connects to this signal,
and the signal declaration itself has been removed from documents/signals.

Changes:
- Remove document_consumer_declaration from documents/signals/__init__.py
- Remove ready() methods and signal imports from all parser app configs
- Delete signal shim files (signals.py) from all parser apps:
  - paperless_tesseract/signals.py
  - paperless_text/signals.py
  - paperless_tika/signals.py
  - paperless_mail/signals.py
  - paperless_remote/signals.py

Parser discovery now happens exclusively through the ParserRegistry
system introduced in the previous refactor phases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: remove empty paperless_text and paperless_tika Django apps

After parser classes were moved to paperless/parsers/ in the plugin
refactor, these Django apps contained only empty AppConfig classes
with no models, views, tasks, migrations, or other functionality.

- Remove paperless_text and paperless_tika from INSTALLED_APPS
- Delete empty app directories entirely
- Update pyproject.toml test exclusions
- Clean stale mypy baseline entries for moved parser files

paperless_remote app is retained as it contains meaningful system
checks for Azure AI configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Moves the checks and tests to the main application and removes the old applications

* Adds a comment to satisy Sonar

* refactor: remove automatic log_summary() call from get_parser_registry()

The summary was logged once per process, causing it to appear repeatedly
during Docker startup (management commands, web server, each Celery
worker subprocess). External parsers are already announced individually
at INFO when discovered; the full summary is redundant noise.
log_summary() is retained on ParserRegistry for manual/debug use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cleans up the duplicate test file/fixture

* Fixes a race condition where webserver threads could race to populate the registry

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 06:53:32 -07:00
GitHub Actions
07f54bfdab Auto translate strings 2026-03-21 09:26:23 +00:00
shamoon
0f84af27d0 Merge branch 'main' into dev
# Conflicts:
#	docs/setup.md
#	src-ui/src/main.ts
#	src/documents/tests/test_api_bulk_edit.py
#	src/documents/tests/test_api_custom_fields.py
#	src/documents/tests/test_api_search.py
#	src/documents/tests/test_api_status.py
#	src/documents/tests/test_workflows.py
#	src/paperless_mail/tests/test_api.py
2026-03-21 02:12:19 -07:00
shamoon
9646b8c67d Bump version to 2.20.13 2026-03-21 01:50:04 -07:00
shamoon
e590d7df69 Merge branch 'release/v2.20.x' 2026-03-21 01:49:32 -07:00
shamoon
cc71aad058 Fix: suggest corrections only if visible results 2026-03-21 01:24:23 -07:00
shamoon
3cbdf5d0b7 Fix: require view permission for more-like search 2026-03-21 01:20:59 -07:00
shamoon
f84e0097e5 Fix validate document link targets 2026-03-21 00:55:36 -07:00
shamoon
7dbf8bdd4a Fix: enforce permissions when attaching accounts to mail rules 2026-03-21 00:44:28 -07:00
github-actions[bot]
d2a752a196 Documentation: Add v2.20.12 changelog (#12407)
* Changelog v2.20.12 - GHA

* Update changelog for version 2.20.12

Added security advisory for GHSA-96jx-fj7m-qh6x and fixed workflow saves and usermod/groupmod issues.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-20 19:23:06 -07:00
shamoon
2cb155e717 Bump version to 2.20.12 2026-03-20 15:47:37 -07:00
shamoon
9e9fc6213c Resolve GHSA-96jx-fj7m-qh6x 2026-03-20 15:39:15 -07:00
Trenton H
a9756f9462 Chore: Convert Tesseract parser to plugin style (#12403)
* Move tesseract parser, tests, and samples to paperless.parsers

Relocates files in preparation for the Phase 3 Protocol-based parser
refactor, preserving full git history via rename.

- src/paperless_tesseract/parsers.py -> src/paperless/parsers/tesseract.py
- src/paperless_tesseract/tests/test_parser.py -> src/paperless/tests/parsers/test_tesseract_parser.py
- src/paperless_tesseract/tests/test_parser_custom_settings.py -> src/paperless/tests/parsers/test_tesseract_custom_settings.py
- src/paperless_tesseract/tests/samples/* -> src/paperless/tests/samples/tesseract/
- Moves RUF001 suppression from broad per-file pyproject.toml ignore to inline noqa comments on the two affected lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor RasterisedDocumentParser to ParserProtocol interface

- Add RasterisedDocumentParser to registry.register_defaults()
- Update parser class: remove DocumentParser inheritance, add Protocol
  class attrs/classmethods/properties, context-manager lifecycle
- Add read_file_handle_unicode_errors() to shared parsers/utils.py
- Replace inline unicode-error-handling with shared utility call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update tesseract signals.py to import from new parser location

RasterisedDocumentParser moved to paperless.parsers.tesseract; update
the lazy import in signals.get_parser so the signal-based consumer
declaration continues to work during the registry transition. Pop
logging_group and progress_callback kwargs for constructor compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* tests: rewrite test_tesseract_parser to pytest style with typed fixtures

- Converts all tests from Django TestCase to pytest-style classes
- Adds tesseract_samples_dir, null_app_config, tesseract_parser, and
  make_tesseract_parser fixtures in conftest.py; all DB-free except
  TestOcrmypdfParameters which uses @pytest.mark.django_db
- Defines MakeTesseractParser type alias in conftest.py for autocomplete
- Fixes FBT001 (boolean positional args) by making bool params
  keyword-only with * separator in parametrize test signatures
- Adds type annotations to all fixture parameters for IDE support
- Uses pytest.param(..., id="...") throughout; pytest-mock for patching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(types): fully annotate paperless/parsers/tesseract.py

Fixes all mypy and pyrefly errors in the new parser file:

- Add missing type annotations to is_image, has_alpha, get_dpi,
  calculate_a4_dpi, construct_ocrmypdf_parameters, post_process_text
- Narrow Path-only (no str) for image helper args; convert to str when
  building list[str] args for run_subprocess
- Annotate ocrmypdf_args as dict[str, Any] so operator expressions on
  its values type-check and ocrmypdf.ocr(**args) resolves cleanly
- Declare text: str | None = None at top of extract_text to unify
  all assignments to the same type across both branches
- Import Any from typing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fixes isort

* fix: add RasterisedDocumentParser to new-style parser shim checks

The new RasterisedDocumentParser uses __enter__/__exit__ for resource
management instead of cleanup(). Update all existing new-style shims to
include it in the isinstance checks:

- documents/consumer.py: _parser_cleanup(), parser_is_new_style
- documents/tasks.py: parser_is_new_style, finally cleanup branch
  (also adds RemoteDocumentParser which was missing from the latter)
- documents/management/commands/document_thumbnails.py: adds new-style
  handling from scratch (enter/exit + 2-arg get_thumbnail signature)

Fix stale import paths in three test files that were still importing
from paperless_tesseract.parsers instead of paperless.parsers.tesseract.

Fix two registry tests that used application/pdf as a proxy for "no
handler" — now that RasterisedDocumentParser is registered, PDF always
has a handler, so switch to a truly unsupported MIME type.

Signal infrastructure and shims remain intact; this is plumbing only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* One missed import (cherry pick?)

* Adds a no cover for a special case of handling unicode errors in PDF metadata

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 12:46:07 -07:00
Trenton H
c2b8b22fb4 Chore: Convert mail parser to plugin style (#12397)
* Refactor(mail): rename paperless_mail/parsers.py → paperless/parsers/mail.py

Preserve git history for MailDocumentParser by committing the rename
separately before editing, following the project convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor(mail): move mail parser tests to paperless/tests/parsers/

Move test_parsers.py → test_mail_parser.py and test_parsers_live.py →
test_mail_parser_live.py alongside the other built-in parser tests,
preserving git history before editing. Update MailDocumentParser import
to the new canonical location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Chore: move mail parser sample files to paperless/tests/samples/mail/

Relocate all mail test fixtures from src/paperless_mail/tests/samples/ to
src/paperless/tests/samples/mail/ ahead of the parser plugin refactor.
Add the new path to the codespell skip list to prevent false-positive
spell corrections in binary/fixture email files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Feat(tests): add mail parser fixtures to paperless/tests/parsers/conftest.py

Add mail_samples_dir, per-file sample fixtures, and mail_parser
(context-manager style) to mirror the old paperless_mail conftest
but rooted at the new samples/mail/ location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Feat(parsers): migrate MailDocumentParser to ParserProtocol

Move the mail parser from paperless_mail/parsers.py to
paperless/parsers/mail.py and refactor it to implement ParserProtocol:

- Class-level name/version/author/url attributes
- supported_mime_types() and score() classmethods (score=20)
- can_produce_archive=False, requires_pdf_rendition=True
- Context manager lifecycle (__enter__/__exit__)
- New parse() signature without mailrule_id kwarg; consumer sets
  parser.mailrule_id before calling parse() instead
- get_text()/get_date()/get_archive_path() accessor methods
- extract_metadata() returning email headers and attachment info

Register MailDocumentParser in the ParserRegistry alongside Text and
Tika parsers. Update consumer, signals, and all import sites to use
the new location. Update tests to use the new accessor API, patch
paths, and context-manager fixture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix(parsers): pop legacy constructor args in mail signal wrapper

MailDocumentParser.__init__ takes no constructor args in the new
protocol. Update the get_parser() signal wrapper to pop logging_group
and progress_callback (passed by the legacy consumer dispatch path)
before instantiating — the same pattern used by TextDocumentParser.

Also update test_mail_parser_receives_mailrule to use the real signal
wrapper (mail_get_parser) instead of MailDocumentParser directly, so
the test exercises the actual dispatch path and matches the new
parse() call signature (no mailrule kwarg).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Bumps this so we can run

* Fixes location of the fixture

* Removes fixtures which were duplicated

* Feat(parsers): add ParserContext and configure() to ParserProtocol

Replace the ad-hoc mailrule_id attribute assignment with a typed,
immutable ParserContext dataclass and a configure() method on the
Protocol:

- ParserContext(frozen=True, slots=True) lives in paperless/parsers/
  alongside ParserProtocol and MetadataEntry; currently carries only
  mailrule_id but is designed to grow with output_type, ocr_mode, and
  ocr_language in a future phase (decoupling parsers from settings.*)
- ParserProtocol.configure(context: ParserContext) -> None is the
  extension point; no-op by default
- MailDocumentParser.configure() reads mailrule_id into _mailrule_id
- TextDocumentParser and TikaDocumentParser implement a no-op configure()
- Consumer calls document_parser.configure(ParserContext(...)) before
  parse(), replacing the isinstance(parser, MailDocumentParser) guard
  and the direct attribute mutation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Feat(parsers): call configure(ParserContext()) in update_document task

Apply the same new-style parser shim pattern as the consumer to
update_document_content_maybe_archive_file:

- Call __enter__ for Text/Tika parsers after instantiation
- Call configure(ParserContext()) before parse() for all new-style parsers
  (mailrule_id is not available here — this is a re-process of an
  existing document, so the default empty context is correct)
- Call parse(path, mime_type) with 2 args for new-style parsers
- Call get_thumbnail(path, mime_type) with 2 args for new-style parsers
- Call __exit__ instead of cleanup() in the finally block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix(tests): add configure() to DummyParser and missing-method parametrize

ParserProtocol now requires configure(context: ParserContext) -> None.
Update DummyParser in test_registry.py to implement it, and add
'missing-configure' to the test_partial_compliant_fails_isinstance
parametrize list so the new method is covered by the negative test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cleans up the reprocess task and generally reduces duplicate of classes

* Corrects the score return

* Updates so we can report a page count for these parsers, assuming we do have an archive produced when called

* Increases test coverage

* One more coverage

* Updates typing

* Updates typing

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:22:18 -07:00
shamoon
d671e34559 Documentation: OIDC token_auth_method setting for v3 (#12398) 2026-03-19 22:03:00 -07:00
dependabot[bot]
f7c12d550a Chore(deps): Bump tinytag in the uv group across 1 directory (#12396)
Bumps the uv group with 1 update in the / directory: [tinytag](https://github.com/tinytag/tinytag).


Updates `tinytag` from 2.2.0 to 2.2.1
- [Release notes](https://github.com/tinytag/tinytag/releases)
- [Commits](https://github.com/tinytag/tinytag/compare/2.2.0...2.2.1)

---
updated-dependencies:
- dependency-name: tinytag
  dependency-version: 2.2.1
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 11:02:16 -07:00
Trenton H
68fc898042 Fix: Resolve more instances of tests which mutated global states (#12395) 2026-03-19 10:05:07 -07:00
Trenton H
2cbe6ae892 Feature: Convert remote AI parser to plugin system (#12334)
* Refactor: move remote parser, test, and sample to paperless.parsers

Relocates three files to their new homes in the parser plugin system:

- src/paperless_remote/parsers.py
    → src/paperless/parsers/remote.py
- src/paperless_remote/tests/test_parser.py
    → src/paperless/tests/parsers/test_remote_parser.py
- src/paperless_remote/tests/samples/simple-digital.pdf
    → src/paperless/tests/samples/remote/simple-digital.pdf

Content and imports will be updated in the follow-up commit that
rewrites the parser to the new ParserProtocol interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Feature: migrate RemoteDocumentParser to ParserProtocol interface

Rewrites the remote OCR parser to the new plugin system contract:

- `supported_mime_types()` is now a classmethod that always returns the
  full set of 7 MIME types; the old instance-method hack (returning {}
  when unconfigured) is removed
- `score()` classmethod returns None when no remote engine is configured
  (making the parser invisible to the registry), and 20 when active —
  higher than the tesseract default of 10 so the remote engine takes
  priority when both are available
- No longer inherits from RasterisedDocumentParser; inherits no parser
  class at all — just implements the protocol directly
- `can_produce_archive = True`; `requires_pdf_rendition = False`
- `_azure_ai_vision_parse()` takes explicit config arg; API client
  created and closed within the method
- `get_page_count()` returns the PDF page count for application/pdf,
  delegating to the new `get_page_count_for_pdf()` utility
- `extract_metadata()` delegates to `extract_pdf_metadata()` for PDFs;
  returns [] for all other MIME types

New files:
- `src/paperless/parsers/utils.py` — shared `extract_pdf_metadata()` and
  `get_page_count_for_pdf()` utilities (pikepdf-based); both the remote
  and tesseract parsers will use these going forward
- `src/paperless/tests/parsers/test_remote_parser.py` — 42 pytest-style
  tests using pytest-django `settings` and pytest-mock `mocker` fixtures
- `src/paperless/tests/parsers/conftest.py` — remote parser instance,
  sample-file, and settings-helper fixtures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor: use fixture factory and usefixtures in remote parser tests

- `_make_azure_mock` helper promoted to `make_azure_mock` factory fixture
  in conftest.py; tests call `make_azure_mock()` or
  `make_azure_mock("custom text")` instead of a module-level function
- `azure_settings` and `no_engine_settings` applied via
  `@pytest.mark.usefixtures` wherever their value is not referenced
  inside the test body; `TestRemoteParserParseError` marked at the class
  level since all three tests need the same setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor: improve remote parser test fixture structure

- make_azure_mock moved from conftest.py back into test_remote_parser.py;
  it is specific to that module and does not belong in shared fixtures
- azure_client fixture composes azure_settings + make_azure_mock + patch
  in one step; tests no longer repeat the mocker.patch call or carry an
  unused azure_settings parameter
- failing_azure_client fixture similarly composes azure_settings + patch
  with a RuntimeError side effect; TestRemoteParserParseError now only
  receives the mock it actually uses
- All @pytest.mark.parametrize calls use pytest.param with explicit ids
  (pdf, png, jpeg, ...) for readable test output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor: wire RemoteDocumentParser into consumer and fix signals

- paperless_remote/signals.py: import from paperless.parsers.remote
  (new location after git mv). supported_mime_types() is now a
  classmethod that always returns the full set, so get_supported_mime_types()
  in the signal layer explicitly checks RemoteEngineConfig validity and
  returns {} when unconfigured — preserving the old behaviour where an
  unconfigured remote parser does not register for any MIME types.

- documents/consumer.py: extend the _parser_cleanup() shim, parse()
  dispatch, and get_thumbnail() dispatch to include RemoteDocumentParser
  alongside TextDocumentParser. Both new-style parsers use __exit__
  for cleanup and take (document_path, mime_type) without a file_name
  argument.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor: fix type errors in remote parser and signals

- remote.py: add `if TYPE_CHECKING: assert` guards before the Azure
  client construction to narrow config.endpoint and config.api_key from
  str|None to str. The narrowing is safe: engine_is_valid() guarantees
  both are non-None when it returns True (api_key explicitly; endpoint
  via `not (engine=="azureai" and endpoint is None)` for the only valid
  engine). Asserts are wrapped in TYPE_CHECKING so they carry zero
  runtime cost.

- signals.py: add full type annotations — return types, Any-typed
  sender parameter, and explicit logging_group argument replacing *args.
  Add `from __future__ import annotations` for consistent annotation style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: get_parser factory forwards logging_group, drops progress_callback

consumer.py calls parser_class(logging_group, progress_callback=...).
RemoteDocumentParser.__init__ accepts logging_group but not
progress_callback, so only the latter is dropped — matching the pattern
established by the TextDocumentParser signals shim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: text parser get_parser forwards logging_group, drops progress_callback

TextDocumentParser.__init__ accepts logging_group: object = None, same
as RemoteDocumentParser. The old shim incorrectly dropped it; fix to
forward it as a positional arg and only drop progress_callback.
Add type annotations and from __future__ import annotations for
consistency with the remote parser signals shim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:19:46 -07:00
Trenton H
b0bb31654f Bumps zensical to 0.0.26 to resolve the wheel building it tries to do (#12392) 2026-03-18 22:53:34 +00:00
Trenton H
0f7c02de5e Fix: test: add regression test for workflow save clobbering filename (#12390)
Add test_workflow_document_updated_does_not_overwrite_filename to
verify that run_workflows (DOCUMENT_UPDATED path) does not revert a
DB filename that was updated by a concurrent bulk_update_documents
task's update_filename_and_move_files call.

The test replicates the race window by:
  - Updating the DB filename directly (simulating BUD-1 completing)
  - Mocking refresh_from_db so the stale in-memory filename persists
  - Asserting the DB filename is not clobbered after run_workflows

Relates to: https://github.com/paperless-ngx/paperless-ngx/issues/12386

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:31:09 -07:00
Trenton H
95dea787f2 Fix: don't try to usermod/groupmod when non-root + update docs (#12365) (#12391) 2026-03-18 10:38:45 -07:00
shamoon
b6501b0c47 Fix: avoid moving files if already moved (#12389) 2026-03-18 09:51:48 -07:00
dependabot[bot]
d162c83eb7 Chore(deps): Bump ujson from 5.11.0 to 5.12.0 (#12387)
Bumps [ujson](https://github.com/ultrajson/ultrajson) from 5.11.0 to 5.12.0.
- [Release notes](https://github.com/ultrajson/ultrajson/releases)
- [Commits](https://github.com/ultrajson/ultrajson/compare/5.11.0...5.12.0)

---
updated-dependencies:
- dependency-name: ujson
  dependency-version: 5.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 09:19:36 -07:00
shamoon
d3ac75741f Update serialisers.py 2026-03-18 07:09:51 -07:00
shamoon
87ebd13abc Fix: remove pagination from document notes api spec (#12388) 2026-03-18 06:48:05 -07:00
dependabot[bot]
3abff21d1f Chore(deps): Bump pyasn1 from 0.6.2 to 0.6.3 (#12370)
Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.2 to 0.6.3.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.2...v0.6.3)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 06:51:56 +00:00
dependabot[bot]
0a08499fc7 Chore(deps): Bump https://github.com/astral-sh/ruff-pre-commit (#12371)
Bumps the pre-commit-dependencies group with 1 update: [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit).


Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.15.5 to 0.15.6
- [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases)
- [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.5...v0.15.6)

---
updated-dependencies:
- dependency-name: https://github.com/astral-sh/ruff-pre-commit
  dependency-version: 0.15.6
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 06:25:40 +00:00
dependabot[bot]
330ee696a8 Chore(deps): Bump the actions group with 2 updates (#12377)
Bumps the actions group with 2 updates: [docker/metadata-action](https://github.com/docker/metadata-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `docker/metadata-action` from 5.10.0 to 6.0.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5.10.0...v6.0.0)

Updates `docker/build-push-action` from 6.19.2 to 7.0.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.19.2...v7.0.0)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 06:18:11 +00:00
dependabot[bot]
b98697ab8b Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates (#12382)
Bumps the utilities-patch group with 2 updates in the / directory: [llama-index-core](https://github.com/run-llama/llama_index) and [zensical](https://github.com/zensical/zensical).


Updates `llama-index-core` from 0.14.15 to 0.14.16
- [Release notes](https://github.com/run-llama/llama_index/releases)
- [Changelog](https://github.com/run-llama/llama_index/blob/main/CHANGELOG.md)
- [Commits](https://github.com/run-llama/llama_index/compare/v0.14.15...v0.14.16)

Updates `zensical` from 0.0.24 to 0.0.25
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.24...v0.0.25)

---
updated-dependencies:
- dependency-name: llama-index-core
  dependency-version: 0.14.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: zensical
  dependency-version: 0.0.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 22:31:04 -07:00
dependabot[bot]
7e94dd8208 Chore(deps): Bump openai in the utilities-minor group (#12379)
Bumps the utilities-minor group with 1 update: [openai](https://github.com/openai/openai-python).


Updates `openai` from 2.24.0 to 2.26.0
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.24.0...v2.26.0)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 2.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 04:29:01 +00:00
dependabot[bot]
79da72f69c Chore(deps-dev): Bump types-python-dateutil (#12380)
Bumps [types-python-dateutil](https://github.com/typeshed-internal/stub_uploader) from 2.9.0.20260124 to 2.9.0.20260305.
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

---
updated-dependencies:
- dependency-name: types-python-dateutil
  dependency-version: 2.9.0.20260305
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 04:07:06 +00:00
dependabot[bot]
261ae9d8ce Chore(deps): Update django-allauth[mfa,socialaccount] requirement (#12381)
Updates the requirements on [django-allauth[mfa,socialaccount]](https://github.com/sponsors/pennersr) to permit the latest version.
- [Commits](https://github.com/sponsors/pennersr/commits)

---
updated-dependencies:
- dependency-name: django-allauth[mfa,socialaccount]
  dependency-version: 65.15.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 03:55:03 +00:00
dependabot[bot]
0e2c191524 Chore(deps-dev): Bump the frontend-jest-dependencies group (#12374)
Bumps the frontend-jest-dependencies group in /src-ui with 2 updates: [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) and [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom).


Updates `jest` from 30.2.0 to 30.3.0
- [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.3.0/packages/jest)

Updates `jest-environment-jsdom` from 30.2.0 to 30.3.0
- [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.3.0/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: jest
  dependency-version: 30.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-jest-dependencies
- dependency-name: jest-environment-jsdom
  dependency-version: 30.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-jest-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 01:15:52 +00:00
dependabot[bot]
ab4656692d Chore(deps): Bump @ng-select/ng-select (#12373)
Bumps the frontend-angular-dependencies group in /src-ui with 1 update: [@ng-select/ng-select](https://github.com/ng-select/ng-select).


Updates `@ng-select/ng-select` from 21.4.1 to 21.5.2
- [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/v21.4.1...v21.5.2)

---
updated-dependencies:
- dependency-name: "@ng-select/ng-select"
  dependency-version: 21.5.2
  dependency-type: direct:production
  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>
2026-03-18 01:07:48 +00:00
dependabot[bot]
03e2c352c2 Chore(deps-dev): Bump @types/node from 25.3.3 to 25.4.0 in /src-ui (#12376)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.3.3 to 25.4.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: 25.4.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>
2026-03-18 00:56:32 +00:00
dependabot[bot]
2d46ed9692 Chore(deps-dev): Bump the frontend-eslint-dependencies group (#12375)
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.54.0 to 8.57.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.57.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.54.0 to 8.57.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.57.0/packages/parser)

Updates `@typescript-eslint/utils` from 8.54.0 to 8.57.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.57.0/packages/utils)

Updates `eslint` from 10.0.2 to 10.0.3
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.2...v10.0.3)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: eslint
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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>
2026-03-18 00:41:24 +00:00
GitHub Actions
8d23d17ae8 Auto translate strings 2026-03-17 22:44:54 +00:00
Trenton H
aea2927a02 Feature: Convert Tika parser to the plugin system (#12333)
* Chore: move Tika parser and tests to paperless/

Move TikaDocumentParser and its tests to the canonical parser package
location, matching the pattern established for TextDocumentParser:

- src/paperless_tika/parsers.py → src/paperless/parsers/tika.py
- src/paperless_tika/tests/test_tika_parser.py → src/paperless/tests/parsers/test_tika_parser.py
- src/paperless_tika/tests/samples/ → src/paperless/tests/samples/tika/

Merge tika fixtures (tika_parser, sample_odt_file, sample_docx_file,
sample_doc_file, sample_broken_odt) into the shared parsers conftest.
Remove the now-empty src/paperless_tika/tests/conftest.py.

Content is unchanged — this commit is rename-only so git history is
preserved on the moved files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Feature: Phase 3 — migrate TikaDocumentParser to ParserProtocol

Refactor TikaDocumentParser to satisfy ParserProtocol without subclassing
the legacy DocumentParser ABC:

- Add ClassVars: name, version, author, url
- Add supported_mime_types() classmethod (12 Office/ODF/RTF MIME types)
- Add score() classmethod — returns None when TIKA_ENABLED is False, 10 otherwise
- can_produce_archive = False (PDF is for display, not an OCR archive)
- requires_pdf_rendition = True (Office formats need PDF for browser display)
- __enter__/__exit__ via ExitStack: TikaClient opened once per parser
  lifetime and shared across parse() and extract_metadata() calls
- extract_metadata() falls back to a short-lived TikaClient when called
  outside a context manager (legacy view-layer metadata path)
- _convert_to_pdf() uses OutputTypeConfig() to honour the database-stored
  ApplicationConfiguration before falling back to the env-var setting
- Rename convert_to_pdf → _convert_to_pdf (private helper)

Update paperless_tika/signals.py shim to import from the new module path
and drop the legacy logging_group/progress_callback kwargs.

Update documents/consumer.py to extend the existing TextDocumentParser
special cases to also cover TikaDocumentParser (parse/get_thumbnail
signatures, __exit__ cleanup).

Add TestTikaParserRegistryInterface (7 tests) covering score(), properties,
and ParserProtocol isinstance check.  Update existing tests to use the new
accessor API (get_text, get_date, get_archive_path, _convert_to_pdf).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: update remaining imports and move live Tika tests after parser migration

- src/documents/tests/test_parsers.py: import TikaDocumentParser from
  paperless.parsers.tika (old paperless_tika.parsers no longer exists)
- git mv paperless_tika/tests/test_live_tika.py →
  paperless/tests/parsers/test_live_tika.py to co-locate all Tika tests
  with the parser; update import and replace old attribute API
  (tika_parser.text/.archive_path) with accessor methods
  (get_text/get_archive_path)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: satisfy mypy and pyrefly for TikaDocumentParser

Use a TYPE_CHECKING-guarded assert to narrow self._tika_client from
TikaClient | None to TikaClient at the point of use in parse().  The
assert is visible to type checkers (TYPE_CHECKING=True) so both mypy
and pyrefly accept the subsequent attribute accesses without error;
at runtime TYPE_CHECKING is False so the assert never executes and no
ruff S101 suppression is required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: require context manager for TikaDocumentParser; clean up client lifecycle

- consumer.py: call __enter__ for new-style parsers so _tika_client and
  _gotenberg_client are set before parse() is invoked
- views.py: use `with parser` (via nullcontext for old-style parsers) in
  get_metadata so extract_metadata always runs inside a context manager
- tika.py: GotenbergClient added to ExitStack alongside TikaClient;
  inline client creation removed from extract_metadata and _convert_to_pdf;
  __exit__ uses ExitStack.close() instead of __exit__ pass-through

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:43:28 -07:00
shamoon
a86c9d32fe Fix: fix file button hover color in dark mode (#12367) 2026-03-17 09:26:41 -07:00
GitHub Actions
d53dcad4f6 Auto translate strings 2026-03-17 15:24:49 +00:00
shamoon
736b08ad09 Tweak: use cancel instead of discard for app config button 2026-03-17 08:23:08 -07:00
shamoon
ca5879a54e Fix one test with explicit override 2026-03-16 23:03:31 -07:00
shamoon
4d4f30b5f8 Security: validate outbound llm URLs and block internal endpoints 2026-03-16 22:58:16 -07:00
Trenton H
85fecac401 Fix: don't try to usermod/groupmod when non-root + update docs (#12365) 2026-03-17 05:15:03 +00:00
shamoon
7942edfdf4 Fixhancement: only offer basic auth for appropriate requests (#12362) 2026-03-16 22:07:12 -07:00
Trenton H
470018c011 Chore: Mocks the celery and Redis pings so we don't wait for their timeout each time (#12354) 2026-03-16 20:12:17 +00:00
dependabot[bot]
54679a093a Chore(deps): Bump pyopenssl from 25.3.0 to 26.0.0 (#12363)
Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 25.3.0 to 26.0.0.
- [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/pyopenssl/compare/25.3.0...26.0.0)

---
updated-dependencies:
- dependency-name: pyopenssl
  dependency-version: 26.0.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 10:36:00 -07:00
dependabot[bot]
58ebcc21be Chore(deps): Bump pyjwt from 2.10.1 to 2.12.0 (#12335)
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.10.1 to 2.12.0.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.10.1...2.12.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 09:57:23 -07:00
Trenton H
1caa3eb8aa Chore: Disables the system checks for management commands in tests and when unnecessary (#12332) 2026-03-16 15:10:35 +00:00
shamoon
866c9fd858 Fix: correct merge bulk edit indentation 2026-03-15 23:50:54 -07:00
shamoon
2bb4af2be6 Change: sort custom fields alphabetically by default (#12358) 2026-03-15 22:52:02 -07:00
GitHub Actions
6b8ff9763d Auto translate strings 2026-03-16 01:52:49 +00:00
shamoon
6034f17c87 Format changelog 2026-03-15 18:51:06 -07:00
shamoon
48cd1cce6a Merge branch 'main' into dev 2026-03-15 18:50:42 -07:00
github-actions[bot]
1e00ad5f30 Documentation: Add v2.20.11 changelog (#12356)
* Changelog v2.20.11 - GHA

* Update changelog for version 2.20.11

Added security advisory and fixed dropdown list issues.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-15 18:47:18 -07:00
shamoon
5f26c01c6f Bump version to 2.20.11 2026-03-15 17:16:11 -07:00
shamoon
92e133eeb0 Merge branch 'release/v2.20.x' 2026-03-15 17:15:20 -07:00
shamoon
06b2d5102c Fix GHSA-59xh-5vwx-4c4q 2026-03-15 17:13:08 -07:00
Trenton H
9d69705e26 Feature: Add progress information to the classifier training for a better ux (#12331) 2026-03-14 19:53:52 +00:00
GitHub Actions
01abacab52 Auto translate strings 2026-03-14 05:09:13 +00:00
shamoon
88b8f9b326 Chore: bump Angular dependencies to 21.2.x (#12338) 2026-03-13 22:07:31 -07:00
dependabot[bot]
365ff99934 Bump ocrmypdf from 16.13.0 to 17.3.0 in the document-processing group (#12267)
* Bump ocrmypdf from 16.13.0 to 17.3.0 in the document-processing group

Bumps the document-processing group with 1 update: [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF).


Updates `ocrmypdf` from 16.13.0 to 17.3.0
- [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases)
- [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v16.13.0...v17.3.0)

---
updated-dependencies:
- dependency-name: ocrmypdf
  dependency-version: 17.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: document-processing
...

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

* Updates the argument name for v17

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2026-03-13 09:51:21 -07:00
Trenton H
d86cfdb088 Feature: Initial document parser plugin framework (#12294) 2026-03-12 21:53:17 +00:00
shamoon
40255cfdbb Fix: correct dropdown list active color in dark mode (#12328) 2026-03-12 14:06:16 -07:00
dependabot[bot]
c2e1085418 Chore(deps): Bump tornado from 6.5.4 to 6.5.5 (#12327)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.4 to 6.5.5.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.4...v6.5.5)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 13:44:41 -07:00
Trenton H
ee0d1a3094 Enhancement: Make the StatusConsumer truly async (#12298) 2026-03-12 13:27:35 -07:00
Trenton H
f15394fa5c Fix: Removes the double exec that prevented migrations from running (#12317) 2026-03-12 12:46:12 -07:00
dependabot[bot]
773eb25f7d Chore(deps): Bump the utilities-minor group across 1 directory with 5 updates (#12324)
* Chore(deps): Bump the utilities-minor group across 1 directory with 5 updates

Bumps the utilities-minor group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) | `2026.1.1` | `2026.3.1` |
| [filelock](https://github.com/tox-dev/py-filelock) | `3.20.3` | `3.25.0` |
| [scikit-learn](https://github.com/scikit-learn/scikit-learn) | `1.7.2` | `1.8.0` |
| [faker](https://github.com/joke2k/faker) | `40.5.1` | `40.8.0` |
| [pyrefly](https://github.com/facebook/pyrefly) | `0.54.0` | `0.55.0` |



Updates `drf-spectacular-sidecar` from 2026.1.1 to 2026.3.1
- [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2026.1.1...2026.3.1)

Updates `filelock` from 3.20.3 to 3.25.0
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.20.3...3.25.0)

Updates `scikit-learn` from 1.7.2 to 1.8.0
- [Release notes](https://github.com/scikit-learn/scikit-learn/releases)
- [Commits](https://github.com/scikit-learn/scikit-learn/compare/1.7.2...1.8.0)

Updates `faker` from 40.5.1 to 40.8.0
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v40.5.1...v40.8.0)

Updates `pyrefly` from 0.54.0 to 0.55.0
- [Release notes](https://github.com/facebook/pyrefly/releases)
- [Commits](https://github.com/facebook/pyrefly/compare/0.54.0...0.55.0)

---
updated-dependencies:
- dependency-name: drf-spectacular-sidecar
  dependency-version: 2026.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: filelock
  dependency-version: 3.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: scikit-learn
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: faker
  dependency-version: 40.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: pyrefly
  dependency-version: 0.55.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
...

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

* Dont know what your problem is dependabot

---------

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>
2026-03-12 12:30:42 -07:00
shamoon
d919c341b1 Fix: clear descendant selections in dropdown when parent toggled (#12326) 2026-03-12 11:57:35 -07:00
dependabot[bot]
e2947ccff2 Chore(deps): Bump the pre-commit-dependencies group with 4 updates (#12323)
* Chore(deps): Bump the pre-commit-dependencies group with 4 updates

---
updated-dependencies:
- dependency-name: https://github.com/codespell-project/codespell
  dependency-version: 2.4.2
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: prettier
  dependency-version: 3.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pre-commit-dependencies
- dependency-name: prettier-plugin-organize-imports
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/lovesegfault/beautysh
  dependency-version: 6.4.3
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
...

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

* Drop this, it seems more trouble than its worth

* Re-run prek with new prettier

---------

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>
2026-03-12 16:29:57 +00:00
dependabot[bot]
61841a767b Chore(deps): Bump the actions group with 3 updates (#12322)
Bumps the actions group with 3 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [docker/login-action](https://github.com/docker/login-action) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `docker/setup-buildx-action` from 3.12.0 to 4.0.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.12.0...v4.0.0)

Updates `docker/login-action` from 3.7.0 to 4.0.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.7.0...v4.0.0)

Updates `actions/setup-node` from 6.2.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6.2.0...v6.3.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2026-03-12 09:04:22 -07:00
GitHub Actions
15db023caa Auto translate strings 2026-03-12 15:44:21 +00:00
shamoon
45b363659e Chore: mark document detail email action as deprecated (#12308) 2026-03-12 15:42:14 +00:00
Trenton H
7494161c95 Add dependency groups for pre-commit dependencies 2026-03-12 08:04:21 -07:00
Trenton H
5331312699 Remove cooldown for pre-commit updates (it's not supported)
Removed the default cooldown period for pre-commit updates.
2026-03-12 07:59:27 -07:00
Trenton H
b5a002b8ed Chore: Enable dependabot for pre-commit (#12305) 2026-03-12 07:52:43 -07:00
shamoon
ba0a80a8ad Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count (#12302) 2026-03-11 07:39:32 -07:00
shamoon
60319c6d37 Fix: prevent stale db filename during workflow actions (#12289) 2026-03-09 19:32:46 -07:00
270 changed files with 20139 additions and 12094 deletions

View File

@@ -21,6 +21,7 @@ body:
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation). - [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues). - [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
- Disable any custom container initialization scripts, if using - Disable any custom container initialization scripts, if using
- Remove any third-party parser plugins — issues caused by or requiring changes to a third-party plugin will be closed without investigation.
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support). If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
- type: textarea - type: textarea

View File

@@ -12,6 +12,8 @@ updates:
open-pull-requests-limit: 10 open-pull-requests-limit: 10
schedule: schedule:
interval: "monthly" interval: "monthly"
cooldown:
default-days: 7
labels: labels:
- "frontend" - "frontend"
- "dependencies" - "dependencies"
@@ -36,7 +38,9 @@ updates:
directory: "/" directory: "/"
# Check for updates once a week # Check for updates once a week
schedule: schedule:
interval: "weekly" interval: "monthly"
cooldown:
default-days: 7
labels: labels:
- "backend" - "backend"
- "dependencies" - "dependencies"
@@ -97,6 +101,8 @@ updates:
schedule: schedule:
# Check for updates to GitHub Actions every month # Check for updates to GitHub Actions every month
interval: "monthly" interval: "monthly"
cooldown:
default-days: 7
labels: labels:
- "ci-cd" - "ci-cd"
- "dependencies" - "dependencies"
@@ -112,7 +118,9 @@ updates:
- "/" - "/"
- "/.devcontainer/" - "/.devcontainer/"
schedule: schedule:
interval: "weekly" interval: "monthly"
cooldown:
default-days: 7
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
@@ -123,7 +131,9 @@ updates:
- package-ecosystem: "docker-compose" - package-ecosystem: "docker-compose"
directory: "/docker/compose/" directory: "/docker/compose/"
schedule: schedule:
interval: "weekly" interval: "monthly"
cooldown:
default-days: 7
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
@@ -147,3 +157,14 @@ updates:
postgres: postgres:
patterns: patterns:
- "docker.io/library/postgres*" - "docker.io/library/postgres*"
greenmail:
patterns:
- "docker.io/greenmail*"
- package-ecosystem: "pre-commit" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
groups:
pre-commit-dependencies:
patterns:
- "*"

View File

@@ -21,7 +21,7 @@ jobs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }} backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Decide run mode - name: Decide run mode
@@ -49,7 +49,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@v3.0.2 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -71,18 +71,18 @@ jobs:
fail-fast: false fail-fast: false
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Start containers - name: Start containers
run: | run: |
docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7.3.1 uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -119,13 +119,13 @@ jobs:
pytest pytest
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: always() if: always()
uses: codecov/codecov-action@v5.5.2 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: junit.xml files: junit.xml
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v5.5.2 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: coverage.xml files: coverage.xml
@@ -144,14 +144,14 @@ jobs:
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.12"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7.3.1 uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -173,7 +173,7 @@ jobs:
check \ check \
src/ src/
- name: Cache Mypy - name: Cache Mypy
uses: actions/cache@v5.0.3 uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: .mypy_cache path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes # Keyed by OS, Python version, and dependency hashes

View File

@@ -41,7 +41,7 @@ jobs:
ref-name: ${{ steps.ref.outputs.name }} ref-name: ${{ steps.ref.outputs.name }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Determine ref name - name: Determine ref name
id: ref id: ref
run: | run: |
@@ -104,9 +104,9 @@ jobs:
echo "repository=${repo_name}" echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.7.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -119,7 +119,7 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY" sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- name: Docker metadata - name: Docker metadata
id: docker-meta id: docker-meta
uses: docker/metadata-action@v5.10.0 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }} ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
@@ -130,7 +130,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@v6.19.2 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -152,7 +152,7 @@ jobs:
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt" echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
- name: Upload digest - name: Upload digest
if: steps.check-push.outputs.should-push == 'true' if: steps.check-push.outputs.should-push == 'true'
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: digests-${{ matrix.arch }} name: digests-${{ matrix.arch }}
path: /tmp/digests/digest-${{ matrix.arch }}.txt path: /tmp/digests/digest-${{ matrix.arch }}.txt
@@ -169,7 +169,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Download digests - name: Download digests
uses: actions/download-artifact@v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
path: /tmp/digests path: /tmp/digests
pattern: digest-*.txt pattern: digest-*.txt
@@ -179,29 +179,29 @@ jobs:
echo "Downloaded digests:" echo "Downloaded digests:"
ls -la /tmp/digests/ ls -la /tmp/digests/
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.7.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.7.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io - name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.7.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }} password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Docker metadata - name: Docker metadata
id: docker-meta id: docker-meta
uses: docker/metadata-action@v5.10.0 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }} ${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}

View File

@@ -23,7 +23,7 @@ jobs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }} docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Decide run mode - name: Decide run mode
@@ -51,7 +51,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@v3.0.2 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -68,16 +68,16 @@ jobs:
name: Build Documentation name: Build Documentation
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/configure-pages@v5.0.0 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7.3.1 uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -93,7 +93,7 @@ jobs:
--frozen \ --frozen \
zensical build --clean zensical build --clean
- name: Upload GitHub Pages artifact - name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v4.0.0 uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with: with:
path: site path: site
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }} name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
@@ -107,7 +107,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- name: Deploy GitHub Pages - name: Deploy GitHub Pages
uses: actions/deploy-pages@v4.0.5 uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
id: deployment id: deployment
with: with:
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }} artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}

View File

@@ -18,7 +18,7 @@ jobs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }} frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Decide run mode - name: Decide run mode
@@ -46,7 +46,7 @@ jobs:
- name: Detect changes - name: Detect changes
id: filter id: filter
if: steps.force.outputs.run_all != 'true' if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@v3.0.2 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with: with:
base: ${{ steps.range.outputs.base }} base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }} ref: ${{ steps.range.outputs.ref }}
@@ -61,20 +61,20 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@v6.2.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v5.0.3 uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -89,19 +89,19 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@v6.2.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@v5.0.3 uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -124,19 +124,19 @@ jobs:
shard-count: [4] shard-count: [4]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@v6.2.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@v5.0.3 uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -148,13 +148,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: always() if: always()
uses: codecov/codecov-action@v5.5.2 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/ directory: src-ui/
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v5.5.2 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/ directory: src-ui/coverage/
@@ -175,19 +175,19 @@ jobs:
shard-count: [2] shard-count: [2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@v6.2.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@v5.0.3 uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -206,21 +206,21 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@v6.2.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@v5.0.3 uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store

View File

@@ -15,10 +15,10 @@ jobs:
runs-on: ubuntu-slim runs-on: ubuntu-slim
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Python - name: Install Python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.14" python-version: "3.14"
- name: Run prek - name: Run prek
uses: j178/prek-action@v1.1.1 uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Wait for Docker build - name: Wait for Docker build
uses: lewagon/wait-on-check-action@v1.5.0 uses: lewagon/wait-on-check-action@74049309dfeff245fe8009a0137eacf28136cb3c # v1.5.0
with: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
check-name: 'Build Docker Image' check-name: 'Build Docker Image'
@@ -28,14 +28,14 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ---- Frontend Build ---- # ---- Frontend Build ----
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@v6.2.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
@@ -47,11 +47,11 @@ jobs:
# ---- Backend Setup ---- # ---- Backend Setup ----
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7.3.1 uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -118,7 +118,7 @@ jobs:
sudo chown -R 1000:1000 paperless-ngx/ sudo chown -R 1000:1000 paperless-ngx/
tar -cJf paperless-ngx.tar.xz paperless-ngx/ tar -cJf paperless-ngx.tar.xz paperless-ngx/
- name: Upload release artifact - name: Upload release artifact
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: release name: release
path: dist/paperless-ngx.tar.xz path: dist/paperless-ngx.tar.xz
@@ -133,7 +133,7 @@ jobs:
version: ${{ steps.get-version.outputs.version }} version: ${{ steps.get-version.outputs.version }}
steps: steps:
- name: Download release artifact - name: Download release artifact
uses: actions/download-artifact@v8.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: release name: release
path: ./ path: ./
@@ -148,7 +148,7 @@ jobs:
fi fi
- name: Create release and changelog - name: Create release and changelog
id: create-release id: create-release
uses: release-drafter/release-drafter@v6.2.0 uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
with: with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }} name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }} tag: ${{ steps.get-version.outputs.version }}
@@ -159,7 +159,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive - name: Upload release archive
uses: shogo82148/actions-upload-release-asset@v1.9.2 uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1.10.0
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }} upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -176,16 +176,16 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: main ref: main
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7.3.1 uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -218,7 +218,7 @@ jobs:
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
git push origin ${{ needs.publish-release.outputs.version }}-changelog git push origin ${{ needs.publish-release.outputs.version }}-changelog
- name: Create pull request - name: Create pull request
uses: actions/github-script@v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const { repo, owner } = context.repo; const { repo, owner } = context.repo;

View File

@@ -27,7 +27,7 @@ jobs:
steps: steps:
- name: Clean temporary images - name: Clean temporary images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.12.0 uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
@@ -53,7 +53,7 @@ jobs:
steps: steps:
- name: Clean untagged images - name: Clean untagged images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.12.0 uses: stumpylog/image-cleaner-action/untagged@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"

View File

@@ -34,10 +34,10 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support # Learn more about CodeQL language support at https://git.io/codeql-language-support
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4.32.5 uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -45,4 +45,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file. # Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main # queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.32.5 uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5

View File

@@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@v2.15.0 uses: crowdin/github-action@8818ff65bfc4322384f983ea37e3926948c11745 # v2.15.0
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true

View File

@@ -10,7 +10,7 @@ jobs:
issues: read issues: read
pull-requests: write pull-requests: write
steps: steps:
- uses: peakoss/anti-slop@v0.2.1 - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
with: with:
max-failures: 4 max-failures: 4
failure-add-pr-labels: 'ai' failure-add-pr-labels: 'ai'
@@ -23,11 +23,11 @@ jobs:
steps: steps:
- name: Label PR by file path or branch name - name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config # see .github/labeler.yml for the labeler config
uses: actions/labeler@v6.0.1 uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size - name: Label by size
uses: Gascon1/pr-size-labeler@v1.3.0 uses: Gascon1/pr-size-labeler@deff8ed00a76639a7c0f197525bafa3350ba4c36 # v1.3.0
with: with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_label: 'small-change' xs_label: 'small-change'
@@ -37,7 +37,7 @@ jobs:
fail_if_xl: 'false' fail_if_xl: 'false'
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$ excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
- name: Label by PR title - name: Label by PR title
uses: actions/github-script@v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
@@ -63,7 +63,7 @@ jobs:
} }
- name: Label bot-generated PRs - name: Label bot-generated PRs
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }} if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
uses: actions/github-script@v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
@@ -88,7 +88,7 @@ jobs:
} }
- name: Welcome comment - name: Welcome comment
if: ${{ !contains(github.actor, 'bot') }} if: ${{ !contains(github.actor, 'bot') }}
uses: actions/github-script@v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;

View File

@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot' if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps: steps:
- name: Label PR with release-drafter - name: Label PR with release-drafter
uses: release-drafter/release-drafter@v6.2.0 uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -15,7 +15,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/stale@v10.2.0 - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with: with:
days-before-stale: 7 days-before-stale: 7
days-before-close: 14 days-before-close: 14
@@ -37,7 +37,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: dessant/lock-threads@v6.0.0 - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with: with:
issue-inactive-days: '30' issue-inactive-days: '30'
pr-inactive-days: '30' pr-inactive-days: '30'
@@ -57,7 +57,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@v8.0.0 - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {
@@ -114,7 +114,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@v8.0.0 - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {
@@ -206,7 +206,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/github-script@v8.0.0 - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
function sleep(ms) { function sleep(ms) {

View File

@@ -11,7 +11,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
env: env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with: with:
@@ -19,13 +19,13 @@ jobs:
ref: ${{ env.GH_REF }} ref: ${{ env.GH_REF }}
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7.3.1 uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with: with:
enable-cache: true enable-cache: true
- name: Install backend python dependencies - name: Install backend python dependencies
@@ -36,18 +36,18 @@ jobs:
- name: Generate backend translation strings - name: Generate backend translation strings
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*" run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
uses: actions/setup-node@v6.2.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.x node-version: 24.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v5.0.3 uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -63,7 +63,7 @@ jobs:
cd src-ui cd src-ui
pnpm run ng extract-i18n pnpm run ng extract-i18n
- name: Commit changes - name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v7.1.0 uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
with: with:
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po' file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
commit_message: "Auto translate strings" commit_message: "Auto translate strings"

View File

@@ -2437,17 +2437,3 @@ src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "Non
src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr] src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr]
src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr] src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "unpaper_clean" [union-attr]
src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "user_args" [union-attr] src/paperless_tesseract/tests/test_parser_custom_settings.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "user_args" [union-attr]
src/paperless_text/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_text/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_text/parsers.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
src/paperless_text/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_text/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Argument 1 to "make_thumbnail_from_pdf" has incompatible type "None"; expected "Path" [arg-type]
src/paperless_tika/parsers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/paperless_tika/parsers.py:0: error: Incompatible types in assignment (expression has type "str | None", variable has type "None") [assignment]
src/paperless_tika/signals.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless_tika/signals.py:0: error: Function is missing a type annotation [no-untyped-def]

View File

@@ -29,7 +29,7 @@ repos:
- id: check-case-conflict - id: check-case-conflict
- id: detect-private-key - id: detect-private-key
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.4.1 rev: v2.4.2
hooks: hooks:
- id: codespell - id: codespell
additional_dependencies: [tomli] additional_dependencies: [tomli]
@@ -46,11 +46,11 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.3.3 - prettier@3.8.1
- 'prettier-plugin-organize-imports@4.1.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.5 rev: v0.15.6
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
@@ -65,7 +65,7 @@ repos:
- id: hadolint - id: hadolint
# Shell script hooks # Shell script hooks
- repo: https://github.com/lovesegfault/beautysh - repo: https://github.com/lovesegfault/beautysh
rev: v6.4.2 rev: v6.4.3
hooks: hooks:
- id: beautysh - id: beautysh
types: [file] types: [file]

View File

@@ -5,14 +5,6 @@ const config = {
singleQuote: true, singleQuote: true,
// https://prettier.io/docs/en/options.html#trailing-commas // https://prettier.io/docs/en/options.html#trailing-commas
trailingComma: 'es5', trailingComma: 'es5',
overrides: [
{
files: ['docs/*.md'],
options: {
tabWidth: 4,
},
},
],
plugins: [require('prettier-plugin-organize-imports')], plugins: [require('prettier-plugin-organize-imports')],
} }

View File

@@ -18,13 +18,13 @@ services:
- "--log-level=warn" - "--log-level=warn"
- "--log-format=text" - "--log-format=text"
tika: tika:
image: docker.io/apache/tika:latest image: docker.io/apache/tika:3.2.3.0
hostname: tika hostname: tika
container_name: tika container_name: tika
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: greenmail/standalone:2.1.8 image: docker.io/greenmail/standalone:2.1.8
hostname: greenmail hostname: greenmail
container_name: greenmail container_name: greenmail
environment: environment:

View File

@@ -10,12 +10,10 @@ cd "${PAPERLESS_SRC_DIR}"
# The whole migrate, with flock, needs to run as the right user # The whole migrate, with flock, needs to run as the right user
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py check --tag compatibility paperless python3 manage.py check --tag compatibility paperless || exit 1
exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input exec s6-setlock -n "${data_dir}/migration_lock" python3 manage.py migrate --skip-checks --no-input
else else
exec s6-setuidgid paperless \ s6-setuidgid paperless python3 manage.py check --tag compatibility paperless || exit 1
s6-setlock -n "${data_dir}/migration_lock" \
python3 manage.py check --tag compatibility paperless
exec s6-setuidgid paperless \ exec s6-setuidgid paperless \
s6-setlock -n "${data_dir}/migration_lock" \ s6-setlock -n "${data_dir}/migration_lock" \
python3 manage.py migrate --skip-checks --no-input python3 manage.py migrate --skip-checks --no-input

View File

@@ -2,6 +2,17 @@
# shellcheck shell=bash # shellcheck shell=bash
declare -r log_prefix="[init-user]" declare -r log_prefix="[init-user]"
# When the container is started as a non-root user (e.g. via `user: 999:999`
# in Docker Compose), usermod/groupmod require root and are meaningless.
# USERMAP_* variables only apply to the root-started path.
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
if [[ -n "${USERMAP_UID}" || -n "${USERMAP_GID}" ]]; then
echo "${log_prefix} WARNING: USERMAP_UID/USERMAP_GID are set but have no effect when the container is started as a non-root user"
fi
echo "${log_prefix} Running as non-root user ($(id --user):$(id --group)), skipping UID/GID remapping"
exit 0
fi
declare -r usermap_original_uid=$(id -u paperless) declare -r usermap_original_uid=$(id -u paperless)
declare -r usermap_original_gid=$(id -g paperless) declare -r usermap_original_gid=$(id -g paperless)
declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid} declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid}

View File

@@ -34,7 +34,6 @@ Options available to docker installations:
order to access them. order to access them.
Paperless uses 4 volumes: Paperless uses 4 volumes:
- `paperless_media`: This is where your documents are stored. - `paperless_media`: This is where your documents are stored.
- `paperless_data`: This is where auxiliary data is stored. This - `paperless_data`: This is where auxiliary data is stored. This
folder also contains the SQLite database, if you use it. folder also contains the SQLite database, if you use it.

View File

@@ -723,6 +723,81 @@ services:
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes 1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
## Installing third-party parser plugins {#parser-plugins}
Third-party parser plugins extend Paperless-ngx to support additional file
formats. A plugin is a Python package that advertises itself under the
`paperless_ngx.parsers` entry point group. Refer to the
[developer documentation](development.md#making-custom-parsers) for how to
create one.
!!! warning "Third-party plugins are not officially supported"
The Paperless-ngx maintainers do not provide support for third-party
plugins. Issues caused by or requiring changes to a third-party plugin
will be closed without further investigation. Always reproduce problems
with all plugins removed before filing a bug report.
### Docker
Use a [custom container initialization script](#custom-container-initialization)
to install the package before the webserver starts. Create a shell script and
mount it into `/custom-cont-init.d`:
```bash
#!/bin/bash
# /path/to/my/scripts/install-parsers.sh
pip install my-paperless-parser-package
```
Mount it in your `docker-compose.yml`:
```yaml
services:
webserver:
# ...
volumes:
- /path/to/my/scripts:/custom-cont-init.d:ro
```
The script runs as `root` before the webserver starts, so the package will be
available when Paperless-ngx discovers plugins at startup.
### Bare metal
Install the package into the same Python environment that runs Paperless-ngx.
If you followed the standard bare-metal install guide, that is the `paperless`
user's environment:
```bash
sudo -Hu paperless pip3 install my-paperless-parser-package
```
If you are using `uv` or a virtual environment, activate it first and then run:
```bash
uv pip install my-paperless-parser-package
# or
pip install my-paperless-parser-package
```
Restart all Paperless-ngx services after installation so the new plugin is
discovered.
### Verifying installation
On the next startup, check the application logs for a line confirming
discovery:
```
Loaded third-party parser 'My Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
```
If this line does not appear, verify that the package is installed in the
correct environment and that its `pyproject.toml` declares the
`paperless_ngx.parsers` entry point.
## MySQL Caveats {#mysql-caveats} ## MySQL Caveats {#mysql-caveats}
### Case Sensitivity ### Case Sensitivity

View File

@@ -437,3 +437,6 @@ Initial API version.
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
the bulk edit endpoint is still supported for compatibility with versions < 10 until support the bulk edit endpoint is still supported for compatibility with versions < 10 until support
for API v9 is dropped. for API v9 is dropped.
- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
large lists of object IDs for operations affecting many objects.

View File

@@ -1,5 +1,56 @@
# Changelog # Changelog
## paperless-ngx 2.20.12
### Security
- Resolve [GHSA-96jx-fj7m-qh6x](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-96jx-fj7m-qh6x)
### Bug Fixes
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
- Fix: don't try to usermod/groupmod when non-root + update docs (#<!---->12365) [@stumpylog](https://github.com/stumpylog) ([#12391](https://github.com/paperless-ngx/paperless-ngx/pull/12391))
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
### All App Changes
<details>
<summary>5 changes</summary>
- Fix: Scope the workflow saves to prevent clobbering filename/archive_filename [@stumpylog](https://github.com/stumpylog) ([#12390](https://github.com/paperless-ngx/paperless-ngx/pull/12390))
- Fix: avoid moving files if already moved [@shamoon](https://github.com/shamoon) ([#12389](https://github.com/paperless-ngx/paperless-ngx/pull/12389))
- Fix: remove pagination from document notes api spec [@shamoon](https://github.com/shamoon) ([#12388](https://github.com/paperless-ngx/paperless-ngx/pull/12388))
- Fix: fix file button hover color in dark mode [@shamoon](https://github.com/shamoon) ([#12367](https://github.com/paperless-ngx/paperless-ngx/pull/12367))
- Fixhancement: only offer basic auth for appropriate requests [@shamoon](https://github.com/shamoon) ([#12362](https://github.com/paperless-ngx/paperless-ngx/pull/12362))
</details>
## paperless-ngx 2.20.11
### Security
- Resolve [GHSA-59xh-5vwx-4c4q](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-59xh-5vwx-4c4q)
### Bug Fixes
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
</details>
## paperless-ngx 2.20.10 ## paperless-ngx 2.20.10
### Bug Fixes ### Bug Fixes
@@ -6260,7 +6311,6 @@ This release contains new database migrations.
### paperless-ng 1.1.0 ### paperless-ng 1.1.0
- Document processing status - Document processing status
- Paperless now shows the status of processing documents on the - Paperless now shows the status of processing documents on the
dashboard in real time. dashboard in real time.
- Status notifications when - Status notifications when
@@ -6305,7 +6355,6 @@ This release contains new database migrations.
- Official support for Python 3.9. - Official support for Python 3.9.
- Other changes and fixes - Other changes and fixes
- Adjusted the default parallelization settings to run more than - Adjusted the default parallelization settings to run more than
one task in parallel on systems with 4 or less cores. This one task in parallel on systems with 4 or less cores. This
addresses issues with paperless not consuming any new files when addresses issues with paperless not consuming any new files when

View File

@@ -674,6 +674,9 @@ See the corresponding [django-allauth documentation](https://docs.allauth.org/en
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)). [PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
: For OpenID Connect providers, set `settings.token_auth_method` if your identity provider
requires a specific token endpoint authentication method.
Defaults to None, which does not enable any third party authentication systems. Defaults to None, which does not enable any third party authentication systems.
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP} #### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
@@ -1947,6 +1950,12 @@ current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "ll
Defaults to None. Defaults to None.
#### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS}
: If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
Defaults to true, which allows internal endpoints.
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON} #### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if : Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if

View File

@@ -94,7 +94,6 @@ first-time setup.
``` ```
7. You can now either ... 7. You can now either ...
- install Redis or - install Redis or
- use the included `scripts/start_services.sh` to use Docker to fire - use the included `scripts/start_services.sh` to use Docker to fire
@@ -371,122 +370,367 @@ docker build --file Dockerfile --tag paperless:local .
## Extending Paperless-ngx ## Extending Paperless-ngx
Paperless-ngx does not have any fancy plugin systems and will probably never Paperless-ngx supports third-party document parsers via a Python entry point
have. However, some parts of the application have been designed to allow plugin system. Plugins are distributed as ordinary Python packages and
easy integration of additional features without any modification to the discovered automatically at startup — no changes to the Paperless-ngx source
base code. are required.
!!! warning "Third-party plugins are not officially supported"
The Paperless-ngx maintainers do not provide support for third-party
plugins. Issues that are caused by or require changes to a third-party
plugin will be closed without further investigation. If you believe you
have found a bug in Paperless-ngx itself (not in a plugin), please
reproduce it with all third-party plugins removed before filing an issue.
### Making custom parsers ### Making custom parsers
Paperless-ngx uses parsers to add documents. A parser is Paperless-ngx uses parsers to add documents. A parser is responsible for:
responsible for:
- Retrieving the content from the original - Extracting plain-text content from the document
- Creating a thumbnail - Generating a thumbnail image
- _optional:_ Retrieving a created date from the original - _optional:_ Detecting the document's creation date
- _optional:_ Creating an archived document from the original - _optional:_ Producing a searchable PDF archive copy
Custom parsers can be added to Paperless-ngx to support more file types. In Custom parsers are distributed as ordinary Python packages and registered
order to do that, you need to write the parser itself and announce its via a [setuptools entry point](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
existence to Paperless-ngx. No changes to the Paperless-ngx source are required.
The parser itself must extend `documents.parsers.DocumentParser` and #### 1. Implementing the parser class
must implement the methods `parse` and `get_thumbnail`. You can provide
your own implementation to `get_date` if you don't want to rely on Your parser must satisfy the `ParserProtocol` structural interface defined in
Paperless-ngx' default date guessing mechanisms. `paperless.parsers`. The simplest approach is to write a plain class — no base
class is required, only the right attributes and methods.
**Class-level identity attributes**
The registry reads these before instantiating the parser, so they must be
plain class attributes (not instance attributes or properties):
```python ```python
class MyCustomParser(DocumentParser): class MyCustomParser:
name = "My Format Parser" # human-readable name shown in logs
def parse(self, document_path, mime_type): version = "1.0.0" # semantic version string
# This method does not return anything. Rather, you should assign author = "Acme Corp" # author / organisation
# whatever you got from the document to the following fields: url = "https://example.com/my-parser" # docs or issue tracker
# The content of the document.
self.text = "content"
# Optional: path to a PDF document that you created from the original.
self.archive_path = os.path.join(self.tempdir, "archived.pdf")
# Optional: "created" date of the document.
self.date = get_created_from_metadata(document_path)
def get_thumbnail(self, document_path, mime_type):
# This should return the path to a thumbnail you created for this
# document.
return os.path.join(self.tempdir, "thumb.webp")
``` ```
If you encounter any issues during parsing, raise a **Declaring supported MIME types**
`documents.parsers.ParseError`.
The `self.tempdir` directory is a temporary directory that is guaranteed Return a `dict` mapping MIME type strings to preferred file extensions
to be empty and removed after consumption finished. You can use that (including the leading dot). Paperless-ngx uses the extension when storing
directory to store any intermediate files and also use it to store the archive copies and serving files for download.
thumbnail / archived document.
After that, you need to announce your parser to Paperless-ngx. You need to
connect a handler to the `document_consumer_declaration` signal. Have a
look in the file `src/paperless_tesseract/apps.py` on how that's done.
The handler is a method that returns information about your parser:
```python ```python
def myparser_consumer_declaration(sender, **kwargs): @classmethod
def supported_mime_types(cls) -> dict[str, str]:
return { return {
"parser": MyCustomParser, "application/x-my-format": ".myf",
"weight": 0, "application/x-my-format-alt": ".myf",
"mime_types": {
"application/pdf": ".pdf",
"image/jpeg": ".jpg",
}
} }
``` ```
- `parser` is a reference to a class that extends `DocumentParser`. **Scoring**
- `weight` is used whenever two or more parsers are able to parse a
file: The parser with the higher weight wins. This can be used to
override the parsers provided by Paperless-ngx.
- `mime_types` is a dictionary. The keys are the mime types your
parser supports and the value is the default file extension that
Paperless-ngx should use when storing files and serving them for
download. We could guess that from the file extensions, but some
mime types have many extensions associated with them and the Python
methods responsible for guessing the extension do not always return
the same value.
## Using Visual Studio Code devcontainer When more than one parser can handle a file, the registry calls `score()` on
each candidate and picks the one with the highest result and equal scores favor third-party parsers over built-ins. Return `None` to
decline handling a file even though the MIME type is listed as supported (for
example, when a required external service is not configured).
Another easy way to get started with development is to use Visual Studio | Score | Meaning |
Code devcontainers. This approach will create a preconfigured development | ------ | --------------------------------------------------------------------------------- |
environment with all of the required tools and dependencies. | `None` | Decline — do not handle this file |
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers). | `10` | Default priority used by all built-in parsers |
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files | `20` | Priority used by the remote OCR built-in parser, allowing it to replace Tesseract |
contain more information about the specific tasks and launch configurations (see the | `> 10` | Override a built-in parser for the same MIME type |
non-standard "description" field).
To get started: ```python
@classmethod
def score(
cls,
mime_type: str,
filename: str,
path: "Path | None" = None,
) -> int | None:
# Inspect filename or file bytes here if needed.
return 10
```
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code. **Archive and rendition flags**
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start. ```python
@property
def can_produce_archive(self) -> bool:
"""True if parse() can produce a searchable PDF archive copy."""
return True # or False if your parser doesn't produce PDFs
3. In case your host operating system is Windows: @property
def requires_pdf_rendition(self) -> bool:
"""True if the original format cannot be displayed by a browser
(e.g. DOCX, ODT) and the PDF output must always be kept."""
return False
```
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this. **Context manager — temp directory lifecycle**
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This Paperless-ngx always uses parsers as context managers. Create a temporary
will initialize the database tables and create a superuser. Then you can compile the front end working directory in `__enter__` (or `__init__`) and remove it in `__exit__`
for production or run the frontend in debug mode. regardless of whether an exception occurred. Store intermediate files,
thumbnails, and archive PDFs inside this directory.
5. The project is ready for debugging, start either run the fullstack debug or individual debug ```python
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services** import shutil
import tempfile
from pathlib import Path
from typing import Self
from types import TracebackType
## Developing Date Parser Plugins from django.conf import settings
class MyCustomParser:
...
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
)
self._text: str | None = None
self._archive_path: Path | None = None
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
shutil.rmtree(self._tempdir, ignore_errors=True)
```
**Optional context — `configure()`**
The consumer calls `configure()` with a `ParserContext` after instantiation
and before `parse()`. If your parser doesn't need context, a no-op
implementation is fine:
```python
from paperless.parsers import ParserContext
def configure(self, context: ParserContext) -> None:
pass # override if you need context.mailrule_id, etc.
```
**Parsing**
`parse()` is the core method. It must not return a value; instead, store
results in instance attributes and expose them via the accessor methods below.
Raise `documents.parsers.ParseError` on any unrecoverable failure.
```python
from documents.parsers import ParseError
def parse(
self,
document_path: Path,
mime_type: str,
*,
produce_archive: bool = True,
) -> None:
try:
self._text = extract_text_from_my_format(document_path)
except Exception as e:
raise ParseError(f"Failed to parse {document_path}: {e}") from e
if produce_archive and self.can_produce_archive:
archive = self._tempdir / "archived.pdf"
convert_to_pdf(document_path, archive)
self._archive_path = archive
```
**Result accessors**
```python
def get_text(self) -> str | None:
return self._text
def get_date(self) -> "datetime.datetime | None":
# Return a datetime extracted from the document, or None to let
# Paperless-ngx use its default date-guessing logic.
return None
def get_archive_path(self) -> Path | None:
return self._archive_path
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
# If the format doesn't have the concept of pages, return None
return count_pages(document_path)
```
**Thumbnail**
`get_thumbnail()` may be called independently of `parse()`. Return the path
to a WebP image inside `self._tempdir`. The image should be roughly 500 × 700
pixels.
```python
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
thumb = self._tempdir / "thumb.webp"
render_thumbnail(document_path, thumb)
return thumb
```
**Optional methods**
These are called by the API on demand, not during the consumption pipeline.
Implement them if your format supports the information; otherwise return
`None` / `[]`.
```python
def extract_metadata(
self,
document_path: Path,
mime_type: str,
) -> "list[MetadataEntry]":
# Must never raise. Return [] if metadata cannot be read.
from paperless.parsers import MetadataEntry
return [
MetadataEntry(
namespace="https://example.com/ns/",
prefix="ex",
key="Author",
value="Alice",
)
]
```
#### 2. Registering via entry point
Add the following to your package's `pyproject.toml`. The key (left of `=`)
is an arbitrary name used only in log output; the value is the
`module:ClassName` import path.
```toml
[project.entry-points."paperless_ngx.parsers"]
my_parser = "my_package.parsers:MyCustomParser"
```
Install your package into the same Python environment as Paperless-ngx (or
add it to the Docker image), and the parser will be discovered automatically
on the next startup. No configuration changes are needed.
To verify discovery, check the application logs at startup for a line like:
```
Loaded third-party parser 'My Format Parser' v1.0.0 by Acme Corp (entrypoint: 'my_parser').
```
#### 3. Utilities
`paperless.parsers.utils` provides helpers you can import directly:
| Function | Description |
| --------------------------------------- | ---------------------------------------------------------------- |
| `read_file_handle_unicode_errors(path)` | Read a file as UTF-8, replacing invalid bytes instead of raising |
| `get_page_count_for_pdf(path)` | Count pages in a PDF using pikepdf |
| `extract_pdf_metadata(path)` | Extract XMP metadata from a PDF as a `list[MetadataEntry]` |
#### Minimal example
A complete, working parser for a hypothetical plain-XML format:
```python
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from typing import Self
from types import TracebackType
import xml.etree.ElementTree as ET
from django.conf import settings
from documents.parsers import ParseError
from paperless.parsers import ParserContext
class XmlDocumentParser:
name = "XML Parser"
version = "1.0.0"
author = "Acme Corp"
url = "https://example.com/xml-parser"
@classmethod
def supported_mime_types(cls) -> dict[str, str]:
return {"application/xml": ".xml", "text/xml": ".xml"}
@classmethod
def score(cls, mime_type: str, filename: str, path: Path | None = None) -> int | None:
return 10
@property
def can_produce_archive(self) -> bool:
return False
@property
def requires_pdf_rendition(self) -> bool:
return False
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
self._text: str | None = None
def __enter__(self) -> Self:
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
shutil.rmtree(self._tempdir, ignore_errors=True)
def configure(self, context: ParserContext) -> None:
pass
def parse(self, document_path: Path, mime_type: str, *, produce_archive: bool = True) -> None:
try:
tree = ET.parse(document_path)
self._text = " ".join(tree.getroot().itertext())
except ET.ParseError as e:
raise ParseError(f"XML parse error: {e}") from e
def get_text(self) -> str | None:
return self._text
def get_date(self):
return None
def get_archive_path(self) -> Path | None:
return None
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
from PIL import Image, ImageDraw
img = Image.new("RGB", (500, 700), color="white")
ImageDraw.Draw(img).text((10, 10), "XML Document", fill="black")
out = self._tempdir / "thumb.webp"
img.save(out, format="WEBP")
return out
def get_page_count(self, document_path: Path, mime_type: str) -> int | None:
return None
def extract_metadata(self, document_path: Path, mime_type: str) -> list:
return []
```
### Developing date parser plugins
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html). Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
### Creating a Date Parser Plugin #### Creating a Date Parser Plugin
To create a custom date parser plugin, you need to: To create a custom date parser plugin, you need to:
@@ -494,7 +738,7 @@ To create a custom date parser plugin, you need to:
2. Implement the required abstract method 2. Implement the required abstract method
3. Register your plugin via an entry point 3. Register your plugin via an entry point
#### 1. Implementing the Parser Class ##### 1. Implementing the Parser Class
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method: Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
@@ -534,7 +778,7 @@ class MyDateParserPlugin(DateParserPluginBase):
yield another_datetime yield another_datetime
``` ```
#### 2. Configuration and Helper Methods ##### 2. Configuration and Helper Methods
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides: Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
@@ -567,11 +811,11 @@ def _filter_date(
""" """
``` ```
#### 3. Resource Management (Optional) ##### 3. Resource Management (Optional)
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors. If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
#### 4. Registering Your Plugin ##### 4. Registering Your Plugin
Register your plugin using a setuptools entry point in your package's `pyproject.toml`: Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
@@ -582,7 +826,7 @@ my_parser = "my_package.parsers:MyDateParserPlugin"
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered. The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
### Plugin Discovery #### Plugin Discovery
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process: Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
@@ -593,7 +837,7 @@ Paperless-ngx automatically discovers and loads date parser plugins at runtime.
If multiple plugins are installed, a warning is logged indicating which plugin was selected. If multiple plugins are installed, a warning is logged indicating which plugin was selected.
### Example: Simple Date Parser #### Example: Simple Date Parser
Here's a minimal example that only looks for ISO 8601 dates: Here's a minimal example that only looks for ISO 8601 dates:
@@ -625,3 +869,30 @@ class ISODateParserPlugin(DateParserPluginBase):
if filtered_date is not None: if filtered_date is not None:
yield filtered_date yield filtered_date
``` ```
## Using Visual Studio Code devcontainer
Another easy way to get started with development is to use Visual Studio
Code devcontainers. This approach will create a preconfigured development
environment with all of the required tools and dependencies.
[Learn more about devcontainers](https://code.visualstudio.com/docs/devcontainers/containers).
The .devcontainer/vscode/tasks.json and .devcontainer/vscode/launch.json files
contain more information about the specific tasks and launch configurations (see the
non-standard "description" field).
To get started:
1. Clone the repository on your machine and open the Paperless-ngx folder in VS Code.
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
3. In case your host operating system is Windows:
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
will initialize the database tables and create a superuser. Then you can compile the front end
for production or run the frontend in debug mode.
5. The project is ready for debugging, start either run the fullstack debug or individual debug
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**

View File

@@ -103,3 +103,30 @@ Multiple options are combined in a single value:
```bash ```bash
PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10" PAPERLESS_DB_OPTIONS="sslmode=require;sslrootcert=/certs/ca.pem;pool.max_size=10"
``` ```
## OpenID Connect Token Endpoint Authentication
Some existing OpenID Connect setups may require an explicit token endpoint authentication method after upgrading to v3.
#### Action Required
If OIDC login fails at the callback with an `invalid_client` error, add `token_auth_method` to the provider `settings` in
[`PAPERLESS_SOCIALACCOUNT_PROVIDERS`](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS).
For example:
```json
{
"openid_connect": {
"APPS": [
{
...
"settings": {
"server_url": "https://login.example.com",
"token_auth_method": "client_secret_basic"
}
}
]
}
}
```

View File

@@ -140,24 +140,17 @@ a [superuser](usage.md#superusers) account.
!!! warning !!! warning
It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`. It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, make this If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
change in `docker-compose.yml`:
- Set the `user` running the container to map to the `paperless` ```yaml
user in the container. This value (`user_id` below) should be webserver:
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in image: ghcr.io/paperless-ngx/paperless-ngx:latest
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID` user: '1000:1000'
[here](configuration.md#docker). ```
Your entry for Paperless should contain something like: Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for the non-rootless case described in step 3.
> ```
> webserver:
> image: ghcr.io/paperless-ngx/paperless-ngx:latest
> user: <user_id>
> ```
**File systems without inotify support (e.g. NFS)** **File systems without inotify support (e.g. NFS)**
@@ -177,7 +170,6 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
#### Installation #### Installation
1. Install dependencies. Paperless requires the following packages: 1. Install dependencies. Paperless requires the following packages:
- `python3` - `python3`
- `python3-pip` - `python3-pip`
- `python3-dev` - `python3-dev`
@@ -200,7 +192,6 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
These dependencies are required for OCRmyPDF, which is used for text These dependencies are required for OCRmyPDF, which is used for text
recognition. recognition.
- `unpaper` - `unpaper`
- `ghostscript` - `ghostscript`
- `icc-profiles-free` - `icc-profiles-free`
@@ -220,13 +211,11 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
``` ```
On Raspberry Pi, these libraries are required as well: On Raspberry Pi, these libraries are required as well:
- `libatlas-base-dev` - `libatlas-base-dev`
- `libxslt1-dev` - `libxslt1-dev`
- `mime-support` - `mime-support`
You will also need these for installing some of the python dependencies: You will also need these for installing some of the python dependencies:
- `build-essential` - `build-essential`
- `python3-setuptools` - `python3-setuptools`
- `python3-wheel` - `python3-wheel`
@@ -279,7 +268,6 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
6. Configure Paperless-ngx. See [configuration](configuration.md) for details. 6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
Edit the included `paperless.conf` and adjust the settings to your Edit the included `paperless.conf` and adjust the settings to your
needs. Required settings for getting Paperless-ngx running are: needs. Required settings for getting Paperless-ngx running are:
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as - [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
`redis://localhost:6379`. `redis://localhost:6379`.
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`, - [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
@@ -303,7 +291,6 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
You can make many more adjustments, especially for OCR. You can make many more adjustments, especially for OCR.
The following options are recommended for most users: The following options are recommended for most users:
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your - Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
documents are written in. documents are written in.
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone. - Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
@@ -313,7 +300,6 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/). Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
7. Create the following directories if they do not already exist: 7. Create the following directories if they do not already exist:
- `/opt/paperless/media` - `/opt/paperless/media`
- `/opt/paperless/data` - `/opt/paperless/data`
- `/opt/paperless/consume` - `/opt/paperless/consume`
@@ -391,7 +377,6 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
starting point. starting point.
Paperless needs: Paperless needs:
- The `webserver` script to run the webserver. - The `webserver` script to run the webserver.
- The `consumer` script to watch the input folder. - The `consumer` script to watch the input folder.
- The `taskqueue` script for background workers (document consumption, etc.). - The `taskqueue` script for background workers (document consumption, etc.).
@@ -598,7 +583,6 @@ commands as well.
1. Stop and remove the Paperless container. 1. Stop and remove the Paperless container.
2. If using an external database, stop that container. 2. If using an external database, stop that container.
3. Update Redis configuration. 3. Update Redis configuration.
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) 1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4. and continue to step 4.
@@ -610,22 +594,18 @@ commands as well.
the new Redis container. the new Redis container.
4. Update user mapping. 4. Update user mapping.
1. If set, change the environment variable `PUID` to `USERMAP_UID`. 1. If set, change the environment variable `PUID` to `USERMAP_UID`.
1. If set, change the environment variable `PGID` to `USERMAP_GID`. 1. If set, change the environment variable `PGID` to `USERMAP_GID`.
5. Update configuration paths. 5. Update configuration paths.
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`. 1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`.
6. Update media paths. 6. Update media paths.
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to 1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media`. `/data/media`.
7. Update timezone. 7. Update timezone.
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same 1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ`. value as `TZ`.

View File

@@ -231,7 +231,6 @@ different means. These are as follows:
documents (the IMAP standard calls these "keywords"). Paperless documents (the IMAP standard calls these "keywords"). Paperless
will not consume mails already tagged. Not all mail servers support will not consume mails already tagged. Not all mail servers support
this feature! this feature!
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_. - **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
!!! warning !!! warning
@@ -1009,7 +1008,6 @@ Paperless-ngx consists of the following components:
with a scheduler that executes certain commands periodically. with a scheduler that executes certain commands periodically.
This task processor is responsible for: This task processor is responsible for:
- Consuming documents. When the consumer finds new documents, it - Consuming documents. When the consumer finds new documents, it
notifies the task processor to start a consumption task. notifies the task processor to start a consumption task.
- The task processor also performs the consumption of any - The task processor also performs the consumption of any

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.20.10" version = "2.20.13"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -26,7 +26,7 @@ dependencies = [
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.10", "django~=5.2.10",
"django-allauth[mfa,socialaccount]~=65.14.0", "django-allauth[mfa,socialaccount]~=65.15.0",
"django-auditlog~=3.4.1", "django-auditlog~=3.4.1",
"django-cachalot~=2.9.0", "django-cachalot~=2.9.0",
"django-celery-results~=2.6.0", "django-celery-results~=2.6.0",
@@ -42,10 +42,10 @@ dependencies = [
"djangorestframework~=3.16", "djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0", "djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28", "drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2026.1.1", "drf-spectacular-sidecar~=2026.3.1",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"faiss-cpu>=1.10", "faiss-cpu>=1.10",
"filelock~=3.20.3", "filelock~=3.25.2",
"flower~=2.0.1", "flower~=2.0.1",
"gotenberg-client~=0.13.1", "gotenberg-client~=0.13.1",
"httpx-oauth~=0.16", "httpx-oauth~=0.16",
@@ -60,7 +60,7 @@ dependencies = [
"llama-index-llms-openai>=0.6.13", "llama-index-llms-openai>=0.6.13",
"llama-index-vector-stores-faiss>=0.5.2", "llama-index-vector-stores-faiss>=0.5.2",
"nltk~=3.9.1", "nltk~=3.9.1",
"ocrmypdf~=16.13.0", "ocrmypdf~=17.3.0",
"openai>=1.76", "openai>=1.76",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
@@ -72,7 +72,7 @@ dependencies = [
"rapidfuzz~=3.14.0", "rapidfuzz~=3.14.0",
"redis[hiredis]~=5.2.1", "redis[hiredis]~=5.2.1",
"regex>=2025.9.18", "regex>=2025.9.18",
"scikit-learn~=1.7.0", "scikit-learn~=1.8.0",
"sentence-transformers>=4.1", "sentence-transformers>=4.1",
"setproctitle~=1.3.4", "setproctitle~=1.3.4",
"tika-client~=0.10.0", "tika-client~=0.10.0",
@@ -111,7 +111,7 @@ docs = [
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker~=40.5.1", "faker~=40.8.0",
"imagehash", "imagehash",
"pytest~=9.0.0", "pytest~=9.0.0",
"pytest-cov~=7.0.0", "pytest-cov~=7.0.0",
@@ -248,15 +248,13 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [
lint.per-file-ignores."src/documents/models.py" = [ lint.per-file-ignores."src/documents/models.py" = [
"SIM115", "SIM115",
] ]
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
"RUF001",
]
lint.isort.force-single-line = true lint.isort.force-single-line = true
[tool.codespell] [tool.codespell]
write-changes = true write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish" ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json" skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples/*,*.po,*.json"
[tool.pytest] [tool.pytest]
minversion = "9.0" minversion = "9.0"
@@ -271,10 +269,6 @@ testpaths = [
"src/documents/tests/", "src/documents/tests/",
"src/paperless/tests/", "src/paperless/tests/",
"src/paperless_mail/tests/", "src/paperless_mail/tests/",
"src/paperless_tesseract/tests/",
"src/paperless_tika/tests",
"src/paperless_text/tests/",
"src/paperless_remote/tests/",
"src/paperless_ai/tests", "src/paperless_ai/tests",
] ]

View File

@@ -468,7 +468,7 @@
"time": 0.951, "time": 0.951,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=9",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

File diff suppressed because one or more lines are too long

View File

@@ -534,7 +534,7 @@
"time": 0.653, "time": 0.653,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9", "url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

View File

@@ -883,7 +883,7 @@
"time": 0.93, "time": 0.93,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [
@@ -961,7 +961,7 @@
"time": -1, "time": -1,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

View File

@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
await expect(page).toHaveURL(/tags__id__all=9/) await expect(page).toHaveURL(/tags__id__all=9/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Document type' }).click() await page.getByRole('button', { name: 'Document type' }).click()
await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click() await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
await expect(page).toHaveURL(/document_type__id__in=1/) await expect(page).toHaveURL(/document_type__id__in=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
await page.getByRole('button', { name: 'Reset filters' }).first().click() await page.getByRole('button', { name: 'Reset filters' }).first().click()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.20.10", "version": "2.20.13",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -11,17 +11,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.0", "@angular/cdk": "^21.2.4",
"@angular/common": "~21.2.0", "@angular/common": "~21.2.6",
"@angular/compiler": "~21.2.0", "@angular/compiler": "~21.2.6",
"@angular/core": "~21.2.0", "@angular/core": "~21.2.6",
"@angular/forms": "~21.2.0", "@angular/forms": "~21.2.6",
"@angular/localize": "~21.2.0", "@angular/localize": "~21.2.6",
"@angular/platform-browser": "~21.2.0", "@angular/platform-browser": "~21.2.6",
"@angular/platform-browser-dynamic": "~21.2.0", "@angular/platform-browser-dynamic": "~21.2.6",
"@angular/router": "~21.2.0", "@angular/router": "~21.2.6",
"@ng-bootstrap/ng-bootstrap": "^20.0.0", "@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.4.1", "@ng-select/ng-select": "^21.5.2",
"@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.8", "bootstrap": "^5.3.8",
@@ -29,7 +29,7 @@
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
"ngx-cookie-service": "^21.1.0", "ngx-cookie-service": "^21.3.1",
"ngx-device-detector": "^11.0.0", "ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^18.0.0", "ngx-ui-tour-ng-bootstrap": "^18.0.0",
"pdfjs-dist": "^5.4.624", "pdfjs-dist": "^5.4.624",
@@ -42,26 +42,26 @@
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^21.0.3", "@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3", "@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.2.0", "@angular-devkit/core": "^21.2.3",
"@angular-devkit/schematics": "^21.2.0", "@angular-devkit/schematics": "^21.2.3",
"@angular-eslint/builder": "21.3.0", "@angular-eslint/builder": "21.3.1",
"@angular-eslint/eslint-plugin": "21.3.0", "@angular-eslint/eslint-plugin": "21.3.1",
"@angular-eslint/eslint-plugin-template": "21.3.0", "@angular-eslint/eslint-plugin-template": "21.3.1",
"@angular-eslint/schematics": "21.3.0", "@angular-eslint/schematics": "21.3.1",
"@angular-eslint/template-parser": "21.3.0", "@angular-eslint/template-parser": "21.3.1",
"@angular/build": "^21.2.0", "@angular/build": "^21.2.3",
"@angular/cli": "~21.2.0", "@angular/cli": "~21.2.3",
"@angular/compiler-cli": "~21.2.0", "@angular/compiler-cli": "~21.2.6",
"@codecov/webpack-plugin": "^1.9.1", "@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^25.3.3", "@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.57.2",
"@typescript-eslint/utils": "^8.54.0", "@typescript-eslint/utils": "^8.57.2",
"eslint": "^10.0.2", "eslint": "^10.1.0",
"jest": "30.2.0", "jest": "30.3.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.3.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"jest-preset-angular": "^16.1.1", "jest-preset-angular": "^16.1.1",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",

2532
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm"> <nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse" <button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation" data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="isMenuCollapsed = !isMenuCollapsed"> (click)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0" <a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
@@ -24,7 +24,8 @@
} }
</div> </div>
</a> </a>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"> <div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden">
<div class="col-12 col-md-7"> <div class="col-12 col-md-7">
<pngx-global-search></pngx-global-search> <pngx-global-search></pngx-global-search>
</div> </div>
@@ -378,7 +379,7 @@
</div> </div>
</nav> </nav>
<main role="main" class="ms-sm-auto px-md-4" <main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden"
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'"> [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>

View File

@@ -44,6 +44,23 @@
.sidebar { .sidebar {
top: 3.5rem; top: 3.5rem;
} }
.search-container {
max-height: 4.5rem;
overflow: hidden;
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
&.mobile-hidden {
max-height: 0;
opacity: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
}
main.mobile-search-hidden {
padding-top: 56px;
}
} }
main { main {

View File

@@ -293,6 +293,59 @@ describe('AppFrameComponent', () => {
expect(component.isMenuCollapsed).toBeTruthy() expect(component.isMenuCollapsed).toBeTruthy()
}) })
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
Object.defineProperty(globalThis, 'innerWidth', {
value: 767,
})
component.ngOnInit()
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 40,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(true)
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 0,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
})
it('should keep mobile search visible on desktop scroll or resize', () => {
Object.defineProperty(globalThis, 'innerWidth', {
value: 1024,
})
component.ngOnInit()
component.mobileSearchHidden = true
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
component.mobileSearchHidden = true
component.onWindowResize()
})
it('should keep mobile search visible while the mobile menu is expanded', () => {
Object.defineProperty(globalThis, 'innerWidth', {
value: 767,
})
component.ngOnInit()
component.isMenuCollapsed = false
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 40,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
})
it('should support close document & navigate on close current doc', () => { it('should support close document & navigate on close current doc', () => {
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
closeSpy.mockReturnValue(of(true)) closeSpy.mockReturnValue(of(true))

View File

@@ -51,6 +51,8 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component' import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
const SCROLL_THRESHOLD = 16
@Component({ @Component({
selector: 'pngx-app-frame', selector: 'pngx-app-frame',
templateUrl: './app-frame.component.html', templateUrl: './app-frame.component.html',
@@ -94,6 +96,10 @@ export class AppFrameComponent
slimSidebarAnimating: boolean = false slimSidebarAnimating: boolean = false
public mobileSearchHidden: boolean = false
private lastScrollY: number = 0
constructor() { constructor() {
super() super()
const permissionsService = this.permissionsService const permissionsService = this.permissionsService
@@ -111,6 +117,8 @@ export class AppFrameComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.lastScrollY = window.scrollY
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) { if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
this.checkForUpdates() this.checkForUpdates()
} }
@@ -263,6 +271,38 @@ export class AppFrameComponent
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
} }
@HostListener('window:resize')
onWindowResize(): void {
if (!this.isMobileViewport()) {
this.mobileSearchHidden = false
}
}
@HostListener('window:scroll')
onWindowScroll(): void {
const currentScrollY = window.scrollY
if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
this.mobileSearchHidden = false
this.lastScrollY = currentScrollY
return
}
const delta = currentScrollY - this.lastScrollY
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
this.mobileSearchHidden = false
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
this.mobileSearchHidden = true
}
this.lastScrollY = currentScrollY
}
private isMobileViewport(): boolean {
return window.innerWidth < 768
}
closeMenu() { closeMenu() {
this.isMenuCollapsed = true this.isMenuCollapsed = true
} }

View File

@@ -32,7 +32,7 @@ export enum EditDialogMode {
@Directive() @Directive()
export abstract class EditDialogComponent< export abstract class EditDialogComponent<
T extends ObjectWithPermissions | ObjectWithId, T extends ObjectWithPermissions | ObjectWithId,
> >
extends LoadingComponentWithPermissions extends LoadingComponentWithPermissions
implements OnInit implements OnInit
{ {

View File

@@ -631,6 +631,59 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
]) ])
}) })
it('deselecting a parent clears selected descendants', () => {
const root: Tag = { id: 100, name: 'Root Tag' }
const child: Tag = { id: 101, name: 'Child Tag', parent: root.id }
const grandchild: Tag = {
id: 102,
name: 'Grandchild Tag',
parent: child.id,
}
const other: Tag = { id: 103, name: 'Other Tag' }
selectionModel.items = [root, child, grandchild, other]
selectionModel.set(root.id, ToggleableItemState.Selected, false)
selectionModel.set(child.id, ToggleableItemState.Selected, false)
selectionModel.set(grandchild.id, ToggleableItemState.Selected, false)
selectionModel.set(other.id, ToggleableItemState.Selected, false)
selectionModel.toggle(root.id, false)
expect(selectionModel.getSelectedItems()).toEqual([other])
})
it('un-excluding a parent clears excluded descendants', () => {
const root: Tag = { id: 110, name: 'Root Tag' }
const child: Tag = { id: 111, name: 'Child Tag', parent: root.id }
const other: Tag = { id: 112, name: 'Other Tag' }
selectionModel.items = [root, child, other]
selectionModel.set(root.id, ToggleableItemState.Excluded, false)
selectionModel.set(child.id, ToggleableItemState.Excluded, false)
selectionModel.set(other.id, ToggleableItemState.Excluded, false)
selectionModel.exclude(root.id, false)
expect(selectionModel.getExcludedItems()).toEqual([other])
})
it('excluding a selected parent clears selected descendants', () => {
const root: Tag = { id: 120, name: 'Root Tag' }
const child: Tag = { id: 121, name: 'Child Tag', parent: root.id }
const other: Tag = { id: 122, name: 'Other Tag' }
selectionModel.manyToOne = true
selectionModel.items = [root, child, other]
selectionModel.set(root.id, ToggleableItemState.Selected, false)
selectionModel.set(child.id, ToggleableItemState.Selected, false)
selectionModel.set(other.id, ToggleableItemState.Selected, false)
selectionModel.exclude(root.id, false)
expect(selectionModel.getExcludedItems()).toEqual([root])
expect(selectionModel.getSelectedItems()).toEqual([other])
})
it('resorts items immediately when document count sorting enabled', () => { it('resorts items immediately when document count sorting enabled', () => {
const apple: Tag = { id: 55, name: 'Apple' } const apple: Tag = { id: 55, name: 'Apple' }
const zebra: Tag = { id: 56, name: 'Zebra' } const zebra: Tag = { id: 56, name: 'Zebra' }

View File

@@ -20,9 +20,9 @@ import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SelectionDataItem } from 'src/app/data/results'
import { FilterPipe } from 'src/app/pipes/filter.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
@@ -235,6 +235,7 @@ export class FilterableDropdownSelectionModel {
state == ToggleableItemState.Excluded state == ToggleableItemState.Excluded
) { ) {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
this.clearDescendantSelections(id)
} }
if (!id) { if (!id) {
@@ -261,6 +262,7 @@ export class FilterableDropdownSelectionModel {
if (this.manyToOne || this.singleSelect) { if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(id)
if (this.singleSelect) { if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of this.temporarySelectionStates.keys()) {
@@ -281,9 +283,15 @@ export class FilterableDropdownSelectionModel {
newState = ToggleableItemState.NotSelected newState = ToggleableItemState.NotSelected
} }
this.temporarySelectionStates.set(id, newState) this.temporarySelectionStates.set(id, newState)
if (newState == ToggleableItemState.Excluded) {
this.clearDescendantSelections(id)
}
} }
} else if (!id || state == ToggleableItemState.Excluded) { } else if (!id || state == ToggleableItemState.Excluded) {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
if (id) {
this.clearDescendantSelections(id)
}
} }
if (fireEvent) { if (fireEvent) {
@@ -295,6 +303,33 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected return this.selectionStates.get(id) || ToggleableItemState.NotSelected
} }
private clearDescendantSelections(id: number) {
for (const descendantID of this.getDescendantIDs(id)) {
this.temporarySelectionStates.delete(descendantID)
}
}
private getDescendantIDs(id: number): number[] {
const descendants: number[] = []
const queue: number[] = [id]
while (queue.length) {
const parentID = queue.shift()
for (const item of this._items) {
if (
typeof item?.id === 'number' &&
typeof (item as any)['parent'] === 'number' &&
(item as any)['parent'] === parentID
) {
descendants.push(item.id)
queue.push(item.id)
}
}
}
return descendants
}
get logicalOperator(): LogicalOperator { get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator return this.temporaryLogicalOperator
} }

View File

@@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => {
component.reprocess() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith([doc.id]) expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
@@ -1644,9 +1644,9 @@ describe('DocumentDetailComponent', () => {
expect( expect(
fixture.debugElement.query(By.css('.preview-sticky img')) fixture.debugElement.query(By.css('.preview-sticky img'))
).not.toBeUndefined() ).not.toBeUndefined()
;(component.document.mime_type = ;((component.document.mime_type =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
fixture.detectChanges() fixture.detectChanges())
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Other component.ContentRenderType.Other
) )

View File

@@ -1379,7 +1379,9 @@ export class DocumentDetailComponent
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.documentsService.reprocessDocuments([this.document.id]).subscribe({ this.documentsService
.reprocessDocuments({ documents: [this.document.id] })
.subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Reprocess operation for "${this.document.title}" will begin in the background.` $localize`Reprocess operation for "${this.document.title}" will begin in the background.`

View File

@@ -92,7 +92,7 @@
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container> <i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container>
</button> </button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container> <i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button> </button>
</div> </div>
@@ -103,13 +103,13 @@
class="btn btn-sm btn-outline-primary" class="btn btn-sm btn-outline-primary"
id="dropdownSend" id="dropdownSend"
ngbDropdownToggle ngbDropdownToggle
[disabled]="disabled || list.selected.size === 0" [disabled]="disabled || !list.hasSelection || list.allSelected"
> >
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div> </div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()"> <button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container> <i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button> </button>
<button ngbDropdownItem (click)="manageShareLinkBundles()"> <button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,7 +117,7 @@
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()"> <button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container> <i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button> </button>
} }

View File

@@ -13,6 +13,7 @@ import { of, throwError } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results' import { Results } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
@@ -273,6 +274,92 @@ describe('BulkEditorComponent', () => {
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1) expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
}) })
it('should apply list selection data to tags menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(3)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openTagsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts).toEqual(
selectionData.selected_document_types
)
})
it('should apply list selection data to correspondents menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts).toEqual(
selectionData.selected_correspondents
)
})
it('should apply list selection data to storage paths menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts).toEqual(
selectionData.selected_storage_paths
)
})
it('should apply list selection data to custom fields menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts).toEqual(
selectionData.selected_custom_fields
)
})
it('should execute modify tags bulk operation', () => { it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest jest
@@ -300,13 +387,56 @@ describe('BulkEditorComponent', () => {
parameters: { add_tags: [101], remove_tags: [] }, parameters: { add_tags: [101], remove_tags: [] },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds ) // listAllFilteredIds
}) })
it('should execute modify tags bulk operation for all filtered documents', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'filterRules', 'get')
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(25)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setTags({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
})
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => { it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -332,7 +462,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -423,7 +553,7 @@ describe('BulkEditorComponent', () => {
parameters: { correspondent: 101 }, parameters: { correspondent: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -455,7 +585,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -521,7 +651,7 @@ describe('BulkEditorComponent', () => {
parameters: { document_type: 101 }, parameters: { document_type: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -553,7 +683,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -619,7 +749,7 @@ describe('BulkEditorComponent', () => {
parameters: { storage_path: 101 }, parameters: { storage_path: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -651,7 +781,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -717,7 +847,7 @@ describe('BulkEditorComponent', () => {
parameters: { add_custom_fields: [101], remove_custom_fields: [102] }, parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -749,7 +879,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -858,7 +988,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4], documents: [3, 4],
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -951,7 +1081,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4], documents: [3, 4],
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -986,7 +1116,7 @@ describe('BulkEditorComponent', () => {
source_mode: 'latest_version', source_mode: 'latest_version',
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1027,7 +1157,7 @@ describe('BulkEditorComponent', () => {
metadata_document_id: 3, metadata_document_id: 3,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1046,7 +1176,7 @@ describe('BulkEditorComponent', () => {
delete_originals: true, delete_originals: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1067,7 +1197,7 @@ describe('BulkEditorComponent', () => {
archive_fallback: true, archive_fallback: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1089,22 +1219,39 @@ describe('BulkEditorComponent', () => {
component.downloadForm.get('downloadFileTypeArchive').patchValue(true) component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
fixture.detectChanges() fixture.detectChanges()
let downloadSpy = jest.spyOn(documentService, 'bulkDownload') let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
downloadSpy.mockReturnValue(of(new Blob()))
//archive //archive
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false) expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'archive',
false
)
//originals //originals
component.downloadForm.get('downloadFileTypeArchive').patchValue(false) component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true) component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false) expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'originals',
false
)
//both //both
component.downloadForm.get('downloadFileTypeArchive').patchValue(true) component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false) expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'both',
false
)
//formatting //formatting
component.downloadForm.get('downloadUseFormatting').patchValue(true) component.downloadForm.get('downloadUseFormatting').patchValue(true)
component.downloadSelected() component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true) expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'both',
true
)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/bulk_download/` `${environment.apiBaseUrl}documents/bulk_download/`
@@ -1153,7 +1300,7 @@ describe('BulkEditorComponent', () => {
}, },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1450,6 +1597,7 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2) expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2]) expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.documents).toEqual([3, 4]) expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit() modal.componentInstance.failed.emit()
@@ -1460,7 +1608,7 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowInfoSpy).toHaveBeenCalled() expect(toastServiceShowInfoSpy).toHaveBeenCalled()
expect(listReloadSpy).toHaveBeenCalled() expect(listReloadSpy).toHaveBeenCalled()
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`

View File

@@ -16,6 +16,7 @@ import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -30,9 +31,9 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { import {
DocumentBulkEditMethod, DocumentBulkEditMethod,
DocumentSelectionQuery,
DocumentService, DocumentService,
MergeDocumentsRequest, MergeDocumentsRequest,
SelectionDataItem,
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
@@ -41,6 +42,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags' import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -261,17 +263,13 @@ export class BulkEditorComponent
modal: NgbModalRef, modal: NgbModalRef,
method: DocumentBulkEditMethod, method: DocumentBulkEditMethod,
args: any, args: any,
overrideDocumentIDs?: number[] overrideSelection?: DocumentSelectionQuery
) { ) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
} }
this.documentService this.documentService
.bulkEdit( .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
overrideDocumentIDs ?? Array.from(this.list.selected),
method,
args
)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: () => this.handleOperationSuccess(modal), next: () => this.handleOperationSuccess(modal),
@@ -329,7 +327,7 @@ export class BulkEditorComponent
) { ) {
let selectionData = new Map<number, ToggleableItemState>() let selectionData = new Map<number, ToggleableItemState>()
items.forEach((i) => { items.forEach((i) => {
if (i.document_count == this.list.selected.size) { if (i.document_count == this.list.selectedCount) {
selectionData.set(i.id, ToggleableItemState.Selected) selectionData.set(i.id, ToggleableItemState.Selected)
} else if (i.document_count > 0) { } else if (i.document_count > 0) {
selectionData.set(i.id, ToggleableItemState.PartiallySelected) selectionData.set(i.id, ToggleableItemState.PartiallySelected)
@@ -338,7 +336,31 @@ export class BulkEditorComponent
selectionModel.init(selectionData) selectionModel.init(selectionData)
} }
private getSelectionQuery(): DocumentSelectionQuery {
if (this.list.allSelected) {
return {
all: true,
filters: queryParamsFromFilterRules(this.list.filterRules),
}
}
return {
documents: Array.from(this.list.selected),
}
}
private getSelectionSize(): number {
return this.list.selectedCount
}
openTagsDropdown() { openTagsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -349,6 +371,17 @@ export class BulkEditorComponent
} }
openDocumentTypeDropdown() { openDocumentTypeDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? []
this.applySelectionData(
this.documentTypeDocumentCounts,
this.documentTypeSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -362,6 +395,17 @@ export class BulkEditorComponent
} }
openCorrespondentDropdown() { openCorrespondentDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? []
this.applySelectionData(
this.correspondentDocumentCounts,
this.correspondentSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -375,6 +419,17 @@ export class BulkEditorComponent
} }
openStoragePathDropdown() { openStoragePathDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? []
this.applySelectionData(
this.storagePathDocumentCounts,
this.storagePathsSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -388,6 +443,17 @@ export class BulkEditorComponent
} }
openCustomFieldsDropdown() { openCustomFieldsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? []
this.applySelectionData(
this.customFieldDocumentCounts,
this.customFieldsSelectionModel
)
return
}
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
@@ -437,33 +503,33 @@ export class BulkEditorComponent
changedTags.itemsToRemove.length == 0 changedTags.itemsToRemove.length == 0
) { ) {
let tag = changedTags.itemsToAdd[0] let tag = changedTags.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.getSelectionSize()} selected document(s).`
} else if ( } else if (
changedTags.itemsToAdd.length > 1 && changedTags.itemsToAdd.length > 1 &&
changedTags.itemsToRemove.length == 0 changedTags.itemsToRemove.length == 0
) { ) {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList( modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd changedTags.itemsToAdd
)} to ${this.list.selected.size} selected document(s).` )} to ${this.getSelectionSize()} selected document(s).`
} else if ( } else if (
changedTags.itemsToAdd.length == 0 && changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length == 1 changedTags.itemsToRemove.length == 1
) { ) {
let tag = changedTags.itemsToRemove[0] let tag = changedTags.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.getSelectionSize()} selected document(s).`
} else if ( } else if (
changedTags.itemsToAdd.length == 0 && changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length > 1 changedTags.itemsToRemove.length > 1
) { ) {
modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList( modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(
changedTags.itemsToRemove changedTags.itemsToRemove
)} from ${this.list.selected.size} selected document(s).` )} from ${this.getSelectionSize()} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList( modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd changedTags.itemsToAdd
)} and remove the tags ${this._localizeList( )} and remove the tags ${this._localizeList(
changedTags.itemsToRemove changedTags.itemsToRemove
)} on ${this.list.selected.size} selected document(s).` )} on ${this.getSelectionSize()} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
@@ -502,9 +568,9 @@ export class BulkEditorComponent
}) })
modal.componentInstance.title = $localize`Confirm correspondent assignment` modal.componentInstance.title = $localize`Confirm correspondent assignment`
if (correspondent) { if (correspondent) {
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.getSelectionSize()} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.getSelectionSize()} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
@@ -540,9 +606,9 @@ export class BulkEditorComponent
}) })
modal.componentInstance.title = $localize`Confirm document type assignment` modal.componentInstance.title = $localize`Confirm document type assignment`
if (documentType) { if (documentType) {
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.getSelectionSize()} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the document type from ${this.getSelectionSize()} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
@@ -578,9 +644,9 @@ export class BulkEditorComponent
}) })
modal.componentInstance.title = $localize`Confirm storage path assignment` modal.componentInstance.title = $localize`Confirm storage path assignment`
if (storagePath) { if (storagePath) {
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.getSelectionSize()} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.getSelectionSize()} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
@@ -615,33 +681,33 @@ export class BulkEditorComponent
changedCustomFields.itemsToRemove.length == 0 changedCustomFields.itemsToRemove.length == 0
) { ) {
let customField = changedCustomFields.itemsToAdd[0] let customField = changedCustomFields.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.getSelectionSize()} selected document(s).`
} else if ( } else if (
changedCustomFields.itemsToAdd.length > 1 && changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0 changedCustomFields.itemsToRemove.length == 0
) { ) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList( modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd changedCustomFields.itemsToAdd
)} to ${this.list.selected.size} selected document(s).` )} to ${this.getSelectionSize()} selected document(s).`
} else if ( } else if (
changedCustomFields.itemsToAdd.length == 0 && changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1 changedCustomFields.itemsToRemove.length == 1
) { ) {
let customField = changedCustomFields.itemsToRemove[0] let customField = changedCustomFields.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).` modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.getSelectionSize()} selected document(s).`
} else if ( } else if (
changedCustomFields.itemsToAdd.length == 0 && changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1 changedCustomFields.itemsToRemove.length > 1
) { ) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList( modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove changedCustomFields.itemsToRemove
)} from ${this.list.selected.size} selected document(s).` )} from ${this.getSelectionSize()} selected document(s).`
} else { } else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList( modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList( )} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove changedCustomFields.itemsToRemove
)} on ${this.list.selected.size} selected document(s).` )} on ${this.getSelectionSize()} selected document(s).`
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
@@ -779,7 +845,7 @@ export class BulkEditorComponent
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Confirm` modal.componentInstance.title = $localize`Confirm`
modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?` modal.componentInstance.messageBold = $localize`Move ${this.getSelectionSize()} selected document(s) to the trash?`
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.` modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Move to trash` modal.componentInstance.btnCaption = $localize`Move to trash`
@@ -789,13 +855,13 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.deleteDocuments(Array.from(this.list.selected)) this.documentService.deleteDocuments(this.getSelectionQuery())
) )
}) })
} else { } else {
this.executeDocumentAction( this.executeDocumentAction(
null, null,
this.documentService.deleteDocuments(Array.from(this.list.selected)) this.documentService.deleteDocuments(this.getSelectionQuery())
) )
} }
} }
@@ -811,7 +877,7 @@ export class BulkEditorComponent
: 'originals' : 'originals'
this.documentService this.documentService
.bulkDownload( .bulkDownload(
Array.from(this.list.selected), this.getSelectionQuery(),
downloadFileType, downloadFileType,
this.downloadForm.get('downloadUseFormatting').value this.downloadForm.get('downloadUseFormatting').value
) )
@@ -827,7 +893,7 @@ export class BulkEditorComponent
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Reprocess confirm` modal.componentInstance.title = $localize`Reprocess confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).` modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.` modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
@@ -837,9 +903,7 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.reprocessDocuments( this.documentService.reprocessDocuments(this.getSelectionQuery())
Array.from(this.list.selected)
)
) )
}) })
} }
@@ -866,7 +930,7 @@ export class BulkEditorComponent
}) })
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm` rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).` rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).`
rotateDialog.btnClass = 'btn-danger' rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed` rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0] rotateDialog.documentID = Array.from(this.list.selected)[0]
@@ -877,7 +941,7 @@ export class BulkEditorComponent
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.rotateDocuments( this.documentService.rotateDocuments(
Array.from(this.list.selected), this.getSelectionQuery(),
rotateDialog.degrees rotateDialog.degrees
) )
) )
@@ -890,7 +954,7 @@ export class BulkEditorComponent
}) })
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
mergeDialog.title = $localize`Merge confirm` mergeDialog.title = $localize`Merge confirm`
mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.` mergeDialog.messageBold = $localize`This operation will merge ${this.getSelectionSize()} selected documents into a new document.`
mergeDialog.btnCaption = $localize`Proceed` mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs = Array.from(this.list.selected) mergeDialog.documentIDs = Array.from(this.list.selected)
mergeDialog.confirmClicked mergeDialog.confirmClicked
@@ -935,7 +999,7 @@ export class BulkEditorComponent
(item) => item.id (item) => item.id
) )
dialog.documents = Array.from(this.list.selected) dialog.selection = this.getSelectionQuery()
dialog.succeeded.subscribe((result) => { dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`) this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload() this.list.reload()

View File

@@ -48,7 +48,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
.mockReturnValue(of('Success')) .mockReturnValue(of('Success'))
const successSpy = jest.spyOn(component.succeeded, 'emit') const successSpy = jest.spyOn(component.succeeded, 'emit')
component.documents = [1, 2] component.selection = [1, 2]
component.fieldsToAddIds = [1] component.fieldsToAddIds = [1]
component.form.controls['1'].setValue('Value 1') component.form.controls['1'].setValue('Value 1')
component.save() component.save()
@@ -63,7 +63,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
.mockReturnValue(throwError(new Error('Error'))) .mockReturnValue(throwError(new Error('Error')))
const failSpy = jest.spyOn(component.failed, 'emit') const failSpy = jest.spyOn(component.failed, 'emit')
component.documents = [1, 2] component.selection = [1, 2]
component.fieldsToAddIds = [1] component.fieldsToAddIds = [1]
component.form.controls['1'].setValue('Value 1') component.form.controls['1'].setValue('Value 1')
component.save() component.save()

View File

@@ -17,7 +17,10 @@ import { SelectComponent } from 'src/app/components/common/input/select/select.c
import { TextComponent } from 'src/app/components/common/input/text/text.component' import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { UrlComponent } from 'src/app/components/common/input/url/url.component' import { UrlComponent } from 'src/app/components/common/input/url/url.component'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentService } from 'src/app/services/rest/document.service' import {
DocumentSelectionQuery,
DocumentService,
} from 'src/app/services/rest/document.service'
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component' import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
@Component({ @Component({
@@ -76,7 +79,11 @@ export class CustomFieldsBulkEditDialogComponent {
public form: FormGroup = new FormGroup({}) public form: FormGroup = new FormGroup({})
public documents: number[] = [] public selection: DocumentSelectionQuery = { documents: [] }
public get documents(): number[] {
return this.selection.documents
}
initForm() { initForm() {
Object.keys(this.form.controls).forEach((key) => { Object.keys(this.form.controls).forEach((key) => {
@@ -91,7 +98,7 @@ export class CustomFieldsBulkEditDialogComponent {
public save() { public save() {
this.documentService this.documentService
.bulkEdit(this.documents, 'modify_custom_fields', { .bulkEdit(this.selection, 'modify_custom_fields', {
add_custom_fields: this.form.value, add_custom_fields: this.form.value,
remove_custom_fields: this.fieldsToRemoveIds, remove_custom_fields: this.fieldsToRemoveIds,
}) })

View File

@@ -15,7 +15,7 @@
} }
@if (document && displayFields?.includes(DisplayField.TAGS)) { @if (document && displayFields?.includes(DisplayField.TAGS)) {
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6" [class.tags-no-wrap]="document.tags.length > 3">
@for (tagID of tagIDs; track tagID) { @for (tagID of tagIDs; track tagID) {
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag> <pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
} }

View File

@@ -72,4 +72,14 @@ a {
max-width: 80%; max-width: 80%;
row-gap: .2rem; row-gap: .2rem;
line-height: 1; line-height: 1;
&.tags-no-wrap {
::ng-deep .badge {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
} }

View File

@@ -82,6 +82,16 @@ describe('DocumentCardSmallComponent', () => {
).toHaveLength(6) ).toHaveLength(6)
}) })
it('should clear hidden tag counter when tag count falls below the limit', () => {
expect(component.moreTags).toEqual(3)
component.document.tags = [1, 2, 3, 4, 5, 6]
fixture.detectChanges()
expect(component.moreTags).toBeNull()
expect(fixture.nativeElement.textContent).not.toContain('+ 3')
})
it('should try to close the preview on mouse leave', () => { it('should try to close the preview on mouse leave', () => {
component.popupPreview = { component.popupPreview = {
close: jest.fn(), close: jest.fn(),

View File

@@ -126,6 +126,7 @@ export class DocumentCardSmallComponent
this.moreTags = this.document.tags.length - (limit - 1) this.moreTags = this.document.tags.length - (limit - 1)
return this.document.tags.slice(0, limit - 1) return this.document.tags.slice(0, limit - 1)
} else { } else {
this.moreTags = null
return this.document.tags return this.document.tags
} }
} }

View File

@@ -2,8 +2,8 @@
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (list.selected.size > 0) { @if (list.hasSelection) {
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
} }
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
@@ -17,7 +17,7 @@
<span class="input-group-text border-0" i18n>Select:</span> <span class="input-group-text border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (list.selected.size > 0) { @if (list.hasSelection) {
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container> <i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button> </button>
@@ -127,11 +127,11 @@
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
} }
@if (list.selected.size > 0) { @if (list.hasSelection) {
<span i18n>{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span> <span i18n>{list.collectionSize, plural, =1 {Selected {{list.selectedCount}} of one document} other {Selected {{list.selectedCount}} of {{list.collectionSize || 0}} documents}}</span>
} }
@if (!list.isReloading) { @if (!list.isReloading) {
@if (list.selected.size === 0) { @if (!list.hasSelection) {
<span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>
}&nbsp;@if (isFiltered) { }&nbsp;@if (isFiltered) {
&nbsp;<span i18n>(filtered)</span> &nbsp;<span i18n>(filtered)</span>
@@ -142,7 +142,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small> <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</button> </button>
} }
@if (!list.isReloading && list.selected.size > 0) { @if (!list.isReloading && list.hasSelection) {
<button class="btn btn-link py-0" (click)="list.selectNone()"> <button class="btn btn-link py-0" (click)="list.selectNone()">
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small> <i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
</button> </button>

View File

@@ -56,13 +56,20 @@ $paperless-card-breakpoints: (
.sticky-top { .sticky-top {
z-index: 990; // below main navbar z-index: 990; // below main navbar
top: calc(7rem - 2px); // height of navbar (mobile) top: calc(7rem - 2px); // height of navbar + search row (mobile)
transition: top 0.2s ease;
@media (min-width: 580px) { @media (min-width: 580px) {
top: 3.5rem; // height of navbar top: 3.5rem; // height of navbar
} }
} }
@media (max-width: 579.98px) {
:host-context(main.mobile-search-hidden) .sticky-top {
top: calc(3.5rem - 2px); // height of navbar only when search is hidden
}
}
.table .form-check { .table .form-check {
padding: 0.2rem; padding: 0.2rem;
min-height: 0; min-height: 0;

View File

@@ -388,8 +388,8 @@ describe('DocumentListComponent', () => {
it('should support select all, none, page & range', () => { it('should support select all, none, page & range', () => {
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
jest jest
.spyOn(documentService, 'listAllFilteredIds') .spyOn(documentListService, 'collectionSize', 'get')
.mockReturnValue(of(docs.map((d) => d.id))) .mockReturnValue(docs.length)
fixture.detectChanges() fixture.detectChanges()
expect(documentListService.selected.size).toEqual(0) expect(documentListService.selected.size).toEqual(0)
const docCards = fixture.debugElement.queryAll( const docCards = fixture.debugElement.queryAll(
@@ -403,7 +403,8 @@ describe('DocumentListComponent', () => {
displayModeButtons[2].triggerEventHandler('click') displayModeButtons[2].triggerEventHandler('click')
expect(selectAllSpy).toHaveBeenCalled() expect(selectAllSpy).toHaveBeenCalled()
fixture.detectChanges() fixture.detectChanges()
expect(documentListService.selected.size).toEqual(3) expect(documentListService.allSelected).toBeTruthy()
expect(documentListService.selectedCount).toEqual(3)
docCards.forEach((card) => { docCards.forEach((card) => {
expect(card.context.selected).toBeTruthy() expect(card.context.selected).toBeTruthy()
}) })

View File

@@ -240,7 +240,7 @@ export class DocumentListComponent
} }
get isBulkEditing(): boolean { get isBulkEditing(): boolean {
return this.list.selected.size > 0 return this.list.hasSelection
} }
toggleDisplayField(field: DisplayField) { toggleDisplayField(field: DisplayField) {
@@ -327,7 +327,7 @@ export class DocumentListComponent
}) })
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
if (this.list.selected.size > 0) { if (this.list.hasSelection) {
this.list.selectNone() this.list.selectNone()
} else if (this.isFiltered) { } else if (this.isFiltered) {
this.resetFilters() this.resetFilters()
@@ -356,7 +356,7 @@ export class DocumentListComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
if (this.list.documents.length > 0) { if (this.list.documents.length > 0) {
if (this.list.selected.size > 0) { if (this.list.hasSelection) {
this.openDocumentDetail(Array.from(this.list.selected)[0]) this.openDocumentDetail(Array.from(this.list.selected)[0])
} else { } else {
this.openDocumentDetail(this.list.documents[0]) this.openDocumentDetail(this.list.documents[0])

View File

@@ -76,6 +76,7 @@ import {
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE, NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
import { import {
PermissionAction, PermissionAction,
PermissionType, PermissionType,
@@ -84,11 +85,7 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { import { DocumentService } from 'src/app/services/rest/document.service'
DocumentService,
SelectionData,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'

View File

@@ -9,8 +9,8 @@
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (activeManagementList.selectedObjects.size > 0) { @if (activeManagementList.hasSelection) {
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
} }
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
@@ -25,7 +25,7 @@
<span class="input-group-text border-0" i18n>Select:</span> <span class="input-group-text border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (activeManagementList.selectedObjects.size > 0) { @if (activeManagementList.hasSelection) {
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()"> <button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container> <i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button> </button>
@@ -40,11 +40,11 @@
</div> </div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()" <button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0"> [disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || !activeManagementList.hasSelection">
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container> <i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()" <button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0"> [disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || !activeManagementList.hasSelection">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()" <button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"

View File

@@ -65,8 +65,8 @@
@if (displayCollectionSize > 0) { @if (displayCollectionSize > 0) {
<div> <div>
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container> <ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
@if (selectedObjects.size > 0) { @if (hasSelection) {
&nbsp;({{selectedObjects.size}} selected) &nbsp;({{selectedCount}} selected)
} }
</div> </div>
} }

View File

@@ -117,7 +117,6 @@ describe('ManagementListComponent', () => {
: tags : tags
return of({ return of({
count: results.length, count: results.length,
all: results.map((o) => o.id),
results, results,
}) })
} }
@@ -231,11 +230,11 @@ describe('ManagementListComponent', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
}) })
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => { it('should use API count for pagination and nested ids for displayed total', fakeAsync(() => {
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce( jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
of({ of({
count: 1, count: 1,
all: [1, 2, 3], display_count: 3,
results: tags.slice(0, 1), results: tags.slice(0, 1),
}) })
) )
@@ -315,13 +314,17 @@ describe('ManagementListComponent', () => {
expect(component.togggleAll).toBe(false) expect(component.togggleAll).toBe(false)
}) })
it('selectAll should use all IDs when collection size exists', () => { it('selectAll should activate all-selection mode', () => {
;(component as any).allIDs = [1, 2, 3, 4] ;(tagService.listFiltered as jest.Mock).mockClear()
component.collectionSize = 4 component.collectionSize = tags.length
component.selectAll() component.selectAll()
expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4])) expect(tagService.listFiltered).not.toHaveBeenCalled()
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
expect((component as any).allSelectionActive).toBe(true)
expect(component.hasSelection).toBe(true)
expect(component.selectedCount).toBe(tags.length)
expect(component.togggleAll).toBe(true) expect(component.togggleAll).toBe(true)
}) })
@@ -395,6 +398,33 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled()
}) })
it('should support bulk edit permissions for all filtered items', () => {
const bulkEditPermsSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.setPermissions()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.SetPermissions,
{},
true,
true,
{ is_root: true }
)
})
it('should support bulk delete objects', () => { it('should support bulk delete objects', () => {
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects') const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
component.toggleSelected(tags[0]) component.toggleSelected(tags[0])
@@ -415,7 +445,11 @@ describe('ManagementListComponent', () => {
modal.componentInstance.confirmClicked.emit(null) modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith( expect(bulkEditSpy).toHaveBeenCalledWith(
Array.from(selected), Array.from(selected),
BulkEditObjectOperation.Delete BulkEditObjectOperation.Delete,
null,
null,
false,
null
) )
expect(errorToastSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled()
@@ -426,6 +460,29 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled()
}) })
it('should support bulk delete for all filtered items', () => {
const bulkEditSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.delete()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ is_root: true }
)
})
it('should disallow bulk permissions or delete objects if no global perms', () => { it('should disallow bulk permissions or delete objects if no global perms', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy() expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()

View File

@@ -90,7 +90,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public data: T[] = [] public data: T[] = []
private unfilteredData: T[] = [] private unfilteredData: T[] = []
private allIDs: number[] = [] private currentExtraParams: { [key: string]: any } = null
private allSelectionActive = false
public page = 1 public page = 1
@@ -107,6 +108,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public selectedObjects: Set<number> = new Set() public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false public togggleAll: boolean = false
public get hasSelection(): boolean {
return this.selectedObjects.size > 0 || this.allSelectionActive
}
public get selectedCount(): number {
return this.allSelectionActive
? this.displayCollectionSize
: this.selectedObjects.size
}
ngOnInit(): void { ngOnInit(): void {
this.reloadData() this.reloadData()
@@ -150,11 +161,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
protected getCollectionSize(results: Results<T>): number { protected getCollectionSize(results: Results<T>): number {
return results.all?.length ?? results.count return results.count
} }
protected getDisplayCollectionSize(results: Results<T>): number { protected getDisplayCollectionSize(results: Results<T>): number {
return this.getCollectionSize(results) return results.display_count ?? this.getCollectionSize(results)
} }
getDocumentCount(object: MatchingModel): number { getDocumentCount(object: MatchingModel): number {
@@ -171,6 +182,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
reloadData(extraParams: { [key: string]: any } = null) { reloadData(extraParams: { [key: string]: any } = null) {
this.loading = true this.loading = true
this.currentExtraParams = extraParams
this.clearSelection() this.clearSelection()
this.service this.service
.listFiltered( .listFiltered(
@@ -189,7 +201,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.data = this.filterData(c.results) this.data = this.filterData(c.results)
this.collectionSize = this.getCollectionSize(c) this.collectionSize = this.getCollectionSize(c)
this.displayCollectionSize = this.getDisplayCollectionSize(c) this.displayCollectionSize = this.getDisplayCollectionSize(c)
this.allIDs = c.all
}), }),
delay(100) delay(100)
) )
@@ -346,7 +357,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
return objects.map((o) => o.id) return objects.map((o) => o.id)
} }
private getBulkEditFilters(): { [key: string]: any } {
const filters = { ...this.currentExtraParams }
if (this._nameFilter?.length) {
filters['name__icontains'] = this._nameFilter
}
return filters
}
clearSelection() { clearSelection() {
this.allSelectionActive = false
this.togggleAll = false this.togggleAll = false
this.selectedObjects.clear() this.selectedObjects.clear()
} }
@@ -356,6 +376,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
selectPage() { selectPage() {
this.allSelectionActive = false
this.selectedObjects = new Set(this.getSelectableIDs(this.data)) this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} }
@@ -365,11 +386,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.clearSelection() this.clearSelection()
return return
} }
this.selectedObjects = new Set(this.allIDs)
this.allSelectionActive = true
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} }
toggleSelected(object) { toggleSelected(object) {
if (this.allSelectionActive) {
this.allSelectionActive = false
}
this.selectedObjects.has(object.id) this.selectedObjects.has(object.id)
? this.selectedObjects.delete(object.id) ? this.selectedObjects.delete(object.id)
: this.selectedObjects.add(object.id) : this.selectedObjects.add(object.id)
@@ -377,6 +403,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
protected areAllPageItemsSelected(): boolean { protected areAllPageItemsSelected(): boolean {
if (this.allSelectionActive) {
return this.data.length > 0
}
const ids = this.getSelectableIDs(this.data) const ids = this.getSelectableIDs(this.data)
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id)) return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
} }
@@ -390,10 +419,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_edit_objects( .bulk_edit_objects(
Array.from(this.selectedObjects), this.allSelectionActive ? [] : Array.from(this.selectedObjects),
BulkEditObjectOperation.SetPermissions, BulkEditObjectOperation.SetPermissions,
permissions, permissions,
merge merge,
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -428,8 +459,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_edit_objects( .bulk_edit_objects(
Array.from(this.selectedObjects), this.allSelectionActive ? [] : Array.from(this.selectedObjects),
BulkEditObjectOperation.Delete BulkEditObjectOperation.Delete,
null,
null,
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
) )
.subscribe({ .subscribe({
next: () => { next: () => {

View File

@@ -41,7 +41,6 @@ describe('TagListComponent', () => {
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue( listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
of({ of({
count: 3, count: 3,
all: [1, 2, 3],
results: [ results: [
{ {
id: 1, id: 1,

View File

@@ -9,7 +9,6 @@ import {
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
@@ -77,16 +76,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent)) return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
} }
protected override getCollectionSize(results: Results<Tag>): number {
// Tag list pages are requested with is_root=true (when unfiltered), so
// pagination must follow root count even though `all` includes descendants
return results.count
}
protected override getDisplayCollectionSize(results: Results<Tag>): number {
return super.getCollectionSize(results)
}
protected override getSelectableIDs(tags: Tag[]): number[] { protected override getSelectableIDs(tags: Tag[]): number[] {
const ids: number[] = [] const ids: number[] = []
for (const tag of tags.filter(Boolean)) { for (const tag of tags.filter(Boolean)) {

View File

@@ -1,7 +1,26 @@
import { Document } from './document'
export interface Results<T> { export interface Results<T> {
count: number count: number
results: T[] display_count?: number
all: number[] results: T[]
}
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export interface DocumentResults extends Results<Document> {
selection_data?: SelectionData
} }

View File

@@ -0,0 +1,122 @@
import {
HttpErrorResponse,
HttpHandlerFn,
HttpRequest,
} from '@angular/common/http'
import { throwError } from 'rxjs'
import * as navUtils from '../utils/navigation'
import { createAuthExpiryInterceptor } from './auth-expiry.interceptor'
describe('withAuthExpiryInterceptor', () => {
let interceptor: ReturnType<typeof createAuthExpiryInterceptor>
let dateNowSpy: jest.SpiedFunction<typeof Date.now>
beforeEach(() => {
interceptor = createAuthExpiryInterceptor()
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('reloads when an API request returns 401', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor(
new HttpRequest('GET', '/api/documents/'),
failingHandler('/api/documents/', 401)
).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
it('does not reload for non-401 errors', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor(
new HttpRequest('GET', '/api/documents/'),
failingHandler('/api/documents/', 500)
).subscribe({
error: () => undefined,
})
expect(reloadSpy).not.toHaveBeenCalled()
})
it('does not reload for non-api 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
interceptor(
new HttpRequest('GET', '/accounts/profile/'),
failingHandler('/accounts/profile/', 401)
).subscribe({
error: () => undefined,
})
expect(reloadSpy).not.toHaveBeenCalled()
})
it('reloads only once even with multiple API 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
const request = new HttpRequest('GET', '/api/documents/')
const handler = failingHandler('/api/documents/', 401)
interceptor(request, handler).subscribe({
error: () => undefined,
})
interceptor(request, handler).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
it('retries reload after cooldown for repeated API 401 responses', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
dateNowSpy
.mockReturnValueOnce(1000)
.mockReturnValueOnce(2500)
.mockReturnValueOnce(3501)
const request = new HttpRequest('GET', '/api/documents/')
const handler = failingHandler('/api/documents/', 401)
interceptor(request, handler).subscribe({
error: () => undefined,
})
interceptor(request, handler).subscribe({
error: () => undefined,
})
interceptor(request, handler).subscribe({
error: () => undefined,
})
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
})
function failingHandler(url: string, status: number): HttpHandlerFn {
return (_request) =>
throwError(
() =>
new HttpErrorResponse({
status,
url,
})
)
}

View File

@@ -0,0 +1,37 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http'
import { catchError, Observable, throwError } from 'rxjs'
import { locationReload } from '../utils/navigation'
export const createAuthExpiryInterceptor = (): HttpInterceptorFn => {
let lastReloadAttempt = Number.NEGATIVE_INFINITY
return (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> =>
next(request).pipe(
catchError((error: unknown) => {
if (
error instanceof HttpErrorResponse &&
error.status === 401 &&
request.url.includes('/api/')
) {
const now = Date.now()
if (now - lastReloadAttempt >= 2000) {
lastReloadAttempt = now
locationReload()
}
}
return throwError(() => error)
})
)
}
export const withAuthExpiryInterceptor = createAuthExpiryInterceptor()

View File

@@ -21,6 +21,7 @@ import {
FILTER_HAS_TAGS_ANY, FILTER_HAS_TAGS_ANY,
} from '../data/filter-rule-type' } from '../data/filter-rule-type'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { SETTINGS_KEYS } from '../data/ui-settings' import { SETTINGS_KEYS } from '../data/ui-settings'
import { PermissionsGuard } from '../guards/permissions.guard' import { PermissionsGuard } from '../guards/permissions.guard'
import { DocumentListViewService } from './document-list-view.service' import { DocumentListViewService } from './document-list-view.service'
@@ -126,13 +127,10 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.isReloading).toBeFalsy() expect(documentListViewService.isReloading).toBeFalsy()
expect(documentListViewService.activeSavedViewId).toBeNull() expect(documentListViewService.activeSavedViewId).toBeNull()
@@ -144,12 +142,12 @@ describe('DocumentListViewService', () => {
it('should handle error on page request out of range', () => { it('should handle error on page request out of range', () => {
documentListViewService.currentPage = 50 documentListViewService.currentPage = 50
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush([], { status: 404, statusText: 'Unexpected error' }) req.flush([], { status: 404, statusText: 'Unexpected error' })
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
@@ -166,7 +164,7 @@ describe('DocumentListViewService', () => {
] ]
documentListViewService.setFilterRules(filterRulesAny) documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush( req.flush(
@@ -174,13 +172,13 @@ describe('DocumentListViewService', () => {
{ status: 404, statusText: 'Unexpected error' } { status: 404, statusText: 'Unexpected error' }
) )
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -188,7 +186,7 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 1 documentListViewService.currentPage = 1
documentListViewService.sortField = 'custom_field_999' documentListViewService.sortField = 'custom_field_999'
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush( req.flush(
@@ -197,7 +195,7 @@ describe('DocumentListViewService', () => {
) )
// resets itself // resets itself
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -212,7 +210,7 @@ describe('DocumentListViewService', () => {
] ]
documentListViewService.setFilterRules(filterRulesAny) documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' }) req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
@@ -220,7 +218,7 @@ describe('DocumentListViewService', () => {
// reset the list // reset the list
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -229,7 +227,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.sortReverse).toBeTruthy() expect(documentListViewService.sortReverse).toBeTruthy()
documentListViewService.setSort('added', false) documentListViewService.setSort('added', false)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortField).toEqual('added') expect(documentListViewService.sortField).toEqual('added')
@@ -237,17 +235,40 @@ describe('DocumentListViewService', () => {
documentListViewService.sortField = 'created' documentListViewService.sortField = 'created'
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=true`
) )
expect(documentListViewService.sortField).toEqual('created') expect(documentListViewService.sortField).toEqual('created')
documentListViewService.sortReverse = true documentListViewService.sortReverse = true
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortReverse).toBeTruthy() expect(documentListViewService.sortReverse).toBeTruthy()
}) })
it('restores only known list view state fields from local storage', () => {
try {
localStorage.setItem(
DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG,
'{"currentPage":3,"sortField":"title","sortReverse":false,"__proto__":{"polluted":true},"injected":"ignored"}'
)
const restoredService = TestBed.runInInjectionContext(
() => new DocumentListViewService()
)
expect(restoredService.currentPage).toEqual(3)
expect(restoredService.sortField).toEqual('title')
expect(restoredService.sortReverse).toBeFalsy()
expect(
(restoredService as any).activeListViewState.injected
).toBeUndefined()
expect(({} as any).polluted).toBeUndefined()
} finally {
localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
}
})
it('should load from query params', () => { it('should load from query params', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
const page = 2 const page = 2
@@ -262,7 +283,7 @@ describe('DocumentListViewService', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${ `${environment.apiBaseUrl}documents/?page=${page}&page_size=${
documentListViewService.pageSize documentListViewService.pageSize
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true` }&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(page) expect(documentListViewService.currentPage).toEqual(page)
@@ -279,7 +300,7 @@ describe('DocumentListViewService', () => {
} }
documentListViewService.loadFromQueryParams(convertToParamMap(params)) documentListViewService.loadFromQueryParams(convertToParamMap(params))
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.filterRules).toEqual([ expect(documentListViewService.filterRules).toEqual([
@@ -289,15 +310,12 @@ describe('DocumentListViewService', () => {
}, },
]) ])
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
}) })
it('should use filter rules to update query params', () => { it('should use filter rules to update query params', () => {
documentListViewService.setFilterRules(filterRules) documentListViewService.setFilterRules(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -306,34 +324,26 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 2 documentListViewService.currentPage = 2
let req = httpTestingController.expectOne((request) => let req = httpTestingController.expectOne((request) =>
request.urlWithParams.startsWith( request.urlWithParams.startsWith(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
documentListViewService.setFilterRules(filterRules, true) documentListViewService.setFilterRules(filterRules, true)
const filteredReqs = httpTestingController.match( const filteredReqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(filteredReqs).toHaveLength(1) expect(filteredReqs).toHaveLength(1)
filteredReqs[0].flush(full_results) filteredReqs[0].flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
}) })
it('should support quick filter', () => { it('should support quick filter', () => {
documentListViewService.quickFilter(filterRules) documentListViewService.quickFilter(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -356,21 +366,21 @@ describe('DocumentListViewService', () => {
convertToParamMap(params) convertToParamMap(params)
) )
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
documentListViewService.currentPage = 1 documentListViewService.currentPage = 1
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9`
) )
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true`
) )
documentListViewService.sortField = 'created' documentListViewService.sortField = 'created'
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
documentListViewService.activateSavedView(null) documentListViewService.activateSavedView(null)
}) })
@@ -378,21 +388,18 @@ describe('DocumentListViewService', () => {
it('should support navigating next / previous', () => { it('should support navigating next / previous', () => {
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ req.flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
.flush([])
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy() expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy() expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
documentListViewService.getNext(documents[0].id).subscribe((docId) => { documentListViewService.getNext(documents[0].id).subscribe((docId) => {
@@ -439,7 +446,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
jest jest
.spyOn(documentListViewService, 'getLastPage') .spyOn(documentListViewService, 'getLastPage')
@@ -454,7 +461,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(2) expect(documentListViewService.currentPage).toEqual(2)
const reqs = httpTestingController.match( const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(reqs.length).toBeGreaterThan(0) expect(reqs.length).toBeGreaterThan(0)
}) })
@@ -489,11 +496,11 @@ describe('DocumentListViewService', () => {
.mockReturnValue(documents) .mockReturnValue(documents)
documentListViewService.currentPage = 2 documentListViewService.currentPage = 2
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
const reloadSpy = jest.spyOn(documentListViewService, 'reload') const reloadSpy = jest.spyOn(documentListViewService, 'reload')
documentListViewService.getPrevious(1).subscribe({ documentListViewService.getPrevious(1).subscribe({
@@ -503,7 +510,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
const reqs = httpTestingController.match( const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(reqs.length).toBeGreaterThan(0) expect(reqs.length).toBeGreaterThan(0)
}) })
@@ -516,13 +523,10 @@ describe('DocumentListViewService', () => {
it('should support select a document', () => { it('should support select a document', () => {
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
@@ -530,12 +534,16 @@ describe('DocumentListViewService', () => {
}) })
it('should support select all', () => { it('should support select all', () => {
documentListViewService.selectAll() documentListViewService.reload()
const req = httpTestingController.expectOne( const reloadReq = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(reloadReq.request.method).toEqual('GET')
req.flush(full_results) reloadReq.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.selectedCount).toEqual(documents.length)
expect(documentListViewService.selected.size).toEqual(documents.length) expect(documentListViewService.selected.size).toEqual(documents.length)
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectNone() documentListViewService.selectNone()
@@ -544,16 +552,13 @@ describe('DocumentListViewService', () => {
it('should support select page', () => { it('should support select page', () => {
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ req.flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.selectPage() documentListViewService.selectPage()
expect(documentListViewService.selected.size).toEqual(3) expect(documentListViewService.selected.size).toEqual(3)
expect(documentListViewService.isSelected(documents[5])).toBeFalsy() expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
@@ -562,13 +567,10 @@ describe('DocumentListViewService', () => {
it('should support select range', () => { it('should support select range', () => {
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectRangeTo(documents[2]) documentListViewService.selectRangeTo(documents[2])
@@ -577,26 +579,62 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.isSelected(documents[3])).toBeTruthy() expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
}) })
it('should support selection range reduction', () => { it('should clear all-selected mode when toggling a single document', () => {
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush(full_results)
documentListViewService.selectAll() documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.allSelected).toBeFalsy()
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
})
it('should clear all-selected mode when selecting a range', () => {
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush(full_results)
documentListViewService.selectAll()
documentListViewService.toggleSelected(documents[1])
documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
documentListViewService.selectRangeTo(documents[3])
expect(documentListViewService.allSelected).toBeFalsy()
expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
expect(documentListViewService.isSelected(documents[2])).toBeTruthy()
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
})
it('should support selection range reduction', () => {
documentListViewService.reload()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.selected.size).toEqual(6) expect(documentListViewService.selected.size).toEqual(6)
documentListViewService.setFilterRules(filterRules) documentListViewService.setFilterRules(filterRules)
httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
) )
const reqs = httpTestingController.match( req.flush({
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
)
reqs[0].flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.selected.size).toEqual(3) expect(documentListViewService.selected.size).toEqual(3)
}) })
@@ -604,7 +642,7 @@ describe('DocumentListViewService', () => {
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending') const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
documentListViewService.reload() documentListViewService.reload()
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
) )
expect(cancelSpy).toHaveBeenCalled() expect(cancelSpy).toHaveBeenCalled()
}) })
@@ -623,7 +661,7 @@ describe('DocumentListViewService', () => {
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
expect(documentListViewService.sortField).toEqual('created') expect(documentListViewService.sortField).toEqual('created')
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -650,11 +688,11 @@ describe('DocumentListViewService', () => {
expect(localStorageSpy).toHaveBeenCalled() expect(localStorageSpy).toHaveBeenCalled()
// reload triggered // reload triggered
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
documentListViewService.displayFields = null documentListViewService.displayFields = null
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(documentListViewService.displayFields).toEqual( expect(documentListViewService.displayFields).toEqual(
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map( DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
@@ -694,7 +732,7 @@ describe('DocumentListViewService', () => {
it('should generate quick filter URL preserving default state', () => { it('should generate quick filter URL preserving default state', () => {
documentListViewService.reload() documentListViewService.reload()
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
const urlTree = documentListViewService.getQuickFilterUrl(filterRules) const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
expect(urlTree).toBeDefined() expect(urlTree).toBeDefined()

View File

@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { ParamMap, Router, UrlTree } from '@angular/router' import { ParamMap, Router, UrlTree } from '@angular/router'
import { Observable, Subject, first, takeUntil } from 'rxjs' import { Observable, Subject, takeUntil } from 'rxjs'
import { import {
DEFAULT_DISPLAY_FIELDS, DEFAULT_DISPLAY_FIELDS,
DisplayField, DisplayField,
@@ -8,6 +8,7 @@ import {
Document, Document,
} from '../data/document' } from '../data/document'
import { FilterRule } from '../data/filter-rule' import { FilterRule } from '../data/filter-rule'
import { DocumentResults, SelectionData } from '../data/results'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { SETTINGS_KEYS } from '../data/ui-settings' import { SETTINGS_KEYS } from '../data/ui-settings'
@@ -17,13 +18,27 @@ import {
isFullTextFilterRule, isFullTextFilterRule,
} from '../utils/filter-rules' } from '../utils/filter-rules'
import { paramsFromViewState, paramsToViewState } from '../utils/query-params' import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
import { DocumentService, SelectionData } from './rest/document.service' import { DocumentService } from './rest/document.service'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map( const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
(f) => f.id (f) => f.id
).filter((f) => f !== DisplayField.ADDED) ).filter((f) => f !== DisplayField.ADDED)
const RESTORABLE_LIST_VIEW_STATE_KEYS: (keyof ListViewState)[] = [
'title',
'documents',
'currentPage',
'collectionSize',
'sortField',
'sortReverse',
'filterRules',
'selected',
'pageSize',
'displayMode',
'displayFields',
]
/** /**
* Captures the current state of the list view. * Captures the current state of the list view.
*/ */
@@ -65,6 +80,11 @@ export interface ListViewState {
*/ */
selected?: Set<number> selected?: Set<number>
/**
* True if the full filtered result set is selected.
*/
allSelected?: boolean
/** /**
* The page size of the list view. * The page size of the list view.
*/ */
@@ -112,6 +132,32 @@ export class DocumentListViewService {
private displayFieldsInitialized: boolean = false private displayFieldsInitialized: boolean = false
private restoreListViewState(savedState: unknown): ListViewState {
const newState = this.defaultListViewState()
if (
!savedState ||
typeof savedState !== 'object' ||
Array.isArray(savedState)
) {
return newState
}
const parsedState = savedState as Partial<
Record<keyof ListViewState, unknown>
>
const mutableState = newState as Record<keyof ListViewState, unknown>
for (const key of RESTORABLE_LIST_VIEW_STATE_KEYS) {
const value = parsedState[key]
if (value != null) {
mutableState[key] = value
}
}
return newState
}
get activeSavedViewId() { get activeSavedViewId() {
return this._activeSavedViewId return this._activeSavedViewId
} }
@@ -127,14 +173,7 @@ export class DocumentListViewService {
if (documentListViewConfigJson) { if (documentListViewConfigJson) {
try { try {
let savedState: ListViewState = JSON.parse(documentListViewConfigJson) let savedState: ListViewState = JSON.parse(documentListViewConfigJson)
// Remove null elements from the restored state let newState = this.restoreListViewState(savedState)
Object.keys(savedState).forEach((k) => {
if (savedState[k] == null) {
delete savedState[k]
}
})
// only use restored state attributes instead of defaults if they are not null
let newState = Object.assign(this.defaultListViewState(), savedState)
this.listViewStates.set(null, newState) this.listViewStates.set(null, newState)
} catch (e) { } catch (e) {
localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
@@ -165,6 +204,20 @@ export class DocumentListViewService {
sortReverse: true, sortReverse: true,
filterRules: [], filterRules: [],
selected: new Set<number>(), selected: new Set<number>(),
allSelected: false,
}
}
private syncSelectedToCurrentPage() {
if (!this.allSelected) {
return
}
this.selected.clear()
this.documents?.forEach((doc) => this.selected.add(doc.id))
if (!this.collectionSize) {
this.selectNone()
} }
} }
@@ -260,27 +313,18 @@ export class DocumentListViewService {
activeListViewState.sortField, activeListViewState.sortField,
activeListViewState.sortReverse, activeListViewState.sortReverse,
activeListViewState.filterRules, activeListViewState.filterRules,
{ truncate_content: true } { truncate_content: true, include_selection_data: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
const resultWithSelectionData = result as DocumentResults
this.initialized = true this.initialized = true
this.isReloading = false this.isReloading = false
activeListViewState.collectionSize = result.count activeListViewState.collectionSize = result.count
activeListViewState.documents = result.results activeListViewState.documents = result.results
this.selectionData = resultWithSelectionData.selection_data ?? null
this.documentService this.syncSelectedToCurrentPage()
.getSelectionData(result.all)
.pipe(first())
.subscribe({
next: (selectionData) => {
this.selectionData = selectionData
},
error: () => {
this.selectionData = null
},
})
if (updateQueryParams && !this._activeSavedViewId) { if (updateQueryParams && !this._activeSavedViewId) {
let base = ['/documents'] let base = ['/documents']
@@ -413,6 +457,20 @@ export class DocumentListViewService {
return this.activeListViewState.selected return this.activeListViewState.selected
} }
get allSelected(): boolean {
return this.activeListViewState.allSelected ?? false
}
get selectedCount(): number {
return this.allSelected
? (this.collectionSize ?? this.selected.size)
: this.selected.size
}
get hasSelection(): boolean {
return this.allSelected || this.selected.size > 0
}
setSort(field: string, reverse: boolean) { setSort(field: string, reverse: boolean) {
this.activeListViewState.sortField = field this.activeListViewState.sortField = field
this.activeListViewState.sortReverse = reverse this.activeListViewState.sortReverse = reverse
@@ -567,11 +625,16 @@ export class DocumentListViewService {
} }
selectNone() { selectNone() {
this.activeListViewState.allSelected = false
this.selected.clear() this.selected.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
} }
reduceSelectionToFilter() { reduceSelectionToFilter() {
if (this.allSelected) {
return
}
if (this.selected.size > 0) { if (this.selected.size > 0) {
this.documentService this.documentService
.listAllFilteredIds(this.filterRules) .listAllFilteredIds(this.filterRules)
@@ -586,12 +649,12 @@ export class DocumentListViewService {
} }
selectAll() { selectAll() {
this.documentService this.activeListViewState.allSelected = true
.listAllFilteredIds(this.filterRules) this.syncSelectedToCurrentPage()
.subscribe((ids) => ids.forEach((id) => this.selected.add(id)))
} }
selectPage() { selectPage() {
this.activeListViewState.allSelected = false
this.selected.clear() this.selected.clear()
this.documents.forEach((doc) => { this.documents.forEach((doc) => {
this.selected.add(doc.id) this.selected.add(doc.id)
@@ -599,10 +662,13 @@ export class DocumentListViewService {
} }
isSelected(d: Document) { isSelected(d: Document) {
return this.selected.has(d.id) return this.allSelected || this.selected.has(d.id)
} }
toggleSelected(d: Document): void { toggleSelected(d: Document): void {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.selected.has(d.id)) this.selected.delete(d.id) if (this.selected.has(d.id)) this.selected.delete(d.id)
else this.selected.add(d.id) else this.selected.add(d.id)
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id) this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
@@ -610,6 +676,10 @@ export class DocumentListViewService {
} }
selectRangeTo(d: Document) { selectRangeTo(d: Document) {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.rangeSelectionAnchorIndex !== null) { if (this.rangeSelectionAnchorIndex !== null) {
const documentToIndex = this.documentIndexInCurrentView(d.id) const documentToIndex = this.documentIndexInCurrentView(d.id)
const fromIndex = Math.min( const fromIndex = Math.min(

View File

@@ -96,6 +96,30 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
}) })
req.flush([]) req.flush([])
}) })
test('should call appropriate api endpoint for bulk delete on all filtered objects', () => {
subscription = service
.bulk_edit_objects(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ name__icontains: 'hello' }
)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_objects/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
object_type: endpoint,
operation: BulkEditObjectOperation.Delete,
all: true,
filters: { name__icontains: 'hello' },
})
req.flush([])
})
}) })
beforeEach(() => { beforeEach(() => {

View File

@@ -37,13 +37,22 @@ export abstract class AbstractNameFilterService<
objects: Array<number>, objects: Array<number>,
operation: BulkEditObjectOperation, operation: BulkEditObjectOperation,
permissions: { owner: number; set_permissions: PermissionsObject } = null, permissions: { owner: number; set_permissions: PermissionsObject } = null,
merge: boolean = null merge: boolean = null,
all: boolean = false,
filters: { [key: string]: any } = null
): Observable<string> { ): Observable<string> {
const params = { const params: any = {
objects,
object_type: this.resourceName, object_type: this.resourceName,
operation, operation,
} }
if (all) {
params['all'] = true
if (filters) {
params['filters'] = filters
}
} else {
params['objects'] = objects
}
if (operation === BulkEditObjectOperation.SetPermissions) { if (operation === BulkEditObjectOperation.SetPermissions) {
params['owner'] = permissions?.owner params['owner'] = permissions?.owner
params['permissions'] = permissions?.set_permissions params['permissions'] = permissions?.set_permissions

View File

@@ -198,7 +198,7 @@ describe(`DocumentService`, () => {
const content = 'both' const content = 'both'
const useFilenameFormatting = false const useFilenameFormatting = false
subscription = service subscription = service
.bulkDownload(ids, content, useFilenameFormatting) .bulkDownload({ documents: ids }, content, useFilenameFormatting)
.subscribe() .subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_download/` `${environment.apiBaseUrl}${endpoint}/bulk_download/`
@@ -218,7 +218,9 @@ describe(`DocumentService`, () => {
add_tags: [15], add_tags: [15],
remove_tags: [6], remove_tags: [6],
} }
subscription = service.bulkEdit(ids, method, parameters).subscribe() subscription = service
.bulkEdit({ documents: ids }, method, parameters)
.subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/` `${environment.apiBaseUrl}${endpoint}/bulk_edit/`
) )
@@ -230,9 +232,32 @@ describe(`DocumentService`, () => {
}) })
}) })
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
const method = 'modify_tags'
const parameters = {
add_tags: [15],
remove_tags: [6],
}
const selection = {
all: true,
filters: { title__icontains: 'apple' },
}
subscription = service.bulkEdit(selection, method, parameters).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method,
parameters,
})
})
it('should call appropriate api endpoint for delete documents', () => { it('should call appropriate api endpoint for delete documents', () => {
const ids = [1, 2, 3] const ids = [1, 2, 3]
subscription = service.deleteDocuments(ids).subscribe() subscription = service.deleteDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/delete/` `${environment.apiBaseUrl}${endpoint}/delete/`
) )
@@ -244,7 +269,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for reprocess documents', () => { it('should call appropriate api endpoint for reprocess documents', () => {
const ids = [1, 2, 3] const ids = [1, 2, 3]
subscription = service.reprocessDocuments(ids).subscribe() subscription = service.reprocessDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/reprocess/` `${environment.apiBaseUrl}${endpoint}/reprocess/`
) )
@@ -256,7 +281,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for rotate documents', () => { it('should call appropriate api endpoint for rotate documents', () => {
const ids = [1, 2, 3] const ids = [1, 2, 3]
subscription = service.rotateDocuments(ids, 90).subscribe() subscription = service.rotateDocuments({ documents: ids }, 90).subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/rotate/` `${environment.apiBaseUrl}${endpoint}/rotate/`
) )

View File

@@ -12,7 +12,7 @@ import {
import { DocumentMetadata } from 'src/app/data/document-metadata' import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions' import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { Results } from 'src/app/data/results' import { Results, SelectionData } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { queryParamsFromFilterRules } from '../../utils/query-params' import { queryParamsFromFilterRules } from '../../utils/query-params'
import { import {
@@ -24,19 +24,6 @@ import { SettingsService } from '../settings.service'
import { AbstractPaperlessService } from './abstract-paperless-service' import { AbstractPaperlessService } from './abstract-paperless-service'
import { CustomFieldsService } from './custom-fields.service' import { CustomFieldsService } from './custom-fields.service'
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export enum BulkEditSourceMode { export enum BulkEditSourceMode {
LATEST_VERSION = 'latest_version', LATEST_VERSION = 'latest_version',
EXPLICIT_SELECTION = 'explicit_selection', EXPLICIT_SELECTION = 'explicit_selection',
@@ -81,6 +68,12 @@ export interface RemovePasswordDocumentsRequest {
source_mode?: BulkEditSourceMode source_mode?: BulkEditSourceMode
} }
export interface DocumentSelectionQuery {
documents?: number[]
all?: boolean
filters?: { [key: string]: any }
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -338,33 +331,37 @@ export class DocumentService extends AbstractPaperlessService<Document> {
return this.http.get<DocumentMetadata>(url.toString()) return this.http.get<DocumentMetadata>(url.toString())
} }
bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) { bulkEdit(
selection: DocumentSelectionQuery,
method: DocumentBulkEditMethod,
args: any
) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
documents: ids, ...selection,
method: method, method: method,
parameters: args, parameters: args,
}) })
} }
deleteDocuments(ids: number[]) { deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), { return this.http.post(this.getResourceUrl(null, 'delete'), {
documents: ids, ...selection,
}) })
} }
reprocessDocuments(ids: number[]) { reprocessDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), { return this.http.post(this.getResourceUrl(null, 'reprocess'), {
documents: ids, ...selection,
}) })
} }
rotateDocuments( rotateDocuments(
ids: number[], selection: DocumentSelectionQuery,
degrees: number, degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) { ) {
return this.http.post(this.getResourceUrl(null, 'rotate'), { return this.http.post(this.getResourceUrl(null, 'rotate'), {
documents: ids, ...selection,
degrees, degrees,
source_mode: sourceMode, source_mode: sourceMode,
}) })
@@ -412,14 +409,14 @@ export class DocumentService extends AbstractPaperlessService<Document> {
} }
bulkDownload( bulkDownload(
ids: number[], selection: DocumentSelectionQuery,
content = 'both', content = 'both',
useFilenameFormatting: boolean = false useFilenameFormatting: boolean = false
) { ) {
return this.http.post( return this.http.post(
this.getResourceUrl(null, 'bulk_download'), this.getResourceUrl(null, 'bulk_download'),
{ {
documents: ids, ...selection,
content: content, content: content,
follow_formatting: useFilenameFormatting, follow_formatting: useFilenameFormatting,
}, },

View File

@@ -166,6 +166,23 @@ describe('SettingsService', () => {
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f') expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
}) })
it('ignores unsafe top-level keys from loaded settings', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
const payload = JSON.parse(
JSON.stringify(ui_settings).replace(
'"settings":{',
'"settings":{"__proto__":{"polluted":"yes"},'
)
)
payload.settings.app_title = 'Safe Title'
req.flush(payload)
expect(settingsService.get(SETTINGS_KEYS.APP_TITLE)).toEqual('Safe Title')
expect(({} as any).polluted).toBeUndefined()
})
it('correctly allows updating settings of various types', () => { it('correctly allows updating settings of various types', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/` `${environment.apiBaseUrl}ui_settings/`

View File

@@ -276,6 +276,8 @@ const ISO_LANGUAGE_OPTION: LanguageOption = {
dateInputFormat: 'yyyy-mm-dd', dateInputFormat: 'yyyy-mm-dd',
} }
const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -291,7 +293,7 @@ export class SettingsService {
protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/' protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/'
private settings: Object = {} private settings: Record<string, any> = {}
currentUser: User currentUser: User
public settingsSaved: EventEmitter<any> = new EventEmitter() public settingsSaved: EventEmitter<any> = new EventEmitter()
@@ -320,6 +322,21 @@ export class SettingsService {
this._renderer = rendererFactory.createRenderer(null, null) this._renderer = rendererFactory.createRenderer(null, null)
} }
private isSafeObjectKey(key: string): boolean {
return !UNSAFE_OBJECT_KEYS.has(key)
}
private assignSafeSettings(source: Record<string, any>) {
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return
}
for (const key of Object.keys(source)) {
if (!this.isSafeObjectKey(key)) continue
this.settings[key] = source[key]
}
}
// this is called by the app initializer in app.module // this is called by the app initializer in app.module
public initializeSettings(): Observable<UiSettings> { public initializeSettings(): Observable<UiSettings> {
return this.http.get<UiSettings>(this.baseUrl).pipe( return this.http.get<UiSettings>(this.baseUrl).pipe(
@@ -338,7 +355,7 @@ export class SettingsService {
}) })
}), }),
tap((uisettings) => { tap((uisettings) => {
Object.assign(this.settings, uisettings.settings) this.assignSafeSettings(uisettings.settings)
if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) { if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) {
environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE) environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE)
} }
@@ -533,7 +550,11 @@ export class SettingsService {
let settingObj = this.settings let settingObj = this.settings
keys.forEach((keyPart, index) => { keys.forEach((keyPart, index) => {
keyPart = keyPart.replace(/-/g, '_') keyPart = keyPart.replace(/-/g, '_')
if (!settingObj.hasOwnProperty(keyPart)) return if (
!this.isSafeObjectKey(keyPart) ||
!Object.prototype.hasOwnProperty.call(settingObj, keyPart)
)
return
if (index == keys.length - 1) value = settingObj[keyPart] if (index == keys.length - 1) value = settingObj[keyPart]
else settingObj = settingObj[keyPart] else settingObj = settingObj[keyPart]
}) })
@@ -579,7 +600,9 @@ export class SettingsService {
const keys = key.replace('general-settings:', '').split(':') const keys = key.replace('general-settings:', '').split(':')
keys.forEach((keyPart, index) => { keys.forEach((keyPart, index) => {
keyPart = keyPart.replace(/-/g, '_') keyPart = keyPart.replace(/-/g, '_')
if (!settingObj.hasOwnProperty(keyPart)) settingObj[keyPart] = {} if (!this.isSafeObjectKey(keyPart)) return
if (!Object.prototype.hasOwnProperty.call(settingObj, keyPart))
settingObj[keyPart] = {}
if (index == keys.length - 1) settingObj[keyPart] = value if (index == keys.length - 1) settingObj[keyPart] = value
else settingObj = settingObj[keyPart] else settingObj = settingObj[keyPart]
}) })
@@ -602,7 +625,10 @@ export class SettingsService {
maybeMigrateSettings() { maybeMigrateSettings() {
if ( if (
!this.settings.hasOwnProperty('documentListSize') && !Object.prototype.hasOwnProperty.call(
this.settings,
'documentListSize'
) &&
localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
) { ) {
// lets migrate // lets migrate
@@ -610,8 +636,7 @@ export class SettingsService {
const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.` const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.`
try { try {
for (const setting in SETTINGS_KEYS) { for (const key of Object.values(SETTINGS_KEYS)) {
const key = SETTINGS_KEYS[setting]
const value = localStorage.getItem(key) const value = localStorage.getItem(key)
this.set(key, value) this.set(key, value)
} }

View File

@@ -62,7 +62,7 @@ export function hslToRgb(h, s, l) {
* @return Array The HSL representation * @return Array The HSL representation
*/ */
export function rgbToHsl(r, g, b) { export function rgbToHsl(r, g, b) {
;(r /= 255), (g /= 255), (b /= 255) ;((r /= 255), (g /= 255), (b /= 255))
var max = Math.max(r, g, b), var max = Math.max(r, g, b),
min = Math.min(r, g, b) min = Math.min(r, g, b)
var h, var h,

View File

@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py apiVersion: '10', // match src/paperless/settings.py
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'prod', tag: 'prod',
version: '2.20.10', version: '2.20.13',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -154,6 +154,7 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard' import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
import { PermissionsGuard } from './app/guards/permissions.guard' import { PermissionsGuard } from './app/guards/permissions.guard'
import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor' import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
import { withAuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor'
import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor' import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor'
import { DocumentTitlePipe } from './app/pipes/document-title.pipe' import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
import { FilterPipe } from './app/pipes/filter.pipe' import { FilterPipe } from './app/pipes/filter.pipe'
@@ -399,7 +400,11 @@ bootstrapApplication(AppComponent, {
StoragePathNamePipe, StoragePathNamePipe,
provideHttpClient( provideHttpClient(
withInterceptorsFromDi(), withInterceptorsFromDi(),
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]), withInterceptors([
withCsrfInterceptor,
withApiVersionInterceptor,
withAuthExpiryInterceptor,
]),
withFetch() withFetch()
), ),
provideUiTour({ provideUiTour({

View File

@@ -150,6 +150,15 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
background-color: var(--pngx-body-color-accent); background-color: var(--pngx-body-color-accent);
} }
.list-group-item-action:not(.active):active {
--bs-list-group-action-active-color: var(--bs-body-color);
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
}
.form-control:hover::file-selector-button {
background-color:var(--pngx-bg-dark) !important
}
.search-container { .search-container {
input, input:focus, i-bs[name="search"] , ::placeholder { input, input:focus, i-bs[name="search"] , ::placeholder {
color: var(--pngx-primary-text-contrast) !important; color: var(--pngx-primary-text-contrast) !important;

View File

@@ -3,24 +3,19 @@ from django.core.checks import Error
from django.core.checks import Warning from django.core.checks import Warning
from django.core.checks import register from django.core.checks import register
from documents.signals import document_consumer_declaration
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from paperless.parsers.registry import get_parser_registry
@register() @register()
def parser_check(app_configs, **kwargs): def parser_check(app_configs, **kwargs):
parsers = [] if not get_parser_registry().all_parsers():
for response in document_consumer_declaration.send(None):
parsers.append(response[1])
if len(parsers) == 0:
return [ return [
Error( Error(
"No parsers found. This is a bug. The consumer won't be " "No parsers found. This is a bug. The consumer won't be "
"able to consume any documents without parsers.", "able to consume any documents without parsers.",
), ),
] ]
else:
return [] return []

View File

@@ -9,6 +9,7 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Iterator from collections.abc import Iterator
from datetime import datetime from datetime import datetime
@@ -191,7 +192,12 @@ class DocumentClassifier:
target_file_temp.rename(target_file) target_file_temp.rename(target_file)
def train(self) -> bool: def train(
self,
status_callback: Callable[[str], None] | None = None,
) -> bool:
notify = status_callback if status_callback is not None else lambda _: None
# Get non-inbox documents # Get non-inbox documents
docs_queryset = ( docs_queryset = (
Document.objects.exclude( Document.objects.exclude(
@@ -213,6 +219,7 @@ class DocumentClassifier:
# Step 1: Extract and preprocess training data from the database. # Step 1: Extract and preprocess training data from the database.
logger.debug("Gathering data from database...") logger.debug("Gathering data from database...")
notify(f"Gathering data from {docs_queryset.count()} document(s)...")
hasher = sha256() hasher = sha256()
for doc in docs_queryset: for doc in docs_queryset:
y = -1 y = -1
@@ -290,6 +297,7 @@ class DocumentClassifier:
# Step 2: vectorize data # Step 2: vectorize data
logger.debug("Vectorizing data...") logger.debug("Vectorizing data...")
notify("Vectorizing document content...")
def content_generator() -> Iterator[str]: def content_generator() -> Iterator[str]:
""" """
@@ -316,6 +324,7 @@ class DocumentClassifier:
# Step 3: train the classifiers # Step 3: train the classifiers
if num_tags > 0: if num_tags > 0:
logger.debug("Training tags classifier...") logger.debug("Training tags classifier...")
notify(f"Training tags classifier ({num_tags} tag(s))...")
if num_tags == 1: if num_tags == 1:
# Special case where only one tag has auto: # Special case where only one tag has auto:
@@ -339,6 +348,9 @@ class DocumentClassifier:
if num_correspondents > 0: if num_correspondents > 0:
logger.debug("Training correspondent classifier...") logger.debug("Training correspondent classifier...")
notify(
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
)
self.correspondent_classifier = MLPClassifier(tol=0.01) self.correspondent_classifier = MLPClassifier(tol=0.01)
self.correspondent_classifier.fit(data_vectorized, labels_correspondent) self.correspondent_classifier.fit(data_vectorized, labels_correspondent)
else: else:
@@ -349,6 +361,9 @@ class DocumentClassifier:
if num_document_types > 0: if num_document_types > 0:
logger.debug("Training document type classifier...") logger.debug("Training document type classifier...")
notify(
f"Training document type classifier ({num_document_types} type(s))...",
)
self.document_type_classifier = MLPClassifier(tol=0.01) self.document_type_classifier = MLPClassifier(tol=0.01)
self.document_type_classifier.fit(data_vectorized, labels_document_type) self.document_type_classifier.fit(data_vectorized, labels_document_type)
else: else:
@@ -361,6 +376,7 @@ class DocumentClassifier:
logger.debug( logger.debug(
"Training storage paths classifier...", "Training storage paths classifier...",
) )
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
self.storage_path_classifier = MLPClassifier(tol=0.01) self.storage_path_classifier = MLPClassifier(tol=0.01)
self.storage_path_classifier.fit( self.storage_path_classifier.fit(
data_vectorized, data_vectorized,

View File

@@ -1,6 +1,6 @@
import datetime import datetime
import hashlib
import os import os
import shutil
import tempfile import tempfile
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
@@ -32,9 +32,7 @@ from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.parsers import DocumentParser
from documents.parsers import ParseError from documents.parsers import ParseError
from documents.parsers import get_parser_class_for_mime_type
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
from documents.plugins.base import AlwaysRunPluginMixin from documents.plugins.base import AlwaysRunPluginMixin
from documents.plugins.base import ConsumeTaskPlugin from documents.plugins.base import ConsumeTaskPlugin
@@ -48,10 +46,13 @@ from documents.signals import document_consumption_started
from documents.signals import document_updated from documents.signals import document_updated
from documents.signals.handlers import run_workflows from documents.signals.handlers import run_workflows
from documents.templating.workflows import parse_w_workflow_placeholders from documents.templating.workflows import parse_w_workflow_placeholders
from documents.utils import compute_checksum
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess from documents.utils import run_subprocess
from paperless_mail.parsers import MailDocumentParser from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol
from paperless.parsers.registry import get_parser_registry
LOGGING_NAME: Final[str] = "paperless.consumer" LOGGING_NAME: Final[str] = "paperless.consumer"
@@ -196,9 +197,7 @@ class ConsumerPlugin(
version_doc = Document( version_doc = Document(
root_document=root_doc_frozen, root_document=root_doc_frozen,
version_index=next_version_index + 1, version_index=next_version_index + 1,
checksum=hashlib.md5( checksum=compute_checksum(file_for_checksum),
file_for_checksum.read_bytes(),
).hexdigest(),
content=text or "", content=text or "",
page_count=page_count, page_count=page_count,
mime_type=mime_type, mime_type=mime_type,
@@ -338,18 +337,15 @@ class ConsumerPlugin(
Return the document object if it was successfully created. Return the document object if it was successfully created.
""" """
tempdir = None
try:
# Preflight has already run including progress update to 0% # Preflight has already run including progress update to 0%
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
tempdir = tempfile.TemporaryDirectory( with tempfile.TemporaryDirectory(
prefix="paperless-ngx", prefix="paperless-ngx",
dir=settings.SCRATCH_DIR, dir=settings.SCRATCH_DIR,
) ) as tmpdir:
self.working_copy = Path(tempdir.name) / Path(self.filename) self.working_copy = Path(tmpdir) / Path(self.filename)
copy_file_with_basic_stats(self.input_doc.original_file, self.working_copy) copy_file_with_basic_stats(self.input_doc.original_file, self.working_copy)
self.unmodified_original = None self.unmodified_original = None
@@ -381,7 +377,7 @@ class ConsumerPlugin(
self.log.debug(f"Detected mime type after qpdf: {mime_type}") self.log.debug(f"Detected mime type after qpdf: {mime_type}")
# Save the original file for later # Save the original file for later
self.unmodified_original = ( self.unmodified_original = (
Path(tempdir.name) / Path("uo") / Path(self.filename) Path(tmpdir) / Path("uo") / Path(self.filename)
) )
self.unmodified_original.parent.mkdir(exist_ok=True) self.unmodified_original.parent.mkdir(exist_ok=True)
copy_file_with_basic_stats( copy_file_with_basic_stats(
@@ -392,11 +388,14 @@ class ConsumerPlugin(
self.log.error(f"Error attempting to clean PDF: {e}") self.log.error(f"Error attempting to clean PDF: {e}")
# Based on the mime type, get the parser for that type # Based on the mime type, get the parser for that type
parser_class: type[DocumentParser] | None = get_parser_class_for_mime_type( parser_class: type[ParserProtocol] | None = (
get_parser_registry().get_parser_for_file(
mime_type, mime_type,
self.filename,
self.working_copy,
)
) )
if not parser_class: if not parser_class:
tempdir.cleanup()
self._fail( self._fail(
ConsumerStatusShortMessage.UNSUPPORTED_TYPE, ConsumerStatusShortMessage.UNSUPPORTED_TYPE,
f"Unsupported mime type {mime_type}", f"Unsupported mime type {mime_type}",
@@ -411,27 +410,16 @@ class ConsumerPlugin(
) )
self.run_pre_consume_script() self.run_pre_consume_script()
except:
if tempdir:
tempdir.cleanup()
raise
def progress_callback(
current_progress,
max_progress,
) -> None: # pragma: no cover
# recalculate progress to be within 20 and 80
p = int((current_progress / max_progress) * 50 + 20)
self._send_progress(p, 100, ProgressStatusOptions.WORKING)
# This doesn't parse the document yet, but gives us a parser. # This doesn't parse the document yet, but gives us a parser.
with parser_class() as document_parser:
document_parser: DocumentParser = parser_class( document_parser.configure(
self.logging_group, ParserContext(mailrule_id=self.input_doc.mailrule_id),
progress_callback=progress_callback,
) )
self.log.debug(f"Parser: {type(document_parser).__name__}") self.log.debug(
f"Parser: {document_parser.name} v{document_parser.version}",
)
# Parse the document. This may take some time. # Parse the document. This may take some time.
@@ -449,18 +437,8 @@ class ConsumerPlugin(
ConsumerStatusShortMessage.PARSING_DOCUMENT, ConsumerStatusShortMessage.PARSING_DOCUMENT,
) )
self.log.debug(f"Parsing {self.filename}...") self.log.debug(f"Parsing {self.filename}...")
if (
isinstance(document_parser, MailDocumentParser) document_parser.parse(self.working_copy, mime_type)
and self.input_doc.mailrule_id
):
document_parser.parse(
self.working_copy,
mime_type,
self.filename,
self.input_doc.mailrule_id,
)
else:
document_parser.parse(self.working_copy, mime_type, self.filename)
self.log.debug(f"Generating thumbnail for {self.filename}...") self.log.debug(f"Generating thumbnail for {self.filename}...")
self._send_progress( self._send_progress(
@@ -472,7 +450,6 @@ class ConsumerPlugin(
thumbnail = document_parser.get_thumbnail( thumbnail = document_parser.get_thumbnail(
self.working_copy, self.working_copy,
mime_type, mime_type,
self.filename,
) )
text = document_parser.get_text() text = document_parser.get_text()
@@ -487,12 +464,12 @@ class ConsumerPlugin(
with get_date_parser() as date_parser: with get_date_parser() as date_parser:
date = next(date_parser.parse(self.filename, text), None) date = next(date_parser.parse(self.filename, text), None)
archive_path = document_parser.get_archive_path() archive_path = document_parser.get_archive_path()
page_count = document_parser.get_page_count(self.working_copy, mime_type) page_count = document_parser.get_page_count(
self.working_copy,
mime_type,
)
except ParseError as e: except ParseError as e:
document_parser.cleanup()
if tempdir:
tempdir.cleanup()
self._fail( self._fail(
str(e), str(e),
f"Error occurred while consuming document {self.filename}: {e}", f"Error occurred while consuming document {self.filename}: {e}",
@@ -500,9 +477,6 @@ class ConsumerPlugin(
exception=e, exception=e,
) )
except Exception as e: except Exception as e:
document_parser.cleanup()
if tempdir:
tempdir.cleanup()
self._fail( self._fail(
str(e), str(e),
f"Unexpected error while consuming document {self.filename}: {e}", f"Unexpected error while consuming document {self.filename}: {e}",
@@ -549,7 +523,9 @@ class ConsumerPlugin(
settings.AUDIT_LOG_ENABLED settings.AUDIT_LOG_ENABLED
and self.metadata.actor_id is not None and self.metadata.actor_id is not None
): ):
actor = User.objects.filter(pk=self.metadata.actor_id).first() actor = User.objects.filter(
pk=self.metadata.actor_id,
).first()
if actor is not None: if actor is not None:
from auditlog.context import ( # type: ignore[import-untyped] from auditlog.context import ( # type: ignore[import-untyped]
set_actor, set_actor,
@@ -656,10 +632,9 @@ class ConsumerPlugin(
document.archive_path, document.archive_path,
) )
with Path(archive_path).open("rb") as f: document.archive_checksum = compute_checksum(
document.archive_checksum = hashlib.md5( document.archive_path,
f.read(), )
).hexdigest()
# Don't save with the lock active. Saving will cause the file # Don't save with the lock active. Saving will cause the file
# renaming logic to acquire the lock as well. # renaming logic to acquire the lock as well.
@@ -673,7 +648,9 @@ class ConsumerPlugin(
) )
# Delete the file only if it was successfully consumed # Delete the file only if it was successfully consumed
self.log.debug(f"Deleting original file {self.input_doc.original_file}") self.log.debug(
f"Deleting original file {self.input_doc.original_file}",
)
self.input_doc.original_file.unlink() self.input_doc.original_file.unlink()
self.log.debug(f"Deleting working copy {self.working_copy}") self.log.debug(f"Deleting working copy {self.working_copy}")
self.working_copy.unlink() self.working_copy.unlink()
@@ -701,9 +678,6 @@ class ConsumerPlugin(
exc_info=True, exc_info=True,
exception=e, exception=e,
) )
finally:
document_parser.cleanup()
tempdir.cleanup()
self.run_post_consume_script(document) self.run_post_consume_script(document)
@@ -800,7 +774,7 @@ class ConsumerPlugin(
title=title[:127], title=title[:127],
content=text, content=text,
mime_type=mime_type, mime_type=mime_type,
checksum=hashlib.md5(file_for_checksum.read_bytes()).hexdigest(), checksum=compute_checksum(file_for_checksum),
created=create_date, created=create_date,
modified=create_date, modified=create_date,
page_count=page_count, page_count=page_count,
@@ -848,7 +822,7 @@ class ConsumerPlugin(
self.metadata.view_users is not None self.metadata.view_users is not None
or self.metadata.view_groups is not None or self.metadata.view_groups is not None
or self.metadata.change_users is not None or self.metadata.change_users is not None
or self.metadata.change_users is not None or self.metadata.change_groups is not None
): ):
permissions = { permissions = {
"view": { "view": {
@@ -881,7 +855,7 @@ class ConsumerPlugin(
Path(source).open("rb") as read_file, Path(source).open("rb") as read_file,
Path(target).open("wb") as write_file, Path(target).open("wb") as write_file,
): ):
write_file.write(read_file.read()) shutil.copyfileobj(read_file, write_file)
# Attempt to copy file's original stats, but it's ok if we can't # Attempt to copy file's original stats, but it's ok if we can't
try: try:
@@ -917,10 +891,9 @@ class ConsumerPreflightPlugin(
def pre_check_duplicate(self) -> None: def pre_check_duplicate(self) -> None:
""" """
Using the MD5 of the file, check this exact file doesn't already exist Using the SHA256 of the file, check this exact file doesn't already exist
""" """
with Path(self.input_doc.original_file).open("rb") as f: checksum = compute_checksum(Path(self.input_doc.original_file))
checksum = hashlib.md5(f.read()).hexdigest()
existing_doc = Document.global_objects.filter( existing_doc = Document.global_objects.filter(
Q(checksum=checksum) | Q(archive_checksum=checksum), Q(checksum=checksum) | Q(archive_checksum=checksum),
) )

View File

@@ -375,6 +375,26 @@ class DelayedQuery:
] ]
return self._manual_hits_cache return self._manual_hits_cache
def get_result_ids(self) -> list[int]:
"""
Return all matching document IDs for the current query and ordering.
"""
if self._manual_sort_requested():
return [hit["id"] for hit in self._manual_hits()]
q, mask, suggested_correction = self._get_query()
self.suggested_correction = suggested_correction
sortedby, reverse = self._get_query_sortedby()
results = self.searcher.search(
q,
mask=mask,
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
limit=None,
sortedby=sortedby,
reverse=reverse,
)
return [hit["id"] for hit in results]
def __getitem__(self, item): def __getitem__(self, item):
if item.start in self.saved_results: if item.start in self.saved_results:
return self.saved_results[item.start] return self.saved_results[item.start]
@@ -477,6 +497,13 @@ class DelayedFullTextQuery(DelayedQuery):
try: try:
corrected = self.searcher.correct_query(q, q_str) corrected = self.searcher.correct_query(q, q_str)
if corrected.string != q_str: if corrected.string != q_str:
corrected_results = self.searcher.search(
corrected.query,
limit=1,
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
scored=False,
)
if len(corrected_results) > 0:
suggested_correction = corrected.string suggested_correction = corrected.string
except Exception as e: except Exception as e:
logger.info( logger.info(

View File

@@ -1,13 +1,32 @@
from django.core.management.base import BaseCommand from __future__ import annotations
import time
from documents.management.commands.base import PaperlessCommand
from documents.tasks import train_classifier from documents.tasks import train_classifier
class Command(BaseCommand): class Command(PaperlessCommand):
help = ( help = (
"Trains the classifier on your data and saves the resulting models to a " "Trains the classifier on your data and saves the resulting models to a "
"file. The document consumer will then automatically use this new model." "file. The document consumer will then automatically use this new model."
) )
supports_progress_bar = False
supports_multiprocessing = False
def handle(self, *args, **options): def handle(self, *args, **options) -> None:
train_classifier(scheduled=False) start = time.monotonic()
with (
self.buffered_logging("paperless.tasks"),
self.buffered_logging("paperless.classifier"),
):
train_classifier(
scheduled=False,
status_callback=lambda msg: self.console.print(f" {msg}"),
)
elapsed = time.monotonic() - start
self.console.print(
f"[green]✓[/green] Classifier training complete ({elapsed:.1f}s)",
)

View File

@@ -56,6 +56,7 @@ from documents.models import WorkflowTrigger
from documents.settings import EXPORTER_ARCHIVE_NAME from documents.settings import EXPORTER_ARCHIVE_NAME
from documents.settings import EXPORTER_FILE_NAME from documents.settings import EXPORTER_FILE_NAME
from documents.settings import EXPORTER_THUMBNAIL_NAME from documents.settings import EXPORTER_THUMBNAIL_NAME
from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from paperless import version from paperless import version
from paperless.models import ApplicationConfiguration from paperless.models import ApplicationConfiguration
@@ -384,10 +385,10 @@ class Command(CryptMixin, PaperlessCommand):
"workflow_webhook_actions": WorkflowActionWebhook.objects.all(), "workflow_webhook_actions": WorkflowActionWebhook.objects.all(),
"workflows": Workflow.objects.all(), "workflows": Workflow.objects.all(),
"custom_fields": CustomField.objects.all(), "custom_fields": CustomField.objects.all(),
"custom_field_instances": CustomFieldInstance.objects.all(), "custom_field_instances": CustomFieldInstance.global_objects.all(),
"app_configs": ApplicationConfiguration.objects.all(), "app_configs": ApplicationConfiguration.objects.all(),
"notes": Note.objects.all(), "notes": Note.global_objects.all(),
"documents": Document.objects.order_by("id").all(), "documents": Document.global_objects.order_by("id").all(),
"social_accounts": SocialAccount.objects.all(), "social_accounts": SocialAccount.objects.all(),
"social_apps": SocialApp.objects.all(), "social_apps": SocialApp.objects.all(),
"social_tokens": SocialToken.objects.all(), "social_tokens": SocialToken.objects.all(),
@@ -442,7 +443,7 @@ class Command(CryptMixin, PaperlessCommand):
writer.write_batch(batch) writer.write_batch(batch)
document_map: dict[int, Document] = { document_map: dict[int, Document] = {
d.pk: d for d in Document.objects.order_by("id") d.pk: d for d in Document.global_objects.order_by("id")
} }
# 3. Export files from each document # 3. Export files from each document
@@ -618,12 +619,15 @@ class Command(CryptMixin, PaperlessCommand):
"""Write per-document manifest file for --split-manifest mode.""" """Write per-document manifest file for --split-manifest mode."""
content = [document_dict] content = [document_dict]
content.extend( content.extend(
serializers.serialize("python", Note.objects.filter(document=document)), serializers.serialize(
"python",
Note.global_objects.filter(document=document),
),
) )
content.extend( content.extend(
serializers.serialize( serializers.serialize(
"python", "python",
CustomFieldInstance.objects.filter(document=document), CustomFieldInstance.global_objects.filter(document=document),
), ),
) )
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json") manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
@@ -693,7 +697,7 @@ class Command(CryptMixin, PaperlessCommand):
source_stat = source.stat() source_stat = source.stat()
target_stat = target.stat() target_stat = target.stat()
if self.compare_checksums and source_checksum: if self.compare_checksums and source_checksum:
target_checksum = hashlib.md5(target.read_bytes()).hexdigest() target_checksum = compute_checksum(target)
perform_copy = target_checksum != source_checksum perform_copy = target_checksum != source_checksum
elif ( elif (
source_stat.st_mtime != target_stat.st_mtime source_stat.st_mtime != target_stat.st_mtime

View File

@@ -125,7 +125,7 @@ class Command(CryptMixin, PaperlessCommand):
"Found existing user(s), this might indicate a non-empty installation", "Found existing user(s), this might indicate a non-empty installation",
), ),
) )
if Document.objects.count() != 0: if Document.global_objects.count() != 0:
self.stdout.write( self.stdout.write(
self.style.WARNING( self.style.WARNING(
"Found existing documents(s), this might indicate a non-empty installation", "Found existing documents(s), this might indicate a non-empty installation",
@@ -205,7 +205,7 @@ class Command(CryptMixin, PaperlessCommand):
ContentType.objects.all().delete() ContentType.objects.all().delete()
Permission.objects.all().delete() Permission.objects.all().delete()
for manifest_path in self.manifest_paths: for manifest_path in self.manifest_paths:
call_command("loaddata", manifest_path) call_command("loaddata", manifest_path, skip_checks=True)
except (FieldDoesNotExist, DeserializationError, IntegrityError) as e: except (FieldDoesNotExist, DeserializationError, IntegrityError) as e:
self.stdout.write(self.style.ERROR("Database import failed")) self.stdout.write(self.style.ERROR("Database import failed"))
if ( if (
@@ -376,7 +376,7 @@ class Command(CryptMixin, PaperlessCommand):
] ]
for record in self.track(document_records, description="Copying files..."): for record in self.track(document_records, description="Copying files..."):
document = Document.objects.get(pk=record["pk"]) document = Document.global_objects.get(pk=record["pk"])
doc_file = record[EXPORTER_FILE_NAME] doc_file = record[EXPORTER_FILE_NAME]
document_path = self.source / doc_file document_path = self.source / doc_file

View File

@@ -3,14 +3,18 @@ import shutil
from documents.management.commands.base import PaperlessCommand from documents.management.commands.base import PaperlessCommand
from documents.models import Document from documents.models import Document
from documents.parsers import get_parser_class_for_mime_type from paperless.parsers.registry import get_parser_registry
logger = logging.getLogger("paperless.management.thumbnails") logger = logging.getLogger("paperless.management.thumbnails")
def _process_document(doc_id: int) -> None: def _process_document(doc_id: int) -> None:
document: Document = Document.objects.get(id=doc_id) document: Document = Document.objects.get(id=doc_id)
parser_class = get_parser_class_for_mime_type(document.mime_type) parser_class = get_parser_registry().get_parser_for_file(
document.mime_type,
document.original_filename or "",
document.source_path,
)
if parser_class is None: if parser_class is None:
logger.warning( logger.warning(
@@ -20,17 +24,9 @@ def _process_document(doc_id: int) -> None:
) )
return return
parser = parser_class(logging_group=None) with parser_class() as parser:
thumb = parser.get_thumbnail(document.source_path, document.mime_type)
try:
thumb = parser.get_thumbnail(
document.source_path,
document.mime_type,
document.get_public_filename(),
)
shutil.move(thumb, document.thumbnail_path) shutil.move(thumb, document.thumbnail_path)
finally:
parser.cleanup()
class Command(PaperlessCommand): class Command(PaperlessCommand):

View File

@@ -0,0 +1,130 @@
import hashlib
import logging
from pathlib import Path
from django.conf import settings
from django.db import migrations
from django.db import models
logger = logging.getLogger("paperless.migrations")
_CHUNK_SIZE = 65536 # 64 KiB — avoids loading entire files into memory
_BATCH_SIZE = 500 # documents per bulk_update call
_PROGRESS_INTERVAL = 500 # log a progress line every N documents
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
while chunk := fh.read(_CHUNK_SIZE):
h.update(chunk)
return h.hexdigest()
def recompute_checksums(apps, schema_editor):
"""Recompute all document checksums from MD5 to SHA256."""
Document = apps.get_model("documents", "Document")
total = Document.objects.count()
if total == 0:
return
logger.info("Recomputing SHA-256 checksums for %d document(s)...", total)
batch: list = []
processed = 0
for doc in Document.objects.only(
"pk",
"filename",
"checksum",
"archive_filename",
"archive_checksum",
).iterator(chunk_size=_BATCH_SIZE):
updated_fields: list[str] = []
# Reconstruct source path the same way Document.source_path does
fname = str(doc.filename) if doc.filename else f"{doc.pk:07}.pdf"
source_path = (settings.ORIGINALS_DIR / Path(fname)).resolve()
if source_path.exists():
doc.checksum = _sha256(source_path)
updated_fields.append("checksum")
else:
logger.warning(
"Document %s: original file %s not found, checksum not updated.",
doc.pk,
source_path,
)
# Mirror Document.has_archive_version: archive_filename is not None
if doc.archive_filename is not None:
archive_path = (
settings.ARCHIVE_DIR / Path(str(doc.archive_filename))
).resolve()
if archive_path.exists():
doc.archive_checksum = _sha256(archive_path)
updated_fields.append("archive_checksum")
else:
logger.warning(
"Document %s: archive file %s not found, checksum not updated.",
doc.pk,
archive_path,
)
if updated_fields:
batch.append(doc)
processed += 1
if len(batch) >= _BATCH_SIZE:
Document.objects.bulk_update(batch, ["checksum", "archive_checksum"])
batch.clear()
if processed % _PROGRESS_INTERVAL == 0:
logger.info(
"SHA-256 checksum progress: %d/%d (%d%%)",
processed,
total,
processed * 100 // total,
)
if batch:
Document.objects.bulk_update(batch, ["checksum", "archive_checksum"])
logger.info(
"SHA-256 checksum recomputation complete: %d document(s) processed.",
total,
)
class Migration(migrations.Migration):
dependencies = [
("documents", "0015_document_version_index_and_more"),
]
operations = [
migrations.AlterField(
model_name="document",
name="checksum",
field=models.CharField(
editable=False,
help_text="The checksum of the original document.",
max_length=64,
verbose_name="checksum",
),
),
migrations.AlterField(
model_name="document",
name="archive_checksum",
field=models.CharField(
blank=True,
editable=False,
help_text="The checksum of the archived document.",
max_length=64,
null=True,
verbose_name="archive checksum",
),
),
migrations.RunPython(recompute_checksums, migrations.RunPython.noop),
]

View File

@@ -216,14 +216,14 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
checksum = models.CharField( checksum = models.CharField(
_("checksum"), _("checksum"),
max_length=32, max_length=64,
editable=False, editable=False,
help_text=_("The checksum of the original document."), help_text=_("The checksum of the original document."),
) )
archive_checksum = models.CharField( archive_checksum = models.CharField(
_("archive checksum"), _("archive checksum"),
max_length=32, max_length=64,
editable=False, editable=False,
blank=True, blank=True,
null=True, null=True,

Some files were not shown because too many files have changed in this diff Show More