Compare commits

...
Author SHA1 Message Date
Trenton HandClaude Sonnet 5.5 6d61214bba Fix: satisfy pyrefly in test_pdf_ops and clarify pdf_ops docstrings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 15:03:31 -07:00
Trenton HandClaude Sonnet 5.5 dcfe389909 Chore: drop stale type-check baseline entries for bulk_edit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 14:59:01 -07:00
Trenton HandClaude Sonnet 5.5 0beb0a1d0b Fix: reject delete_pages page numbers below 1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 14:58:36 -07:00
Trenton HandClaude Sonnet 5.5 bfaea71a83 Refactor: use pdf_ops for PDF page work in bulk_edit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 14:54:15 -07:00
Trenton HandClaude Sonnet 5.5 0b7cecb6bb Refactor: add pure pdf_ops module with real-PDF tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 14:47:53 -07:00
dependabot[bot] 8adbff1423 Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates (#14351)
Bumps the pre-commit-dependencies group with 3 updates in the / directory: [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier), prettier and [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit).


Updates `https://github.com/rbubley/mirrors-prettier` from v3.9.8 to 3.9.9
- [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.8...v3.9.9)

Updates `prettier` from 3.9.8 to 3.9.9

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

---
updated-dependencies:
- dependency-name: https://github.com/rbubley/mirrors-prettier
  dependency-version: 3.9.9
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: prettier
  dependency-version: 3.9.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/astral-sh/ruff-pre-commit
  dependency-version: 0.16.9
  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-10-03 13:58:23 -07:00
dependabot[bot] 7c11be81ca Chore(deps-dev): Bump types-channels (#14335)
Bumps [types-channels](https://github.com/python/typeshed) from 4.3.0.20260408 to 4.3.0.20260518.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-channels
  dependency-version: 4.3.0.20260518
  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-10-03 20:04:48 +00:00
dependabot[bot] 3e46186440 docker(deps): Bump astral-sh/uv (#14327)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.12.20-python3.14-trixie-slim to 0.12.23-python3.14-trixie-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.12.20...0.12.23)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.12.21-python3.14-trixie-slim
  dependency-type: direct:production
  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-10-03 12:52:54 -07:00
dependabot[bot] 6ce43be400 Chore(deps): Bump the actions group across 1 directory with 4 updates (#14332)
Bumps the actions group with 4 updates in the / directory: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action), [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [crowdin/github-action](https://github.com/crowdin/github-action).


Updates `github/codeql-action/upload-sarif` from 4.38.1 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/1c5b675653bb5c22dbe9b12b556ec555138e09fd...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/init` from 4.38.1 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/1c5b675653bb5c22dbe9b12b556ec555138e09fd...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/analyze` from 4.38.1 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/1c5b675653bb5c22dbe9b12b556ec555138e09fd...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `crowdin/github-action` from 3.2.0 to 3.3.0
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/df474cdfb9f41d6ae777118749477c2cdf7cacc8...9c23991700c0ec5256fd41089b9d9d7d540e424e)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 20:42:36 -07:00
dependabot[bot] 0204009b66 Chore(deps): Bump the uv group across 1 directory with 2 updates (#14325)
Bumps the uv group with 2 updates in the / directory: [django](https://github.com/django/django) and [tornado](https://github.com/tornadoweb/tornado).


Updates `django` from 5.2.16 to 5.2.17
- [Commits](https://github.com/django/django/compare/5.2.16...5.2.17)

Updates `tornado` from 6.5.8 to 6.5.9
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.8...v6.5.9)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.17
  dependency-type: direct:production
- dependency-name: tornado
  dependency-version: 6.5.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-03 01:07:57 +00:00
GitHub Actions cec0b99d2e Auto translate strings 2026-10-02 23:10:15 +00:00
Trenton H 3615a5a2a5 Fix: Ensure log rotation settings are converted to integers (#14343)
* Fix: Ensure the log rotatation options are integers

* This might have the same problem

* Just in case, transition these to the helper too
2026-10-02 23:08:48 +00:00
Trenton H 56687614df Fix: Wrap apt calls into a retry so we can ideally jump a slow mirror (#14344)
* Fix: Wrap apt calls into a retry so we can ideally jump from a slow mirror to a faster one

* Use the different synxtax for an in-repo usage
2026-10-02 15:55:54 -07:00
GitHub Actions f1ae9c00ac Auto translate strings 2026-10-02 05:59:48 +00:00
dependabot[bot] bc2ff953ed Chore(deps): Bump the frontend-angular-dependencies group (#14329)
Bumps the frontend-angular-dependencies group in /src-ui with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `22.1.7` | `22.2.0` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `22.1.7` | `22.2.0` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `22.1.7` | `22.2.0` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `22.1.7` | `22.2.0` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `22.1.7` | `22.2.0` |
| [@angular/localize](https://github.com/angular/angular) | `22.1.7` | `22.2.0` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `22.1.7` | `22.2.0` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `22.1.7` | `22.2.0` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.0` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.0` |
| [@angular/build](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.0` |
| [@angular/cli](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.0` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `22.1.7` | `22.2.0` |


Updates `@angular/cdk` from 22.1.7 to 22.2.0
- [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/v22.1.7...v22.2.0)

Updates `@angular/common` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.2.0/packages/common)

Updates `@angular/compiler` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.2.0/packages/compiler)

Updates `@angular/core` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.2.0/packages/core)

Updates `@angular/forms` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.2.0/packages/forms)

Updates `@angular/localize` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/compare/v22.1.7...v22.2.0)

Updates `@angular/platform-browser` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.2.0/packages/platform-browser)

Updates `@angular/router` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.2.0/packages/router)

Updates `@angular-devkit/core` from 22.1.8 to 22.2.0
- [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/v22.1.8...v22.2.0)

Updates `@angular-devkit/schematics` from 22.1.8 to 22.2.0
- [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/v22.1.8...v22.2.0)

Updates `@angular/build` from 22.1.8 to 22.2.0
- [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/v22.1.8...v22.2.0)

Updates `@angular/cli` from 22.1.8 to 22.2.0
- [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/v22.1.8...v22.2.0)

Updates `@angular/compiler-cli` from 22.1.7 to 22.2.0
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.2.0/packages/compiler-cli)

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/core"
  dependency-version: 22.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/schematics"
  dependency-version: 22.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/build"
  dependency-version: 22.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/cli"
  dependency-version: 22.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 22.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 05:58:14 +00:00
dependabot[bot] e17689a91b Chore(deps-dev): Bump prettier from 3.9.8 to 3.9.9 in /src-ui (#14331)
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.8 to 3.9.9.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.8...3.9.9)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.9
  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-10-02 05:42:48 +00:00
dependabot[bot] 764d1a492d Chore(deps-dev): Bump the frontend-eslint-dependencies group (#14330)
Bumps the frontend-eslint-dependencies group in /src-ui with 3 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) and [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils).


Updates `@typescript-eslint/eslint-plugin` from 8.70.0 to 8.70.1
- [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.70.1/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.70.0 to 8.70.1
- [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.70.1/packages/parser)

Updates `@typescript-eslint/utils` from 8.70.0 to 8.70.1
- [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.70.1/packages/utils)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.70.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.70.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.70.1
  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-10-02 05:31:40 +00:00
9d50a3b261 Fix: ship pdf.js CMaps so CJK documents render in the viewer (#14318)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-02 03:27:15 +00:00
dependabot[bot] da3de299fc Chore(deps-dev): Bump zensical (#14326)
Bumps the development group with 1 update: [zensical](https://github.com/zensical/zensical).


Updates `zensical` from 0.0.64 to 0.0.65
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.64...v0.0.65)

---
updated-dependencies:
- dependency-name: zensical
  dependency-version: 0.0.65
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 20:32:40 +00:00
GitHub Actions 3a022e2dc4 Auto translate strings 2026-10-01 20:02:19 +00:00
shamoon 58bda48682 Enhancement: more control over suggestion requests (#14258) 2026-10-01 20:00:52 +00:00
dependabot[bot] b2a2e15ead Chore(deps): Bump the uv group across 1 directory with 2 updates (#14314)
Bumps the uv group with 2 updates in the / directory: [pyjwt](https://github.com/jpadilla/pyjwt) and [urllib3](https://github.com/urllib3/urllib3).


Updates `pyjwt` from 2.13.0 to 2.15.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.13.0...2.15.0)

Updates `urllib3` from 2.7.0 to 2.8.0
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.15.0
  dependency-type: indirect
- dependency-name: urllib3
  dependency-version: 2.8.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-10-01 17:14:42 +00:00
shamoon e865185425 Chore: anchor admin url pattern (#14316) 2026-10-01 01:22:06 -07:00
shamoon d02c19a09e Chorehancement: set manifest CORS for credentials (#14307) 2026-09-30 08:03:04 -07:00
dependabot[bot]andstumpylog 4a106454ce Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates (#14305)
* Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates

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

| Package | From | To |
| --- | --- | --- |
| [django-guardian](https://github.com/django-guardian/django-guardian) | `3.4.1` | `3.5.0` |
| [flower](https://github.com/mher/flower) | `2.1.0` | `2.2.0` |
| [sentence-transformers](https://github.com/huggingface/sentence-transformers) | `6.0.1` | `6.1.0` |
| [watchfiles](https://github.com/samuelcolvin/watchfiles) | `1.2.0` | `1.3.0` |
| [mysqlclient](https://github.com/PyMySQL/mysqlclient) | `2.2.8` | `2.3.0` |
| [faker](https://github.com/joke2k/faker) | `40.38.0` | `40.39.0` |
| [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) | `16.6.1` | `16.7` |



Updates `django-guardian` from 3.4.1 to 3.5.0
- [Release notes](https://github.com/django-guardian/django-guardian/releases)
- [Commits](https://github.com/django-guardian/django-guardian/compare/3.4.1...3.5.0)

Updates `flower` from 2.1.0 to 2.2.0
- [Release notes](https://github.com/mher/flower/releases)
- [Commits](https://github.com/mher/flower/compare/v2.1.0...v2.2.0)

Updates `sentence-transformers` from 6.0.1 to 6.1.0
- [Release notes](https://github.com/huggingface/sentence-transformers/releases)
- [Commits](https://github.com/huggingface/sentence-transformers/compare/v6.0.1...v6.1.0)

Updates `watchfiles` from 1.2.0 to 1.3.0
- [Release notes](https://github.com/samuelcolvin/watchfiles/releases)
- [Commits](https://github.com/samuelcolvin/watchfiles/compare/v1.2.0...v1.3.0)

Updates `mysqlclient` from 2.2.8 to 2.3.0
- [Release notes](https://github.com/PyMySQL/mysqlclient/releases)
- [Changelog](https://github.com/PyMySQL/mysqlclient/blob/main/HISTORY.rst)
- [Commits](https://github.com/PyMySQL/mysqlclient/compare/v2.2.8...v2.3.0)

Updates `faker` from 40.38.0 to 40.39.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.38.0...v40.39.0)

Updates `pytest-rerunfailures` from 16.6.1 to 16.7
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/16.6.1...16.7)

---
updated-dependencies:
- dependency-name: django-guardian
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: faker
  dependency-version: 40.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: flower
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: mysqlclient
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: pytest-rerunfailures
  dependency-version: '16.7'
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: sentence-transformers
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: watchfiles
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
...

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

* Linting

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-09-29 13:28:35 -07:00
dependabot[bot] 4254100fae docker-compose(deps): bump greenmail/standalone in /docker/compose (#14281)
Bumps greenmail/standalone from 2.1.13 to 2.1.14.

---
updated-dependencies:
- dependency-name: greenmail/standalone
  dependency-version: 2.1.14
  dependency-type: direct:production
  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-09-29 11:31:43 -07:00
dependabot[bot] 74f727b1f3 docker(deps): Bump astral-sh/uv (#14282)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.12.16-python3.14-trixie-slim to 0.12.20-python3.14-trixie-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.12.16...0.12.20)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.12.19-python3.14-trixie-slim
  dependency-type: direct:production
  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-09-29 10:08:39 -07:00
dependabot[bot] fb6fad952e Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates (#14283)
Bumps the pre-commit-dependencies group with 3 updates in the / directory: [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier), prettier and [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit).


Updates `https://github.com/rbubley/mirrors-prettier` from v3.9.6 to 3.9.8
- [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.6...v3.9.8)

Updates `prettier` from 3.9.6 to 3.9.8

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

---
updated-dependencies:
- dependency-name: https://github.com/astral-sh/ruff-pre-commit
  dependency-version: 0.16.8
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/rbubley/mirrors-prettier
  dependency-version: 3.9.8
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  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-09-29 09:33:49 -07:00
dependabot[bot] f9b4e0c8e0 Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates (#14297)
Bumps the utilities-patch group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [llama-index-core](https://github.com/run-llama/llama_index) | `0.14.24` | `0.14.25` |
| [tantivy](https://github.com/quickwit-oss/tantivy-py) | `0.26.0` | `0.26.2` |
| [psycopg-pool](https://github.com/psycopg/psycopg) | `3.3.1` | `3.3.2` |
| [zensical](https://github.com/zensical/zensical) | `0.0.60` | `0.0.64` |
| [prek](https://github.com/j178/prek) | `0.5.2` | `0.5.3` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.7` | `0.16.8` |



Updates `llama-index-core` from 0.14.24 to 0.14.25
- [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.24...v0.14.25)

Updates `tantivy` from 0.26.0 to 0.26.2
- [Release notes](https://github.com/quickwit-oss/tantivy-py/releases)
- [Commits](https://github.com/quickwit-oss/tantivy-py/compare/0.26.0...0.26.2)

Updates `psycopg-pool` from 3.3.1 to 3.3.2
- [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst)
- [Commits](https://github.com/psycopg/psycopg/compare/3.3.1...3.3.2)

Updates `zensical` from 0.0.60 to 0.0.64
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.60...v0.0.64)

Updates `prek` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.5.2...v0.5.3)

Updates `ruff` from 0.16.7 to 0.16.8
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.7...0.16.8)

---
updated-dependencies:
- dependency-name: llama-index-core
  dependency-version: 0.14.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: prek
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: psycopg-pool
  dependency-version: 3.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: ruff
  dependency-version: 0.16.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: tantivy
  dependency-version: 0.26.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: zensical
  dependency-version: 0.0.63
  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-09-29 08:52:09 -07:00
dependabot[bot] e5041097af Chore(deps): Bump the actions group across 1 directory with 10 updates (#14301)
Bumps the actions group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.1.0` | `10.2.0` |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `7.0.0` | `7.1.1` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.3.0` | `4.4.1` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.3.0` | `7.4.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.38.0` | `4.38.1` |
| [stumpylog/image-cleaner-action/ephemeral](https://github.com/stumpylog/image-cleaner-action) | `0.12.0` | `0.13.0` |
| [stumpylog/image-cleaner-action/untagged](https://github.com/stumpylog/image-cleaner-action) | `0.12.0` | `0.13.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.0` | `4.38.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.0` | `4.38.1` |
| [crowdin/github-action](https://github.com/crowdin/github-action) | `3.0.2` | `3.2.0` |



Updates `astral-sh/setup-uv` from 10.1.0 to 10.2.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/bec219d24cd3e171d82865faccec33120bb574f4...c18668ad3cf93ea998bef934396af7bb5c839dc7)

Updates `codecov/codecov-action` from 7.0.0 to 7.1.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/fb8b3582c8e4def4969c97caa2f19720cb33a72f...303a32d7a59b442fa8d48b6a1cc6825c09c847a5)

Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...f87e5991a6d7451dcb8d9637bfbc97413f497069)

Updates `docker/build-push-action` from 7.3.0 to 7.4.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a...c3c9e263c25d99ce0380d002d59b67737d91b0dc)

Updates `github/codeql-action/upload-sarif` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `stumpylog/image-cleaner-action/ephemeral` from 0.12.0 to 0.13.0
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/4fe057d991d63b8f6d5d22c40f17c1bca2226537...21f875bab2376314e0525c614e923c669434f0e4)

Updates `stumpylog/image-cleaner-action/untagged` from 0.12.0 to 0.13.0
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/4fe057d991d63b8f6d5d22c40f17c1bca2226537...21f875bab2376314e0525c614e923c669434f0e4)

Updates `github/codeql-action/init` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `github/codeql-action/analyze` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `crowdin/github-action` from 3.0.2 to 3.2.0
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/0d5670f539973aea2f01abce61a8989934df0025...df474cdfb9f41d6ae777118749477c2cdf7cacc8)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: codecov/codecov-action
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: stumpylog/image-cleaner-action/ephemeral
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: stumpylog/image-cleaner-action/untagged
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: crowdin/github-action
  dependency-version: 3.2.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-09-29 08:18:28 -07:00
Trenton H 61f36eb253 Chore: Disallow prerelease resolution in uv (#14296)
uv's default "if-necessary" prerelease policy will silently select a
prerelease package when no stable release satisfies the combined
constraint set, even without an explicit opt-in.  Disallow pre-releases
instead
2026-09-28 18:49:21 +00:00
Trenton H e50bcc2efb Chore: Updates typing baseline files (#14295)
* Chore: Refresh the mypy and pyrefly baselines

* Chore: Type factory calls as the model they build for mypy

factory-boy leaves Factory() unannotated, so mypy took UserFactory() to
be a factory instance rather than a User. A shared TypedModelFactory
base declares the call as returning the model for type checking only.

* Refresh again after rebase
2026-09-28 11:32:05 -07:00
dependabot[bot] 88000aab45 Chore(deps-dev): Bump the frontend-jest-dependencies group (#14286)
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.5.1 to 30.5.2
- [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.5.2/packages/jest)

Updates `jest-environment-jsdom` from 30.5.1 to 30.5.2
- [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.5.2/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: jest
  dependency-version: 30.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-jest-dependencies
- dependency-name: jest-environment-jsdom
  dependency-version: 30.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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-09-28 09:22:21 +00:00
dependabot[bot] 01b4ab392c Chore(deps-dev): Bump eslint (#14287)
Bumps the frontend-eslint-dependencies group in /src-ui with 1 update: [eslint](https://github.com/eslint/eslint).


Updates `eslint` from 10.10.0 to 10.11.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.10.0...v10.11.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.11.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-09-28 09:11:55 +00:00
dependabot[bot] 52aa8758e3 Chore(deps-dev): Bump @types/node from 26.5.0 to 26.6.2 in /src-ui (#14288)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.5.0 to 26.6.2.
- [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: 26.6.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 09:00:39 +00:00
dependabot[bot] 4b7cc1c0c3 Chore(deps-dev): Bump prettier from 3.9.6 to 3.9.8 in /src-ui (#14289)
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.6 to 3.9.8.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.8
  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-09-28 08:48:03 +00:00
GitHub Actions f02d7455be Auto translate strings 2026-09-28 08:32:00 +00:00
dependabot[bot] a61c35fcd8 Chore(deps): Bump the frontend-angular-dependencies group (#14285)
Bumps the frontend-angular-dependencies group in /src-ui with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `22.1.6` | `22.1.7` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `22.1.6` | `22.1.7` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `22.1.6` | `22.1.7` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `22.1.6` | `22.1.7` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `22.1.6` | `22.1.7` |
| [@angular/localize](https://github.com/angular/angular) | `22.1.6` | `22.1.7` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `22.1.6` | `22.1.7` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `22.1.6` | `22.1.7` |
| [@ng-select/ng-select](https://github.com/ng-select/ng-select) | `24.1.1` | `24.1.2` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `22.1.6` | `22.1.7` |


Updates `@angular/cdk` from 22.1.6 to 22.1.7
- [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/v22.1.6...v22.1.7)

Updates `@angular/common` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/common)

Updates `@angular/compiler` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler)

Updates `@angular/core` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/core)

Updates `@angular/forms` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/forms)

Updates `@angular/localize` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/compare/v22.1.6...v22.1.7)

Updates `@angular/platform-browser` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/platform-browser)

Updates `@angular/router` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/router)

Updates `@ng-select/ng-select` from 24.1.1 to 24.1.2
- [Release notes](https://github.com/ng-select/ng-select/releases)
- [Commits](https://github.com/ng-select/ng-select/compare/v24.1.1...v24.1.2)

Updates `@angular/compiler-cli` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler-cli)

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@ng-select/ng-select"
  dependency-version: 24.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 22.1.7
  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-09-28 08:30:31 +00:00
GitHub Actions 64552d5d5b Auto translate strings 2026-09-28 06:27:28 +00:00
shamoon a59edf3e5b Fix: use version page_count for versioned document (#14280) 2026-09-27 23:26:00 -07:00
Bernát Gábor 6a5d06ee26 Chore: replace bleach with turbohtml (#14269)
bleach's repository is archived, and it depends on html5lib, which has not had
a release since 2020. turbohtml provides a bleach-compatible clean and a native
linkify that runs in linear time, so the 2048-character cap on email
linkification from #13187 is no longer needed and long messages get their
email addresses linked again.
2026-09-27 16:50:19 -07:00
GitHub Actions 126ec414a8 Auto translate strings 2026-09-27 14:08:00 +00:00
shamoon 773744d04b Enhancement: include Django admin with 2FA (#14270) 2026-09-27 07:06:26 -07:00
shamoon 7b98085b31 Fix: prevent dev pdf viewer link flash (#14268) 2026-09-26 07:02:49 -07:00
shamoon b3bf220f56 Fix: rest of PDF link fix 2026-09-25 20:08:36 -07:00
shamoon d4429c3cc7 Fix: allow pointer events for pdf links in pngx viewer (#14264) 2026-09-25 18:35:21 -07:00
Trenton H 4f777d7438 Feature: propagate resolved secrets to interactive container shells (#14254)
docker exec bash bypasses s6, so *_FILE secrets resolved by init-env-file
into /run/s6/container_environment aren't visible even though s6 services
and management wrappers already see them via with-contenv.

Add /etc/profile.d/contenv.sh, sourced by both login and non-login
interactive bash shells, to export those resolved values into the shell.
2026-09-24 13:07:41 -07:00
60 changed files with 9121 additions and 16408 deletions

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
name: Install apt packages
description: >
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
inputs:
packages:
description: Space separated list of packages to install
required: true
attempts:
description: Maximum number of attempts
default: "3"
attempt-timeout:
description: Seconds before a single attempt is killed
default: "180"
runs:
using: composite
steps:
- name: Install ${{ inputs.packages }}
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
ATTEMPTS: ${{ inputs.attempts }}
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
run: |
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
attempt_install() {
$SUDO apt-get update -q
# Intentionally unquoted, this is a list of package names
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
}
export -f attempt_install
export SUDO PACKAGES
for attempt in $(seq 1 "${ATTEMPTS}"); do
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
start=$(date +%s)
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
echo "::endgroup::"
echo "Installed in $(( $(date +%s) - start ))s"
exit 0
fi
echo "::endgroup::"
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
# A killed attempt can leave dpkg half configured
$SUDO dpkg --configure -a || true
done
echo "::error::apt install failed after ${ATTEMPTS} attempts"
exit 1
-1
View File
@@ -59,7 +59,6 @@ updates:
- "drf-*"
- "djangorestframework"
- "whitenoise"
- "bleach"
- "jinja2"
# Async, Task Queuing & Caching
async-tasks:
+8 -9
View File
@@ -102,17 +102,16 @@ jobs:
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install system dependencies
timeout-minutes: 10
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends \
unpaper tesseract-ocr imagemagick ghostscript poppler-utils
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
@@ -139,13 +138,13 @@ jobs:
pytest
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
flags: backend-python-${{ matrix.python-version }}
files: junit.xml
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
flags: backend-python-${{ matrix.python-version }}
files: coverage.xml
@@ -176,7 +175,7 @@ jobs:
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+3 -3
View File
@@ -106,7 +106,7 @@ jobs:
echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
@@ -132,7 +132,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: ./Dockerfile
@@ -182,7 +182,7 @@ jobs:
echo "Downloaded digests:"
ls -la /tmp/digests/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+7 -6
View File
@@ -174,13 +174,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/
@@ -216,7 +216,7 @@ jobs:
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: '0.12.x'
enable-cache: false
@@ -231,9 +231,10 @@ jobs:
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Install backend system dependencies
run: |
apt-get update
apt-get install --yes --quiet --no-install-recommends libmagic1
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: libmagic1
- name: Install backend dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
+6 -5
View File
@@ -59,7 +59,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
@@ -70,9 +70,10 @@ jobs:
run: |
uv sync --python "${PYTHON_VERSION}" --dev --frozen
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext liblept5
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: gettext liblept5
# ---- Build Documentation ----
- name: Build documentation
env:
@@ -212,7 +213,7 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
if: always()
with:
sarif_file: results.sarif
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
uses: stumpylog/image-cleaner-action/ephemeral@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
@@ -56,7 +56,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@4fe057d991d63b8f6d5d22c40f17c1bca2226537 # v0.12.0
uses: stumpylog/image-cleaner-action/untagged@21f875bab2376314e0525c614e923c669434f0e4 # v0.13.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# 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
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false
- name: crowdin action
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0
with:
upload_translations: false
download_translations: true
+5 -4
View File
@@ -25,11 +25,12 @@ jobs:
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: gettext
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+706 -457
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.6'
rev: 'v3.9.9'
hooks:
- id: prettier
types_or:
@@ -46,11 +46,11 @@ repos:
- ts
- markdown
additional_dependencies:
- prettier@3.9.6
- prettier@3.9.9
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.7
rev: v0.16.9
hooks:
- id: ruff-check
- id: ruff-format
+4801 -12945
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.12.16-python3.14-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.12.23-python3.14-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
@@ -171,7 +171,9 @@ RUN set -eux \
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/*
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
&& echo "Configuring interactive shells to source the s6 container environment" \
&& echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
WORKDIR /usr/src/paperless/src/
+1 -1
View File
@@ -24,7 +24,7 @@ services:
network_mode: host
restart: unless-stopped
greenmail:
image: docker.io/greenmail/standalone:2.1.13
image: docker.io/greenmail/standalone:2.1.14
hostname: greenmail
container_name: greenmail
environment:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Source s6 container environment for interactive shells.
# Ensures variables resolved from *_FILE secret injection are visible
# when using 'docker exec bash'. Does not affect s6 services (those
# use with-contenv directly). Has no effect in non-container contexts
# because the directory will not exist.
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
# only bash-based sessions benefit from this file.
_pngx_contenv="/run/s6/container_environment"
if [ -d "${_pngx_contenv}" ]; then
for _pngx_f in "${_pngx_contenv}"/*; do
[ -f "${_pngx_f}" ] || continue
_pngx_name=$(basename "${_pngx_f}")
_pngx_val=$(cat "${_pngx_f}")
export "${_pngx_name}=${_pngx_val}"
done
fi
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
+9 -7
View File
@@ -136,13 +136,15 @@ for suggested generation and embedding models.
### AI-assisted suggestions
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
storage path and dates by sending the document to the LLM. This is **opt-in per request**
and surfaces through the "Suggest" control on the document detail page, alongside the
classic classifier-based suggestions — it does not disable them. Suggestions are requested
automatically when you open a document that carries an inbox tag unless "Automatically request
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
language can be steered with
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
storage path and dates by sending the document to the LLM using "Suggest" button on the document
detail page. You can choose which type of suggestions are requested by default under Settings >
Documents, either ML (classifier-based) suggestions, AI suggestions, or both. When both are requested
the results are combined.
Suggestions are requested automatically when you open a document that carries an inbox tag
unless "Automatically request suggestions for inbox documents" under Settings > Documents is disabled.
Suggestion output language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
(otherwise it follows the user's UI language).
### The LLM index (RAG) and similar documents
+7 -7
View File
@@ -19,7 +19,6 @@ classifiers = [
dependencies = [
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
"bleach~=6.4.0",
"celery[redis]~=5.6.2",
"channels~=4.2",
"channels-redis~=4.2",
@@ -35,7 +34,7 @@ dependencies = [
"django-cors-headers~=4.9.0",
"django-extensions~=4.1",
"django-filter~=25.1",
"django-guardian>=3.3.3,<3.5",
"django-guardian>=3.3.3,<3.6",
"django-multiselectfield~=1.0.1",
"django-rich~=2.2.0",
"django-soft-delete~=1.0.18",
@@ -45,7 +44,7 @@ dependencies = [
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0",
"flower>=2.0.1,<2.2",
"flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17",
"ijson>=3.5.1",
@@ -77,6 +76,7 @@ dependencies = [
"tantivy~=0.26.0",
"tika-client[httpx]~=1.0",
"torch>=2.13,<2.15",
"turbohtml~=1.10.0",
"watchfiles>=1.2",
"whitenoise~=6.11",
"whoosh-compat[tantivy]==0.3",
@@ -84,13 +84,13 @@ dependencies = [
]
[project.optional-dependencies]
mariadb = [
"mysqlclient~=2.2.7",
"mysqlclient>=2.2.7,<2.4",
]
postgres = [
"psycopg[c,pool]==3.3.4",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3.4",
"psycopg-pool==3.3.1",
"psycopg-pool==3.3.2",
]
webserver = [
"granian[uvloop]>=2.7,<2.9",
@@ -112,7 +112,7 @@ lint = [
testing = [
"daphne",
"factory-boy~=3.3.1",
"faker>=40.36,<40.39",
"faker>=40.36,<40.40",
"imagehash",
"pytest~=9.1.1",
"pytest-cov~=7.1.0",
@@ -136,7 +136,6 @@ typing = [
"mypy",
"mypy-baseline",
"pyrefly",
"types-bleach",
"types-channels",
"types-colorama",
"types-dateparser",
@@ -151,6 +150,7 @@ typing = [
[tool.uv]
required-version = ">=0.9.0"
prerelease = "disallow"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
+5
View File
@@ -91,6 +91,11 @@
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/cmaps/",
"output": "/assets/cmaps/"
}
],
"styles": [
+280 -217
View File
File diff suppressed because it is too large. Load diff
+22 -22
View File
@@ -15,16 +15,16 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^22.1.6",
"@angular/common": "~22.1.6",
"@angular/compiler": "~22.1.6",
"@angular/core": "~22.1.6",
"@angular/forms": "~22.1.6",
"@angular/localize": "~22.1.6",
"@angular/platform-browser": "~22.1.6",
"@angular/router": "~22.1.6",
"@angular/cdk": "^22.2.0",
"@angular/common": "~22.2.0",
"@angular/compiler": "~22.2.0",
"@angular/core": "~22.2.0",
"@angular/forms": "~22.2.0",
"@angular/localize": "~22.2.0",
"@angular/platform-browser": "~22.2.0",
"@angular/router": "~22.2.0",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~24.1.1",
"@ng-select/ng-select": "~24.1.2",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -45,29 +45,29 @@
},
"devDependencies": {
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.1.8",
"@angular-devkit/schematics": "^22.1.8",
"@angular-devkit/core": "^22.2.0",
"@angular-devkit/schematics": "^22.2.0",
"@angular-eslint/builder": "22.5.0",
"@angular-eslint/eslint-plugin": "22.5.0",
"@angular-eslint/eslint-plugin-template": "22.5.0",
"@angular-eslint/schematics": "22.5.0",
"@angular-eslint/template-parser": "22.5.0",
"@angular/build": "22.1.8",
"@angular/cli": "22.1.8",
"@angular/compiler-cli": "~22.1.6",
"@angular/build": "22.2.0",
"@angular/cli": "22.2.0",
"@angular/compiler-cli": "~22.2.0",
"@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.5.0",
"@typescript-eslint/eslint-plugin": "^8.70.0",
"@typescript-eslint/parser": "^8.70.0",
"@typescript-eslint/utils": "^8.70.0",
"eslint": "^10.10.0",
"jest": "30.5.1",
"jest-environment-jsdom": "^30.5.1",
"@types/node": "^26.6.2",
"@typescript-eslint/eslint-plugin": "^8.70.1",
"@typescript-eslint/parser": "^8.70.1",
"@typescript-eslint/utils": "^8.70.1",
"eslint": "^10.11.0",
"jest": "30.5.2",
"jest-environment-jsdom": "^30.5.2",
"jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.6",
"prettier": "^3.9.9",
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.2",
"typescript": "^6.0.3"
+1312 -1960
View File
File diff suppressed because it is too large. Load diff
@@ -253,6 +253,24 @@
</div>
</div>
@if (aiEnabled) {
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Suggestions default to</span>
</div>
<div class="col">
<fieldset class="btn-group btn-group-sm">
<input type="radio" class="btn-check" id="suggestionSourceBoth" [value]="SuggestionSource.Both" formControlName="documentEditingSuggestionSource">
<label class="btn btn-outline-primary" for="suggestionSourceBoth"><ng-container i18n>Both</ng-container></label>
<input type="radio" class="btn-check" id="suggestionSourceML" [value]="SuggestionSource.ML" formControlName="documentEditingSuggestionSource">
<label class="btn btn-outline-primary" for="suggestionSourceML"><i-bs class="me-1" name="cpu"></i-bs><ng-container i18n>ML only</ng-container></label>
<input type="radio" class="btn-check" id="suggestionSourceAI" [value]="SuggestionSource.AI" formControlName="documentEditingSuggestionSource">
<label class="btn btn-outline-primary" for="suggestionSourceAI"><i-bs class="me-1" name="stars"></i-bs><ng-container i18n>AI only</ng-container></label>
</fieldset>
</div>
</div>
}
<div class="row">
<div class="col">
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(34)
expect(setSpy).toHaveBeenCalledTimes(35)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
@@ -44,6 +44,7 @@ import {
HIDEABLE_SIDEBAR_ITEM_IDS,
HideableSidebarItemID,
SETTINGS_KEYS,
SuggestionSource,
} from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -184,6 +185,7 @@ export class SettingsComponent
documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null),
documentEditingAutoSuggest: new FormControl(null),
documentEditingSuggestionSource: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]),
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
@@ -217,6 +219,11 @@ export class SettingsComponent
public readonly PdfZoomScale = PdfZoomScale
public readonly PdfEditorEditMode = PdfEditorEditMode
public readonly SuggestionSource = SuggestionSource
get aiEnabled(): boolean {
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
}
public readonly documentDetailFieldOptions = documentDetailFieldOptions
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
@@ -404,6 +411,9 @@ export class SettingsComponent
documentEditingAutoSuggest: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
),
documentEditingSuggestionSource: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
),
documentDetailsHiddenFields: this.settings.get(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
),
@@ -625,6 +635,10 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
this.settingsForm.value.documentEditingAutoSuggest
)
this.settings.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
this.settingsForm.value.documentEditingSuggestionSource
)
this.settings.set(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
this.settingsForm.value.documentDetailsHiddenFields
@@ -154,11 +154,29 @@
& section {
position: absolute;
text-align: initial;
pointer-events: auto;
box-sizing: border-box;
transform-origin: 0 0;
}
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
position: absolute;
inset: 0;
font-size: 1em;
transition: none;
}
& :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
> a:hover {
opacity: 0.2;
background-color: rgb(255 255 0);
}
& .annotationTextContent {
opacity: 0;
}
}
:host ::ng-deep .textLayer.selecting ~ .annotationLayer section {
pointer-events: none;
}
@@ -1,7 +1,11 @@
import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
import {
LinkTarget,
PDFSinglePageViewer,
PDFViewer,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import { PngxPdfViewerComponent } from './pdf-viewer.component'
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
@@ -58,6 +62,16 @@ describe('PngxPdfViewerComponent', () => {
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
})
it('opens external links in a new tab', () => {
const linkService = (component as any).linkService
expect(linkService.options).toEqual(
expect.objectContaining({
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: 'noopener noreferrer nofollow',
})
)
})
it('resolves the worker source relative to the document base URI', async () => {
setBaseHref('/paperless/')
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
@@ -76,6 +90,7 @@ describe('PngxPdfViewerComponent', () => {
withCredentials: true,
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
cMapUrl: expect.stringContaining('/paperless/assets/cmaps/'),
})
})
@@ -21,6 +21,7 @@ import {
} from 'pdfjs-dist/legacy/build/pdf.mjs'
import {
EventBus,
LinkTarget,
PDFFindController,
PDFLinkService,
PDFSinglePageViewer,
@@ -75,7 +76,11 @@ export class PngxPdfViewerComponent
private lastViewerPage?: number
private readonly eventBus = new EventBus()
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
private readonly linkService = new PDFLinkService({
eventBus: this.eventBus,
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: 'noopener noreferrer nofollow',
})
private readonly findController = new PDFFindController({
eventBus: this.eventBus,
linkService: this.linkService,
@@ -185,6 +190,7 @@ export class PngxPdfViewerComponent
withCredentials: true,
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
cMapUrl: new URL('assets/cmaps/', this.document.baseURI).toString(),
}
this.loadingTask = getDocument(initOptions)
try {
@@ -1,58 +1,84 @@
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
} @else {
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
<div class="d-flex align-items-center">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
} @else {
<i-bs width="1.2em" height="1.2em" name="lightbulb"></i-bs>
}
@if (noSuggestions) {
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
} @else {
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
}
@if (totalSuggestions > 0) {
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
}
</button>
@if (aiEnabled()) {
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<span class="visually-hidden" i18n>Show suggestions</span>
</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
@if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
@if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
@if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div>
}
</div>
</div>
</div>
}
@if (noSuggestions) {
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
} @else {
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
}
@if (totalSuggestions > 0) {
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
}
</button>
</div>
@if (aiEnabled()) {
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<span class="visually-hidden" i18n>Show suggestions</span>
<div ngbDropdown autoClose="outside" placement="bottom-end" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-link position-relative" ngbDropdownToggle [disabled]="disabled() || loading()" i18n-title title="Suggestion options">
<i-bs name="three-dots"></i-bs>
@if (source() !== defaultSource()) {
<span class="position-absolute top-0 start-100 translate-middle p-1 bg-primary border border-light rounded-circle">
<span class="visually-hidden" i18n>Not using default</span>
</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
@if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
@if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
@if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div>
}
<div ngbDropdownMenu class="shadow p-3">
<div class="small text-muted mb-2" i18n>Suggest using:</div>
<div class="form-check small">
<input class="form-check-input" type="checkbox" id="suggestionSourceML" [checked]="useML" [disabled]="useML && !useAI" (change)="setSources($event.target.checked, useAI)">
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceML"><i-bs name="cpu"></i-bs><ng-container i18n>ML</ng-container></label>
</div>
<div class="form-check small">
<input class="form-check-input" type="checkbox" id="suggestionSourceAI" [checked]="useAI" [disabled]="useAI && !useML" (change)="setSources(useML, $event.target.checked)">
<label class="form-check-label d-inline-flex align-items-center gap-1" for="suggestionSourceAI"><i-bs name="stars"></i-bs><ng-container i18n>AI</ng-container></label>
</div>
</div>
</div>
@@ -1,3 +1,7 @@
.suggestions-dropdown {
min-width: 250px;
}
.btn-link.dropdown-toggle::after {
display: none;
}
@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SuggestionSource } from 'src/app/data/ui-settings'
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
describe('SuggestionsDropdownComponent', () => {
@@ -179,14 +180,71 @@ describe('SuggestionsDropdownComponent', () => {
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.detectChanges()
fixture.componentRef.setInput('suggestions', {
suggested_correspondents: [],
suggested_tags: [],
suggested_document_types: [],
})
fixture.detectChanges()
component.clickSuggest()
expect(component.dropdown.open).toBeTruthy()
expect(component.dropdown.isOpen()).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
})
it('should fetch unfetched sources and show existing suggestions', () => {
jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', { tags: [1] })
fixture.detectChanges()
component.clickSuggest()
expect(component.getSuggestions.emit).toHaveBeenCalledWith(
SuggestionSource.Both
)
expect(component.dropdown.isOpen()).toBeTruthy()
})
it('should only show source options when AI is enabled', () => {
expect(
fixture.nativeElement.querySelector('#suggestionSourceML')
).toBeNull()
fixture.componentRef.setInput('aiEnabled', true)
fixture.detectChanges()
fixture.nativeElement
.querySelector('button[title="Suggestion options"]')
.click()
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('#suggestionSourceML')
).not.toBeNull()
})
it('should emit source changes and never allow no source', () => {
const emitSpy = jest.spyOn(component.sourceChange, 'emit')
component.setSources(true, true)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.Both)
component.setSources(true, false)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.ML)
component.setSources(false, true)
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.AI)
emitSpy.mockClear()
component.setSources(false, false)
expect(emitSpy).not.toHaveBeenCalled()
})
it('should indicate a non-default source', () => {
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('source', SuggestionSource.AI)
fixture.componentRef.setInput('defaultSource', SuggestionSource.AI)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Not using default')
fixture.componentRef.setInput('source', SuggestionSource.Both)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Not using default')
})
})
@@ -8,6 +8,7 @@ import {
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { SuggestionSource } from 'src/app/data/ui-settings'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
@Component({
@@ -18,12 +19,16 @@ import { pngxPopperOptions } from 'src/app/utils/popper-options'
})
export class SuggestionsDropdownComponent {
public popperOptions = pngxPopperOptions
public readonly SuggestionSource = SuggestionSource
@ViewChild('dropdown') dropdown: NgbDropdown
readonly suggestions = input<DocumentSuggestions>(null)
readonly aiEnabled = input(false)
readonly loading = input(false)
readonly disabled = input(false)
readonly source = input<SuggestionSource>(SuggestionSource.ML)
readonly defaultSource = input<SuggestionSource>(SuggestionSource.ML)
readonly fetchedSources = input<SuggestionSource[]>([])
readonly appliedTags = input<number[]>([])
readonly appliedCorrespondent = input<number>(null)
@@ -31,8 +36,10 @@ export class SuggestionsDropdownComponent {
readonly appliedStoragePath = input<number>(null)
@Output()
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
new EventEmitter()
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
@Output()
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
@Output()
addTag: EventEmitter<string> = new EventEmitter()
@@ -53,12 +60,42 @@ export class SuggestionsDropdownComponent {
}
if (!this.suggestions()) {
this.getSuggestions.emit(this)
this.getSuggestions.emit(this.source())
} else if (this.hasUnfetchedSources) {
// sources changed, fetch the rest and show what we have meanwhile
this.getSuggestions.emit(this.source())
this.dropdown?.open()
} else {
this.dropdown?.toggle()
}
}
get useML(): boolean {
return this.source() !== SuggestionSource.AI
}
get useAI(): boolean {
return this.source() !== SuggestionSource.ML
}
get hasUnfetchedSources(): boolean {
const fetched = this.fetchedSources()
return (
(this.useML && !fetched.includes(SuggestionSource.ML)) ||
(this.useAI && !fetched.includes(SuggestionSource.AI))
)
}
public setSources(ml: boolean, ai: boolean) {
if (ml && ai) {
this.sourceChange.emit(SuggestionSource.Both)
} else if (ml) {
this.sourceChange.emit(SuggestionSource.ML)
} else if (ai) {
this.sourceChange.emit(SuggestionSource.AI)
}
}
get novelSuggestions(): number {
return (
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
@@ -134,11 +134,15 @@
[loading]="suggestionsLoading()"
[suggestions]="suggestions()"
[aiEnabled]="aiEnabled"
[source]="suggestionSource"
[defaultSource]="defaultSuggestionSource"
[fetchedSources]="fetchedSuggestionSources()"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
(getSuggestions)="getSuggestions()"
(getSuggestions)="getSuggestions($event)"
(sourceChange)="suggestionSourceOverride.set($event)"
(addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)"
(addCorrespondent)="createCorrespondent($event)">
@@ -43,7 +43,7 @@ import {
} from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -1528,6 +1528,113 @@ describe('DocumentDetailComponent', () => {
expect(component.suggestionsLoading()).toBeFalsy()
})
it('should get and merge ML and AI suggestions when source is both', () => {
settingsService.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
SuggestionSource.Both
)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
const suggestionsSpy = jest
.spyOn(documentService, 'getSuggestions')
.mockReturnValue(of({ tags: [42], dates: ['2024-01-01'] }))
const aiSuggestionsSpy = jest
.spyOn(documentService, 'getAiSuggestions')
.mockReturnValue(
of({ title: 'AI title', tags: [42, 43], suggested_tags: ['New'] })
)
initNormally()
expect(suggestionsSpy).toHaveBeenCalled()
expect(aiSuggestionsSpy).toHaveBeenCalled()
expect(component.suggestions().title).toEqual('AI title')
expect(component.suggestions().tags).toEqual([42, 43])
expect(component.suggestions().suggested_tags).toEqual(['New'])
expect(component.suggestions().dates).toEqual(['2024-01-01'])
})
it('should only fetch sources not yet fetched for the document', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
settingsService.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
SuggestionSource.ML
)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
const suggestionsSpy = jest
.spyOn(documentService, 'getSuggestions')
.mockReturnValue(of({ tags: [42] }))
const aiSuggestionsSpy = jest
.spyOn(documentService, 'getAiSuggestions')
.mockReturnValue(of({ tags: [43] }))
initNormally()
component.getSuggestions()
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
component.getSuggestions(SuggestionSource.Both)
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
expect(component.suggestions().tags).toEqual([42, 43])
component.getSuggestions(SuggestionSource.Both)
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
})
it('should use the per-document source override and reset it on document change', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
initNormally()
expect(component.suggestionSource).toEqual(SuggestionSource.AI)
component.suggestionSourceOverride.set(SuggestionSource.ML)
expect(component.suggestionSource).toEqual(SuggestionSource.ML)
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(of(Object.assign({}, doc)))
;(component as any).loadDocument(doc.id, true)
expect(component.suggestionSourceOverride()).toBeNull()
expect(component.fetchedSuggestionSources()).toEqual([])
})
it('should keep suggestions from one source if the other fails', () => {
settingsService.set(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
SuggestionSource.Both
)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
const errorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(documentService, 'getSuggestions')
.mockReturnValue(of({ tags: [42] }))
jest
.spyOn(documentService, 'getAiSuggestions')
.mockReturnValue(throwError(() => new Error('failed')))
initNormally()
expect(errorSpy).toHaveBeenCalled()
expect(component.suggestions().tags).toEqual([42])
expect(component.fetchedSuggestionSources()).toEqual([SuggestionSource.ML])
})
it('should show error if needed for get suggestions', () => {
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
const errorSpy = jest.spyOn(toastService, 'showError')
@@ -28,7 +28,7 @@ import {
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
import {
catchError,
debounceTime,
@@ -48,7 +48,10 @@ import { DataType } from 'src/app/data/datatype'
import { Document, DocumentVersionInfo } from 'src/app/data/document'
import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import {
DocumentSuggestions,
mergeSuggestions,
} from 'src/app/data/document-suggestions'
import { DocumentType } from 'src/app/data/document-type'
import { FilterRule } from 'src/app/data/filter-rule'
import {
@@ -63,7 +66,7 @@ import {
import { ObjectWithId } from 'src/app/data/object-with-id'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -240,6 +243,10 @@ export class DocumentDetailComponent
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
)
private readonly suggestionSourceSetting =
this.settings.getSignal<SuggestionSource>(
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
)
private readonly hiddenFieldsSetting = this.settings.getSignal<
DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@@ -261,6 +268,9 @@ export class DocumentDetailComponent
readonly metadata = signal<DocumentMetadata>(undefined)
readonly suggestions = signal<DocumentSuggestions>(undefined)
readonly suggestionsLoading = signal(false)
// per-document, resets on navigation
readonly suggestionSourceOverride = signal<SuggestionSource>(null)
readonly fetchedSuggestionSources = signal<SuggestionSource[]>([])
readonly users = signal<User[]>(undefined)
readonly title = signal<string>(undefined)
@@ -365,6 +375,15 @@ export class DocumentDetailComponent
return this.autoSuggestSetting()
}
get defaultSuggestionSource(): SuggestionSource {
return this.aiEnabled ? this.suggestionSourceSetting() : SuggestionSource.ML
}
get suggestionSource(): SuggestionSource {
if (!this.aiEnabled) return SuggestionSource.ML
return this.suggestionSourceOverride() ?? this.defaultSuggestionSource
}
get archiveContentRenderType(): ContentRenderType {
const hasArchiveVersion =
this.metadata()?.has_archive_version ??
@@ -590,6 +609,8 @@ export class DocumentDetailComponent
}
this.documentId.set(doc.id)
this.suggestions.set(null)
this.suggestionSourceOverride.set(null)
this.fetchedSuggestionSources.set([])
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId()
)
@@ -1077,29 +1098,44 @@ export class DocumentDetailComponent
return this.documentForm.get('custom_fields') as FormArray
}
getSuggestions() {
getSuggestions(source: SuggestionSource = this.suggestionSource) {
const sources = (
source === SuggestionSource.Both
? [SuggestionSource.ML, SuggestionSource.AI]
: [source]
).filter((s) => !this.fetchedSuggestionSources().includes(s))
if (!sources.length) return
this.suggestionsLoading.set(true)
const suggestionsObservable = this.aiEnabled
? this.documentsService.getAiSuggestions(this.documentId())
: this.documentsService.getSuggestions(this.documentId())
suggestionsObservable
merge(
...sources.map((s) =>
(s === SuggestionSource.AI
? this.documentsService.getAiSuggestions(this.documentId())
: this.documentsService.getSuggestions(this.documentId())
).pipe(
first(),
map((result) => ({ source: s, result })),
catchError((error) => {
this.toastService.showError(
$localize`Error retrieving suggestions.`,
error
)
return of(null)
})
)
)
)
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier),
finalize(() => this.suggestionsLoading.set(false))
)
.subscribe({
next: (result) => {
this.suggestions.set(result)
},
error: (error) => {
this.suggestions.set(null)
this.toastService.showError(
$localize`Error retrieving suggestions.`,
error
)
},
.subscribe((response) => {
if (!response) return
this.fetchedSuggestionSources.update((f) => [...f, response.source])
this.suggestions.set(
mergeSuggestions(this.suggestions(), response.result)
)
})
}
@@ -15,3 +15,33 @@ export interface DocumentSuggestions {
dates?: string[] // ISO-formatted date string e.g. 2022-11-03
}
const union = <T>(a: T[] = [], b: T[] = []): T[] => [...new Set([...a, ...b])]
export function mergeSuggestions(
a: DocumentSuggestions,
b: DocumentSuggestions
): DocumentSuggestions {
if (!a) return b
return {
title: a.title || b.title,
tags: union(a.tags, b.tags),
suggested_tags: union(a.suggested_tags, b.suggested_tags),
correspondents: union(a.correspondents, b.correspondents),
suggested_correspondents: union(
a.suggested_correspondents,
b.suggested_correspondents
),
document_types: union(a.document_types, b.document_types),
suggested_document_types: union(
a.suggested_document_types,
b.suggested_document_types
),
storage_paths: union(a.storage_paths, b.storage_paths),
suggested_storage_paths: union(
a.suggested_storage_paths,
b.suggested_storage_paths
),
dates: union(a.dates, b.dates),
}
}
+13
View File
@@ -20,6 +20,12 @@ export enum GlobalSearchType {
TITLE_CONTENT = 'title-content',
}
export enum SuggestionSource {
ML = 'ml',
AI = 'ai',
Both = 'both',
}
export enum CollapsibleSection {
ATTRIBUTES = 'attributes',
}
@@ -98,6 +104,8 @@ export const SETTINGS_KEYS = {
'general-settings:document-editing:overlay-thumbnail',
DOCUMENT_EDITING_AUTO_SUGGEST:
'general-settings:document-editing:auto-suggest',
DOCUMENT_EDITING_SUGGESTION_SOURCE:
'general-settings:document-editing:suggestion-source',
DOCUMENT_DETAILS_HIDDEN_FIELDS:
'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only',
@@ -326,6 +334,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
type: 'string',
default: SuggestionSource.AI,
},
{
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
type: 'array',
+4
View File
@@ -74,6 +74,7 @@ import {
clipboardCheckFill,
clipboardFill,
clockHistory,
cpu,
creditCard,
dash,
dashCircle,
@@ -118,6 +119,7 @@ import {
infoCircle,
journalBookmarkFill,
journals,
lightbulb,
link,
list,
listNested,
@@ -322,6 +324,7 @@ const icons = {
clipboardCheckFill,
clipboardFill,
clockHistory,
cpu,
cash,
creditCard,
dash,
@@ -367,6 +370,7 @@ const icons = {
infoCircle,
journalBookmarkFill,
journals,
lightbulb,
link,
list,
listNested,
+1
View File
@@ -292,6 +292,7 @@ a.btn-link:active,
a.btn-link:focus-visible,
.btn-link:hover,
.btn-link:active,
.btn-link.show,
.btn-link:focus-visible {
color: var(--pngx-primary-lighten-10) !important;
.primary-light & {
@@ -25,10 +25,20 @@ export class PDFFindController {
onIsPageVisible?: () => boolean
}
export const LinkTarget = {
NONE: 0,
SELF: 1,
BLANK: 2,
PARENT: 3,
TOP: 4,
}
export class PDFLinkService {
private document?: unknown
private viewer?: unknown
constructor(readonly options: Record<string, unknown> = {}) {}
setDocument(document: unknown): void {
this.document = document
}
+185 -219
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging
import tempfile
import uuid
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Literal
@@ -17,6 +18,7 @@ from django.db.models import Max
from django.db.models import Q
from django.utils import timezone
from documents import pdf_ops
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
@@ -116,6 +118,11 @@ def _resolve_root_and_source_doc(
)
def _scratch_path(name: str) -> Path:
"""A path inside a fresh directory under SCRATCH_DIR."""
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
def set_correspondent(
doc_ids: list[int],
correspondent: Correspondent,
@@ -474,8 +481,6 @@ def rotate(
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
docs_by_root_id.setdefault(pair.root_doc.id, pair)
import pikepdf
for pair in docs_by_root_id.values():
if pair.source_doc.mime_type != "application/pdf":
logger.warning(
@@ -488,11 +493,7 @@ def rotate(
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{pair.root_doc.id}_rotated.pdf"
)
with pikepdf.open(pair.source_doc.source_path) as pdf:
for page in pdf.pages:
page.rotate(degrees, relative=True)
pdf.remove_unreferenced_resources()
pdf.save(filepath)
pdf_ops.rotate_pdf(pair.source_doc.source_path, filepath, degrees)
# Preserve metadata/permissions via overrides; mark as new version
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
@@ -535,48 +536,45 @@ def merge(
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
docs_by_id = {doc.id: doc for doc in qs}
affected_docs: list[int] = []
import pikepdf
merged_pdf = pikepdf.new()
version: str = merged_pdf.pdf_version
handoff_asn: int | None = None
# use doc_ids to preserve order
for doc_id in doc_ids:
doc = docs_by_id.get(doc_id)
if doc is None:
continue
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
try:
doc_path = (
pair.source_doc.archive_path
if archive_fallback
and pair.source_doc.mime_type != "application/pdf"
and pair.source_doc.has_archive_version
else pair.source_doc.source_path
)
with pikepdf.open(str(doc_path)) as pdf:
version = max(version, pdf.pdf_version)
merged_pdf.pages.extend(pdf.pages)
affected_docs.append(doc.id)
if handoff_asn is None and doc.archive_serial_number is not None:
handoff_asn = doc.archive_serial_number
except Exception as e:
logger.exception(
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
)
if len(affected_docs) == 0:
logger.warning("No documents were merged")
return "OK"
with pdf_ops.PdfMerger() as merger:
# use doc_ids to preserve order
for doc_id in doc_ids:
doc = docs_by_id.get(doc_id)
if doc is None:
continue
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
try:
# archive_path is None when there is no archive version
archive_path = (
pair.source_doc.archive_path
if archive_fallback
and pair.source_doc.mime_type != "application/pdf"
else None
)
merger.add(
archive_path
if archive_path is not None
else pair.source_doc.source_path,
)
affected_docs.append(doc.id)
if handoff_asn is None and doc.archive_serial_number is not None:
handoff_asn = doc.archive_serial_number
except Exception as e:
logger.exception(
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
)
if len(affected_docs) == 0:
logger.warning("No documents were merged")
return "OK"
filepath = (
Path(
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
filepath = (
Path(
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
)
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
)
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
)
merged_pdf.remove_unreferenced_resources()
merged_pdf.save(filepath, min_version=version)
merged_pdf.close()
merger.save(filepath)
if metadata_document_id:
metadata_document = qs.get(id=metadata_document_id)
@@ -752,64 +750,60 @@ def split(
)
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
import pikepdf
consume_tasks = []
try:
with pikepdf.open(pair.source_doc.source_path) as pdf:
for idx, split_doc in enumerate(pages):
dst: pikepdf.Pdf = pikepdf.new()
for page in split_doc:
dst.pages.append(pdf.pages[page - 1])
filepath: Path = (
Path(
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
)
/ f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"
)
dst.remove_unreferenced_resources()
dst.save(filepath)
dst.close()
outputs = [
(
[pdf_ops.PageSpec(page) for page in split_doc],
partial(_scratch_path, f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"),
)
for split_doc in pages
]
filepaths = pdf_ops.build_pdfs(pair.source_doc.source_path, outputs)
overrides: DocumentMetadataOverrides = (
DocumentMetadataOverrides().from_document(doc)
)
overrides.title = f"{doc.title} (split {idx + 1})"
if user is not None:
overrides.owner_id = user.id
if not delete_originals:
overrides.skip_asn_if_exists = True
logger.info(
f"Adding split document with pages {split_doc} to the task queue.",
)
consume_tasks.append(
consume_file.s(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
),
overrides=overrides,
).set(headers={"trigger_source": trigger_source}),
)
for idx, (split_doc, filepath) in enumerate(
zip(pages, filepaths, strict=True),
):
overrides: DocumentMetadataOverrides = (
DocumentMetadataOverrides().from_document(doc)
)
overrides.title = f"{doc.title} (split {idx + 1})"
if user is not None:
overrides.owner_id = user.id
if not delete_originals:
overrides.skip_asn_if_exists = True
logger.info(
f"Adding split document with pages {split_doc} to the task queue.",
)
consume_tasks.append(
consume_file.s(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
),
overrides=overrides,
).set(headers={"trigger_source": trigger_source}),
)
if delete_originals:
backup = release_archive_serial_numbers([doc.id])
logger.info(
"Queueing removal of original document after consumption of the split documents",
)
try:
chord(
header=consume_tasks,
body=delete.si([doc.id]),
).on_error(
restore_archive_serial_numbers_task.s(backup),
).apply_async()
except Exception:
restore_archive_serial_numbers(backup)
raise
else:
group(consume_tasks).delay()
if delete_originals:
backup = release_archive_serial_numbers([doc.id])
logger.info(
"Queueing removal of original document after consumption of the split documents",
)
try:
chord(
header=consume_tasks,
body=delete.si([doc.id]),
).on_error(
restore_archive_serial_numbers_task.s(backup),
).apply_async()
except Exception:
restore_archive_serial_numbers(backup)
raise
else:
group(consume_tasks).delay()
except Exception as e:
logger.exception(f"Error splitting document {doc.id}: {e}")
@@ -830,8 +824,7 @@ def delete_pages(
)
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
pages = sorted(pages) # sort pages to avoid index issues
import pikepdf
pages = sorted(set(pages))
try:
# Produce edited PDF to a temp file and create a new version
@@ -839,13 +832,7 @@ def delete_pages(
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{pair.root_doc.id}_pages_deleted.pdf"
)
with pikepdf.open(pair.source_doc.source_path) as pdf:
offset = 1 # pages are 1-indexed
for page_num in pages:
pdf.pages.remove(pdf.pages[page_num - offset])
offset += 1 # remove() changes the index of the pages
pdf.remove_unreferenced_resources()
pdf.save(filepath)
pdf_ops.remove_pages(pair.source_doc.source_path, filepath, pages)
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
if user is not None:
@@ -894,47 +881,28 @@ def edit_pdf(
)
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
import pikepdf
pdf_docs: list[pikepdf.Pdf] = []
try:
if not operations:
raise ValueError("Output document index is out of bounds")
max_idx = max(op.get("doc", 0) for op in operations)
if update_document and max_idx > 0:
logger.error(
"Update requested but multiple output documents specified",
output_count = pdf_ops.validate_page_operations(
operations,
single_output=update_document,
)
page_specs: list[list[pdf_ops.PageSpec]] = [[] for _ in range(output_count)]
for op in operations:
page_specs[op.get("doc", 0)].append(
pdf_ops.PageSpec(op["page"], op.get("rotate", 0)),
)
raise ValueError("Multiple output documents specified")
if any(
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
for op in operations
):
raise ValueError("Output document index is out of bounds")
with pikepdf.open(pair.source_doc.source_path) as src:
# prepare output documents
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
for op in operations:
dst = pdf_docs[op.get("doc", 0)]
page = src.pages[op["page"] - 1]
dst.pages.append(page)
if op.get("rotate"):
dst.pages[-1].rotate(op["rotate"], relative=True)
if update_document:
# Create a new version from the edited PDF rather than replacing in-place
pdf = pdf_docs[0]
pdf.remove_unreferenced_resources()
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{pair.root_doc.id}_edited.pdf"
(filepath,) = pdf_ops.build_pdfs(
pair.source_doc.source_path,
[
(
page_specs[0],
partial(_scratch_path, f"{pair.root_doc.id}_edited.pdf"),
),
],
)
pdf.save(filepath)
overrides = (
DocumentMetadataOverrides().from_document(pair.root_doc)
if include_metadata
@@ -955,6 +923,19 @@ def edit_pdf(
headers={"trigger_source": trigger_source},
)
else:
version_filepaths = pdf_ops.build_pdfs(
pair.source_doc.source_path,
[
(
specs,
partial(
_scratch_path,
f"{pair.root_doc.id}_edit_{idx}.pdf",
),
)
for idx, specs in enumerate(page_specs, start=1)
],
)
consume_tasks = []
overrides = (
DocumentMetadataOverrides().from_document(pair.root_doc)
@@ -966,15 +947,9 @@ def edit_pdf(
overrides.actor_id = user.id
if not delete_original:
overrides.skip_asn_if_exists = True
if delete_original and len(pdf_docs) == 1:
if delete_original and output_count == 1:
overrides.asn = pair.root_doc.archive_serial_number
for idx, pdf in enumerate(pdf_docs, start=1):
version_filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{pair.root_doc.id}_edit_{idx}.pdf"
)
pdf.remove_unreferenced_resources()
pdf.save(version_filepath)
for version_filepath in version_filepaths:
consume_tasks.append(
consume_file.s(
input_doc=ConsumableDocument(
@@ -1024,8 +999,6 @@ def remove_password(
"""
Remove password protection from PDF documents.
"""
import pikepdf
for doc_id in doc_ids:
doc = Document.objects.select_related("root_document").get(id=doc_id)
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
@@ -1039,76 +1012,69 @@ def remove_password(
doc.id,
pair.source_doc.source_path,
)
try:
with pikepdf.open(source_path) as pdf:
if not pdf.is_encrypted:
logger.info(
"Skipping password removal for document %s because the "
"source PDF is not encrypted",
pair.root_doc.id,
)
continue
except pikepdf.PasswordError:
# Password-protected PDFs need the supplied password below.
pass
with pikepdf.open(source_path, password=password) as pdf:
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{pair.root_doc.id}_unprotected.pdf"
if not pdf_ops.needs_decrypt(source_path):
logger.info(
"Skipping password removal for document %s because the "
"source PDF is not encrypted",
pair.root_doc.id,
)
pdf.remove_unreferenced_resources()
pdf.save(filepath)
continue
if update_document:
# Create a new version rather than modifying the root/original in place.
overrides = (
DocumentMetadataOverrides().from_document(pair.root_doc)
if include_metadata
else DocumentMetadataOverrides()
)
if user is not None:
overrides.owner_id = user.id
overrides.actor_id = user.id
consume_file.apply_async(
kwargs={
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
"overrides": overrides,
},
headers={"trigger_source": trigger_source},
)
filepath = pdf_ops.decrypt_pdf(
source_path,
partial(_scratch_path, f"{pair.root_doc.id}_unprotected.pdf"),
password,
)
if update_document:
# Create a new version rather than modifying the root/original in place.
overrides = (
DocumentMetadataOverrides().from_document(pair.root_doc)
if include_metadata
else DocumentMetadataOverrides()
)
if user is not None:
overrides.owner_id = user.id
overrides.actor_id = user.id
consume_file.apply_async(
kwargs={
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
"overrides": overrides,
},
headers={"trigger_source": trigger_source},
)
else:
consume_tasks = []
overrides = (
DocumentMetadataOverrides().from_document(pair.root_doc)
if include_metadata
else DocumentMetadataOverrides()
)
if user is not None:
overrides.owner_id = user.id
overrides.actor_id = user.id
consume_tasks.append(
consume_file.s(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
),
overrides=overrides,
).set(headers={"trigger_source": trigger_source}),
)
if delete_original:
chord(
header=consume_tasks,
body=delete.si([doc.id]),
).delay()
else:
consume_tasks = []
overrides = (
DocumentMetadataOverrides().from_document(pair.root_doc)
if include_metadata
else DocumentMetadataOverrides()
)
if user is not None:
overrides.owner_id = user.id
overrides.actor_id = user.id
consume_tasks.append(
consume_file.s(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
),
overrides=overrides,
).set(headers={"trigger_source": trigger_source}),
)
if delete_original:
chord(
header=consume_tasks,
body=delete.si([doc.id]),
).delay()
else:
group(consume_tasks).delay()
group(consume_tasks).delay()
except Exception as e:
logger.exception(
+184
View File
@@ -0,0 +1,184 @@
"""
Pure PDF page operations used by documents.bulk_edit.
This module deliberately knows nothing about Django, Celery or the documents
app: callers resolve documents, choose output paths and queue work. Every
function that writes a PDF removes unreferenced resources before saving.
pikepdf is always called as ``pikepdf.open(...)`` / ``pikepdf.new()`` (never
``from pikepdf import open``) so tests can patch those module attributes.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import NamedTuple
import pikepdf
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Mapping
from collections.abc import Sequence
from pathlib import Path
from types import TracebackType
class PageSpec(NamedTuple):
"""One page of an output PDF: a 1-indexed source page, optionally rotated."""
page: int
rotate: int = 0 # relative degrees, 0 leaves the page alone
def _require_positive(pages: Iterable[int]) -> None:
for page in pages:
if page < 1:
raise ValueError(f"Page numbers start at 1, got {page}")
def rotate_pdf(src: Path, dst: Path, degrees: int) -> None:
"""
Rotate every page relatively on the opened document, not a rebuild, so Info,
XMP and outlines are kept. ``src`` is not modified.
"""
with pikepdf.open(src) as pdf:
for page in pdf.pages:
page.rotate(degrees, relative=True)
pdf.remove_unreferenced_resources()
pdf.save(dst)
def remove_pages(src: Path, dst: Path, pages: Iterable[int]) -> None:
"""
Remove 1-indexed pages from the opened document, not a rebuild, so Info, XMP
and outlines are kept. ``src`` is not modified.
Duplicates are ignored. Pages are removed highest first so earlier removals
never shift the index of later ones.
"""
unique = sorted(set(pages))
_require_positive(unique)
with pikepdf.open(src) as pdf:
for page_num in reversed(unique):
del pdf.pages[page_num - 1]
pdf.remove_unreferenced_resources()
pdf.save(dst)
def build_pdfs(
src: Path,
outputs: Sequence[tuple[Sequence[PageSpec], Callable[[], Path]]],
) -> list[Path]:
"""
Build one new PDF per output from pages of ``src``, opening ``src`` once.
Each output is ``(page_specs, make_dst)``. ``make_dst`` is called after that
output's pages are copied and immediately before it is saved, so a bad page
number never leaves a destination behind. Document-level data (Info, XMP,
outlines) is not carried over. Returns the written paths in output order.
"""
for specs, _ in outputs:
_require_positive(spec.page for spec in specs)
written: list[Path] = []
with pikepdf.open(src) as source:
for specs, make_dst in outputs:
dst = pikepdf.new()
for spec in specs:
dst.pages.append(source.pages[spec.page - 1])
if spec.rotate:
dst.pages[-1].rotate(spec.rotate, relative=True)
dst.remove_unreferenced_resources()
path = make_dst()
dst.save(path)
dst.close()
written.append(path)
return written
def validate_page_operations(
operations: Sequence[Mapping[str, int]],
*,
single_output: bool,
) -> int:
"""
Validate ``edit_pdf`` style operations and return the output document count.
Each operation has ``page`` and optionally ``rotate`` and ``doc`` (the output
document index, default 0). The bounds rule is kept as it was: a ``doc`` index
must be below the number of operations.
"""
if not operations:
raise ValueError("Output document index is out of bounds")
max_idx = max(op.get("doc", 0) for op in operations)
if single_output and max_idx > 0:
raise ValueError("Multiple output documents specified")
if any(
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations) for op in operations
):
raise ValueError("Output document index is out of bounds")
return max_idx + 1
def needs_decrypt(src: Path) -> bool:
"""
True if ``src`` is encrypted. A PDF that needs a password to open at all
counts as encrypted.
"""
try:
with pikepdf.open(src) as pdf:
return bool(pdf.is_encrypted)
except pikepdf.PasswordError:
return True
def decrypt_pdf(src: Path, make_dst: Callable[[], Path], password: str) -> Path:
"""
Write an unencrypted copy of ``src`` and return its path.
``make_dst`` is only called once the password has been accepted, so a wrong
password never leaves a destination behind.
"""
with pikepdf.open(src, password=password) as pdf:
pdf.remove_unreferenced_resources()
dst = make_dst()
pdf.save(dst)
return dst
class PdfMerger:
"""
Accumulates the pages of several PDFs into one new PDF.
``add`` raises if a source cannot be read; deciding whether to skip it is the
caller's policy. Use as a context manager so the merged PDF is closed.
"""
def __init__(self) -> None:
self._pdf = pikepdf.new()
self._version: str = self._pdf.pdf_version
def __enter__(self) -> PdfMerger:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self._pdf.close()
def add(self, path: Path) -> None:
with pikepdf.open(str(path)) as pdf:
self._version = max(self._version, pdf.pdf_version)
self._pdf.pages.extend(pdf.pages)
def save(self, dst: Path) -> None:
self._pdf.remove_unreferenced_resources()
self._pdf.save(dst, min_version=self._version)
+12
View File
@@ -1079,6 +1079,16 @@ class DocumentSerializer(
)
def get_page_count(self, obj) -> int | None:
# Like content versions get their own page count from the newest version,
# use the prefetched versions cache to avoid an extra query
prefetched_cache = getattr(obj, "_prefetched_objects_cache", None)
prefetched_versions = (
prefetched_cache.get("versions")
if isinstance(prefetched_cache, dict)
else None
)
if obj.root_document_id is None and prefetched_versions:
return sort_versions_newest_first(prefetched_versions)[0].page_count
return obj.page_count
@extend_schema_field(DuplicateDocumentSummarySerializer(many=True))
@@ -2127,6 +2137,8 @@ class BulkEditSerializer(
raise serializers.ValidationError("pages must be a list")
if not all(isinstance(i, int) for i in parameters["pages"]):
raise serializers.ValidationError("pages must be a list of integers")
if any(i < 1 for i in parameters["pages"]):
raise serializers.ValidationError("pages must be positive integers")
def _validate_parameters_merge(self, parameters) -> None:
if "delete_originals" in parameters:
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="robots" content="noindex,nofollow">
<meta name="author" content="The Paperless-ngx Team">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="manifest" href="{% static webmanifest %}">
<link rel="manifest" href="{% static webmanifest %}" crossorigin="use-credentials">
<link rel="stylesheet" href="{% static styles_css %}">
<link rel="apple-touch-icon" href="{% static apple_touch_icon %}">
</head>
+30
View File
@@ -1843,6 +1843,36 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once()
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
def test_bulk_edit_delete_pages_rejects_pages_below_one(self, m) -> None:
"""
GIVEN:
- A legacy delete_pages bulk edit
WHEN:
- API to bulk edit is called with a page number below 1
THEN:
- API returns HTTP 400
- delete_pages is not called
"""
self.setup_mock(m, "delete_pages")
for pages in ([0], [-1], [1, 0]):
with self.subTest(pages=pages):
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "delete_pages",
"parameters": {"pages": pages},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"pages must be positive integers", response.content)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.rotate")
def test_rotate_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin")
@@ -19,6 +19,7 @@ from documents.models import Document
from documents.versioning import annotate_effective_content
from documents.views import DocumentSelectionMixin
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory
from paperless_testing.http import read_streaming_response
from paperless_testing.permissions import grant_global
@@ -821,6 +822,26 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["content"], "v1-content")
def test_page_count_resolves_to_latest_version(self) -> None:
root = DocumentFactory(page_count=2)
DocumentFactory(root_document=root, version_index=1, page_count=1)
unversioned = DocumentFactory(page_count=5)
resp = self.client.get("/api/documents/?fields=id,page_count")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(
{doc["id"]: doc["page_count"] for doc in resp.data["results"]},
{root.id: 1, unversioned.id: 5},
)
resp = self.client.get(f"/api/documents/{root.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["page_count"], 1)
resp = self.client.get(f"/api/documents/{root.id}/?version={root.id}")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["page_count"], 2)
def _make_root_with_out_of_order_versions(self) -> tuple[Document, ...]:
"""
A root whose newest version has a *lower* id than an older one, which is
+386
View File
@@ -0,0 +1,386 @@
"""
Tests for documents.pdf_ops.
These use real PDFs from the sample directories. No database, Celery or mocks.
Pages are compared by a hash of their content stream, so page identity and order
are easy to assert.
"""
import ast
import hashlib
from collections.abc import Callable
from pathlib import Path
import pikepdf
import pytest
from documents import pdf_ops
from documents.pdf_ops import PageSpec
SRC_ROOT = Path(__file__).parents[2]
SAMPLES = Path(__file__).parent / "samples"
THREE_PAGES = SAMPLES / "documents" / "originals" / "0000002.pdf"
TWELVE_PAGES = SAMPLES / "barcodes" / "split-by-asn-2.pdf"
ENCRYPTED = SAMPLES / "password-is-test.pdf"
SIGNED = SRC_ROOT / "paperless" / "tests" / "samples" / "tesseract" / "signed.pdf"
def _page_fingerprint(page: pikepdf.Page) -> str:
contents = page.obj.get("/Contents")
assert contents is not None, "sample page has no /Contents"
streams = list(contents) if isinstance(contents, pikepdf.Array) else [contents]
return hashlib.sha1(b"".join(s.read_bytes() for s in streams)).hexdigest()
def fingerprints(path: Path) -> list[str]:
with pikepdf.open(path) as pdf:
return [_page_fingerprint(page) for page in pdf.pages]
def rotations(path: Path) -> list[int]:
with pikepdf.open(path) as pdf:
return [int(page.obj.get("/Rotate", 0)) for page in pdf.pages]
def docinfo_keys(path: Path) -> set[str]:
with pikepdf.open(path) as pdf:
return set(pdf.docinfo.keys())
def constant(path: Path) -> Callable[[], Path]:
return lambda: path
@pytest.fixture
def source_fingerprints() -> list[str]:
fps = fingerprints(THREE_PAGES)
assert len(set(fps)) == 3, "sample must have three distinct pages"
return fps
class TestRotatePdf:
def test_rotation_is_relative_and_applies_to_every_page(self, tmp_path: Path):
once = tmp_path / "once.pdf"
twice = tmp_path / "twice.pdf"
pdf_ops.rotate_pdf(THREE_PAGES, once, 90)
pdf_ops.rotate_pdf(once, twice, 90)
assert rotations(once) == [90, 90, 90]
assert rotations(twice) == [180, 180, 180]
assert fingerprints(twice) == fingerprints(THREE_PAGES)
def test_keeps_document_info(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
pdf_ops.rotate_pdf(THREE_PAGES, dst, 90)
assert "/Creator" in docinfo_keys(dst)
class TestRemovePages:
def test_removes_selected_pages(
self,
tmp_path: Path,
source_fingerprints: list[str],
):
dst = tmp_path / "out.pdf"
pdf_ops.remove_pages(THREE_PAGES, dst, [2])
assert fingerprints(dst) == [source_fingerprints[0], source_fingerprints[2]]
def test_duplicate_page_numbers_remove_the_page_once(
self,
tmp_path: Path,
source_fingerprints: list[str],
):
dst = tmp_path / "out.pdf"
pdf_ops.remove_pages(THREE_PAGES, dst, [2, 2])
assert fingerprints(dst) == [source_fingerprints[0], source_fingerprints[2]]
def test_unordered_pages(self, tmp_path: Path, source_fingerprints: list[str]):
dst = tmp_path / "out.pdf"
pdf_ops.remove_pages(THREE_PAGES, dst, [3, 1])
assert fingerprints(dst) == [source_fingerprints[1]]
def test_empty_list_keeps_every_page(
self,
tmp_path: Path,
source_fingerprints: list[str],
):
dst = tmp_path / "out.pdf"
pdf_ops.remove_pages(THREE_PAGES, dst, [])
assert fingerprints(dst) == source_fingerprints
def test_removing_every_page_writes_an_empty_pdf(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
pdf_ops.remove_pages(THREE_PAGES, dst, [1, 2, 3])
assert fingerprints(dst) == []
def test_keeps_document_info(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
pdf_ops.remove_pages(THREE_PAGES, dst, [1])
assert "/Creator" in docinfo_keys(dst)
@pytest.mark.parametrize("bad_page", [0, -1])
def test_rejects_pages_below_one(self, tmp_path: Path, bad_page: int):
dst = tmp_path / "out.pdf"
with pytest.raises(ValueError, match="start at 1"):
pdf_ops.remove_pages(THREE_PAGES, dst, [1, bad_page])
assert not dst.exists()
def test_page_past_the_end_raises_and_writes_nothing(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
with pytest.raises(IndexError):
pdf_ops.remove_pages(THREE_PAGES, dst, [99])
assert not dst.exists()
class TestBuildPdfs:
def test_selects_and_orders_pages(
self,
tmp_path: Path,
source_fingerprints: list[str],
):
dst = tmp_path / "out.pdf"
written = pdf_ops.build_pdfs(
THREE_PAGES,
[([PageSpec(3), PageSpec(1)], constant(dst))],
)
assert written == [dst]
assert fingerprints(dst) == [source_fingerprints[2], source_fingerprints[0]]
def test_rotates_only_the_requested_pages(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
pdf_ops.build_pdfs(
THREE_PAGES,
[([PageSpec(1), PageSpec(2, 90), PageSpec(3, 180)], constant(dst))],
)
assert rotations(dst) == [0, 90, 180]
def test_writes_one_file_per_output_in_order(self, tmp_path: Path):
first = tmp_path / "first.pdf"
second = tmp_path / "second.pdf"
source = fingerprints(TWELVE_PAGES)
written = pdf_ops.build_pdfs(
TWELVE_PAGES,
[
([PageSpec(p) for p in (1, 2, 3)], constant(first)),
([PageSpec(p) for p in range(4, 13)], constant(second)),
],
)
assert written == [first, second]
assert fingerprints(first) == source[:3]
assert fingerprints(second) == source[3:]
def test_empty_page_list_writes_a_zero_page_file(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
pdf_ops.build_pdfs(THREE_PAGES, [([], constant(dst))])
assert fingerprints(dst) == []
def test_does_not_carry_over_document_info(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
assert "/Creator" in docinfo_keys(THREE_PAGES)
pdf_ops.build_pdfs(THREE_PAGES, [([PageSpec(1)], constant(dst))])
assert "/Creator" not in docinfo_keys(dst)
def test_destination_is_not_requested_when_a_page_is_out_of_range(
self,
tmp_path: Path,
):
requested: list[Path] = []
def make_dst() -> Path:
requested.append(tmp_path / "out.pdf")
return requested[-1]
with pytest.raises(IndexError):
pdf_ops.build_pdfs(THREE_PAGES, [([PageSpec(99)], make_dst)])
assert requested == []
@pytest.mark.parametrize("bad_page", [0, -1])
def test_rejects_pages_below_one_before_opening_anything(
self,
tmp_path: Path,
bad_page: int,
):
requested: list[Path] = []
def make_dst() -> Path:
requested.append(tmp_path / "out.pdf")
return requested[-1]
with pytest.raises(ValueError, match="start at 1"):
pdf_ops.build_pdfs(
THREE_PAGES,
[([PageSpec(1)], make_dst), ([PageSpec(bad_page)], make_dst)],
)
assert requested == []
class TestValidatePageOperations:
def test_returns_the_output_count(self):
operations = [{"page": 1}, {"page": 2}, {"page": 3}]
assert pdf_ops.validate_page_operations(operations, single_output=True) == 1
def test_gap_in_output_indices_counts_up_to_the_highest(self):
operations = [
{"page": 1, "doc": 0},
{"page": 2, "doc": 2},
{"page": 3, "doc": 0},
]
count = pdf_ops.validate_page_operations(operations, single_output=False)
assert count == 3
def test_empty_operations_are_rejected(self):
with pytest.raises(ValueError, match="index is out of bounds"):
pdf_ops.validate_page_operations([], single_output=False)
def test_multiple_outputs_rejected_when_single_output_required(self):
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}]
with pytest.raises(ValueError, match="Multiple output documents"):
pdf_ops.validate_page_operations(operations, single_output=True)
@pytest.mark.parametrize("doc", [-1, 2, 2**32])
def test_output_index_out_of_bounds(self, doc: int):
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": doc}]
with pytest.raises(ValueError, match="index is out of bounds"):
pdf_ops.validate_page_operations(operations, single_output=False)
class TestDecrypt:
def test_needs_decrypt(self):
assert pdf_ops.needs_decrypt(ENCRYPTED) is True
assert pdf_ops.needs_decrypt(THREE_PAGES) is False
def test_pdf_that_opens_without_a_password_but_is_flagged_encrypted(self):
assert pdf_ops.needs_decrypt(SIGNED) is True
def test_decrypt_writes_an_unencrypted_copy(self, tmp_path: Path):
dst = tmp_path / "out.pdf"
result = pdf_ops.decrypt_pdf(ENCRYPTED, constant(dst), "test")
assert result == dst
assert pdf_ops.needs_decrypt(dst) is False
def test_wrong_password_raises_and_never_requests_a_destination(
self,
tmp_path: Path,
):
requested: list[Path] = []
def make_dst() -> Path:
requested.append(tmp_path / "out.pdf")
return requested[-1]
with pytest.raises(pikepdf.PasswordError):
pdf_ops.decrypt_pdf(ENCRYPTED, make_dst, "wrong")
assert requested == []
class TestPdfMerger:
def test_pages_are_appended_in_the_order_added(
self,
tmp_path: Path,
source_fingerprints: list[str],
):
reordered = tmp_path / "reordered.pdf"
merged = tmp_path / "merged.pdf"
pdf_ops.build_pdfs(
THREE_PAGES,
[([PageSpec(3), PageSpec(1)], constant(reordered))],
)
with pdf_ops.PdfMerger() as merger:
merger.add(reordered)
merger.add(THREE_PAGES)
merger.save(merged)
assert fingerprints(merged) == [
source_fingerprints[2],
source_fingerprints[0],
*source_fingerprints,
]
def test_output_version_is_at_least_the_highest_source_version(
self,
tmp_path: Path,
):
merged = tmp_path / "merged.pdf"
with pikepdf.open(TWELVE_PAGES) as pdf:
source_versions = [pdf.pdf_version]
with pikepdf.open(THREE_PAGES) as pdf:
source_versions.append(pdf.pdf_version)
with pdf_ops.PdfMerger() as merger:
merger.add(TWELVE_PAGES)
merger.add(THREE_PAGES)
merger.save(merged)
with pikepdf.open(merged) as pdf:
assert pdf.pdf_version >= max(source_versions)
def test_unreadable_source_raises_so_the_caller_can_skip_it(
self,
tmp_path: Path,
):
garbage = tmp_path / "garbage.pdf"
garbage.write_bytes(b"not a pdf")
with pdf_ops.PdfMerger() as merger:
with pytest.raises(pikepdf.PdfError):
merger.add(garbage)
def test_pdf_ops_imports_only_the_standard_library_and_pikepdf():
tree = ast.parse(Path(pdf_ops.__file__).read_text())
imported: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
imported.add(node.module.split(".")[0])
coupled = imported & {
"django",
"celery",
"documents",
"paperless",
"paperless_mail",
"paperless_ai",
}
assert not coupled
+6 -2
View File
@@ -1192,6 +1192,7 @@ class DocumentViewSet(
"version_label",
"root_document_id",
"version_index",
"page_count",
),
),
"tags",
@@ -1271,13 +1272,16 @@ class DocumentViewSet(
if (
"version" not in request.query_params
or not isinstance(response.data, dict)
or "content" not in response.data
or not ({"content", "page_count"} & response.data.keys())
):
return response
root_doc = self.get_object()
content_doc = self._resolve_file_doc(root_doc, request)
response.data["content"] = content_doc.content or ""
if "content" in response.data:
response.data["content"] = content_doc.content or ""
if "page_count" in response.data:
response.data["page_count"] = content_doc.page_count
return response
def update(self, request, *args, **kwargs):
+57 -57
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-23 19:00+0000\n"
"POT-Creation-Date: 2026-10-02 23:09+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:514 documents/serialisers.py:871
#: documents/serialisers.py:2885 documents/views.py:342 documents/views.py:2725
#: documents/serialisers.py:2895 documents/views.py:342 documents/views.py:2729
#: paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
@@ -1641,39 +1641,39 @@ msgstr ""
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:2352
#: documents/serialisers.py:2362
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:2396
#: documents/serialisers.py:2406
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2403
#: documents/serialisers.py:2413
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2420 documents/serialisers.py:2430
#: documents/serialisers.py:2430 documents/serialisers.py:2440
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2425
#: documents/serialisers.py:2435
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2572
#: documents/serialisers.py:2582
msgid "Invalid variable detected."
msgstr ""
#: documents/serialisers.py:2941
#: documents/serialisers.py:2951
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2971 documents/views.py:4780
#: documents/serialisers.py:2981 documents/views.py:4784
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1941,40 +1941,40 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:335 documents/views.py:2722
#: documents/views.py:335 documents/views.py:2726
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1669
#: documents/views.py:1673
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1680
#: documents/views.py:1684
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:1692
#: documents/views.py:1696
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
#: documents/views.py:2547 documents/views.py:2863
#: documents/views.py:2551 documents/views.py:2867
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4793
#: documents/views.py:4797
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4839
#: documents/views.py:4843
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4903
#: documents/views.py:4907
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4917
#: documents/views.py:4921
msgid "The share link bundle is unavailable."
msgstr ""
@@ -2262,155 +2262,155 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings/__init__.py:559
#: paperless/settings/__init__.py:560
msgid "English (US)"
msgstr ""
#: paperless/settings/__init__.py:560
#: paperless/settings/__init__.py:561
msgid "Arabic"
msgstr ""
#: paperless/settings/__init__.py:561
#: paperless/settings/__init__.py:562
msgid "Afrikaans"
msgstr ""
#: paperless/settings/__init__.py:562
#: paperless/settings/__init__.py:563
msgid "Belarusian"
msgstr ""
#: paperless/settings/__init__.py:563
#: paperless/settings/__init__.py:564
msgid "Bulgarian"
msgstr ""
#: paperless/settings/__init__.py:564
#: paperless/settings/__init__.py:565
msgid "Catalan"
msgstr ""
#: paperless/settings/__init__.py:565
#: paperless/settings/__init__.py:566
msgid "Czech"
msgstr ""
#: paperless/settings/__init__.py:566
#: paperless/settings/__init__.py:567
msgid "Danish"
msgstr ""
#: paperless/settings/__init__.py:567
#: paperless/settings/__init__.py:568
msgid "German"
msgstr ""
#: paperless/settings/__init__.py:568
#: paperless/settings/__init__.py:569
msgid "Greek"
msgstr ""
#: paperless/settings/__init__.py:569
#: paperless/settings/__init__.py:570
msgid "English (GB)"
msgstr ""
#: paperless/settings/__init__.py:570
#: paperless/settings/__init__.py:571
msgid "Spanish"
msgstr ""
#: paperless/settings/__init__.py:571
#: paperless/settings/__init__.py:572
msgid "Persian"
msgstr ""
#: paperless/settings/__init__.py:572
#: paperless/settings/__init__.py:573
msgid "Finnish"
msgstr ""
#: paperless/settings/__init__.py:573
#: paperless/settings/__init__.py:574
msgid "French"
msgstr ""
#: paperless/settings/__init__.py:574
#: paperless/settings/__init__.py:575
msgid "Hungarian"
msgstr ""
#: paperless/settings/__init__.py:575
#: paperless/settings/__init__.py:576
msgid "Indonesian"
msgstr ""
#: paperless/settings/__init__.py:576
#: paperless/settings/__init__.py:577
msgid "Italian"
msgstr ""
#: paperless/settings/__init__.py:577
#: paperless/settings/__init__.py:578
msgid "Japanese"
msgstr ""
#: paperless/settings/__init__.py:578
#: paperless/settings/__init__.py:579
msgid "Korean"
msgstr ""
#: paperless/settings/__init__.py:579
#: paperless/settings/__init__.py:580
msgid "Luxembourgish"
msgstr ""
#: paperless/settings/__init__.py:580
#: paperless/settings/__init__.py:581
msgid "Norwegian"
msgstr ""
#: paperless/settings/__init__.py:581
#: paperless/settings/__init__.py:582
msgid "Dutch"
msgstr ""
#: paperless/settings/__init__.py:582
#: paperless/settings/__init__.py:583
msgid "Polish"
msgstr ""
#: paperless/settings/__init__.py:583
#: paperless/settings/__init__.py:584
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings/__init__.py:584
#: paperless/settings/__init__.py:585
msgid "Portuguese"
msgstr ""
#: paperless/settings/__init__.py:585
#: paperless/settings/__init__.py:586
msgid "Romanian"
msgstr ""
#: paperless/settings/__init__.py:586
#: paperless/settings/__init__.py:587
msgid "Russian"
msgstr ""
#: paperless/settings/__init__.py:587
#: paperless/settings/__init__.py:588
msgid "Slovak"
msgstr ""
#: paperless/settings/__init__.py:588
#: paperless/settings/__init__.py:589
msgid "Slovenian"
msgstr ""
#: paperless/settings/__init__.py:589
#: paperless/settings/__init__.py:590
msgid "Serbian"
msgstr ""
#: paperless/settings/__init__.py:590
#: paperless/settings/__init__.py:591
msgid "Swedish"
msgstr ""
#: paperless/settings/__init__.py:591
#: paperless/settings/__init__.py:592
msgid "Turkish"
msgstr ""
#: paperless/settings/__init__.py:592
#: paperless/settings/__init__.py:593
msgid "Ukrainian"
msgstr ""
#: paperless/settings/__init__.py:593
#: paperless/settings/__init__.py:594
msgid "Vietnamese"
msgstr ""
#: paperless/settings/__init__.py:594
#: paperless/settings/__init__.py:595
msgid "Chinese Simplified"
msgstr ""
#: paperless/settings/__init__.py:595
#: paperless/settings/__init__.py:596
msgid "Chinese Traditional"
msgstr ""
#: paperless/urls.py:435
#: paperless/urls.py:438
msgid "Paperless-ngx administration"
msgstr ""
+4 -12
View File
@@ -22,8 +22,6 @@ from pathlib import Path
from typing import TYPE_CHECKING
from typing import Self
from bleach import clean
from bleach import linkify
from django.conf import settings
from django.utils import timezone
from django.utils.timezone import is_naive
@@ -38,6 +36,9 @@ from humanize import naturalsize
from imap_tools import MailAttachment
from imap_tools import MailMessage
from tika_client import TikaClient
from turbohtml.clean import Linkify
from turbohtml.clean import linkify
from turbohtml.migration.bleach import clean
from documents.parsers import ParseError
from documents.parsers import make_thumbnail_from_pdf
@@ -58,12 +59,6 @@ _SUPPORTED_MIME_TYPES: dict[str, str] = {
"message/rfc822": ".eml",
}
# Bleach's email-address linkifier uses a superlinear regular expression. Keep
# email linkification for ordinary headers and short messages, but never run it
# over an unbounded attacker-controlled field. URL linkification remains enabled
# for longer text.
_MAX_EMAIL_LINKIFY_LENGTH = 2048
class MailDocumentParser:
"""Parse .eml email files for Paperless-ngx.
@@ -633,10 +628,7 @@ class MailDocumentParser:
text = str(text)
text = escape(text)
text = clean(text)
text = linkify(
text,
parse_email="@" in text and len(text) <= _MAX_EMAIL_LINKIFY_LENGTH,
)
text = linkify(text, Linkify(parse_email=True))
text = text.replace("\n", "<br>")
return text
+9 -8
View File
@@ -291,7 +291,7 @@ if _CHANNELS_BACKEND.startswith("channels_redis."):
###############################################################################
EMAIL_HOST: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST", "localhost")
EMAIL_PORT: Final[int] = int(os.getenv("PAPERLESS_EMAIL_PORT", 25))
EMAIL_PORT: Final[int] = get_int_from_env("PAPERLESS_EMAIL_PORT", 25)
EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "")
DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER)
@@ -378,8 +378,9 @@ ACCOUNT_SESSION_REMEMBER = get_bool_from_env(
"True",
)
SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER
SESSION_COOKIE_AGE = int(
os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3),
SESSION_COOKIE_AGE = get_int_from_env(
"PAPERLESS_SESSION_COOKIE_AGE",
60 * 60 * 24 * 7 * 3,
)
# https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-SESSION_ENGINE
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
@@ -611,8 +612,8 @@ USE_TZ = True
LOGGING_DIR.mkdir(parents=True, exist_ok=True)
LOGROTATE_MAX_SIZE = os.getenv("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024)
LOGROTATE_MAX_BACKUPS = os.getenv("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20)
LOGROTATE_MAX_SIZE = get_int_from_env("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024)
LOGROTATE_MAX_BACKUPS = get_int_from_env("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20)
LOGGING = {
"version": 1,
@@ -796,7 +797,7 @@ def default_threads_per_worker(task_workers) -> int:
return 1
THREADS_PER_WORKER = os.getenv(
THREADS_PER_WORKER = get_int_from_env(
"PAPERLESS_THREADS_PER_WORKER",
default_threads_per_worker(CELERY_WORKER_CONCURRENCY),
)
@@ -817,9 +818,9 @@ IGNORABLE_FILES: Final[list[str]] = [
"Thumbs.db",
]
CONSUMER_POLLING_INTERVAL = float(os.getenv("PAPERLESS_CONSUMER_POLLING_INTERVAL", 0))
CONSUMER_POLLING_INTERVAL = get_float_from_env("PAPERLESS_CONSUMER_POLLING_INTERVAL", 0)
CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5))
CONSUMER_STABILITY_DELAY = get_float_from_env("PAPERLESS_CONSUMER_STABILITY_DELAY", 5)
CONSUMER_DELETE_DUPLICATES = get_bool_from_env("PAPERLESS_CONSUMER_DELETE_DUPLICATES")
@@ -735,33 +735,24 @@ class TestParser:
assert expected_html == actual_html
def test_mail_to_html_bounds_email_linkification(
def test_mail_to_html_linkifies_email_in_long_text(
self,
mail_parser: MailDocumentParser,
) -> None:
mail = mock.Mock(
subject="sender@example.com",
subject="",
from_values=None,
to_values=[],
cc_values=[],
bcc_values=[],
attachments=[],
date=timezone.now(),
text=("a." * 1500) + "@example.com",
text=("a." * 1500) + " sender@example.com",
)
with mock.patch(
"paperless.parsers.mail.linkify",
side_effect=lambda text, **kwargs: text,
) as mock_linkify:
mail_parser.mail_to_html(mail)
html_file = mail_parser.mail_to_html(mail)
parse_email_by_text = {
call.args[0]: call.kwargs["parse_email"]
for call in mock_linkify.call_args_list
}
assert parse_email_by_text["sender@example.com"] is True
assert parse_email_by_text[mail.text] is False
assert 'href="mailto:sender@example.com"' in html_file.read_text()
def test_generate_pdf_from_mail(
self,
+68
View File
@@ -0,0 +1,68 @@
import time
from allauth.mfa import app_settings as mfa_settings
from allauth.mfa.totp.internal import auth as totp_auth
from django.test import TestCase
from django.urls import reverse
from paperless_testing.factories import UserFactory
class TestAdminAuth(TestCase):
def test_admin_login_redirects_to_allauth(self):
user = UserFactory(staff=True, password="testpassword")
admin_url = reverse("admin:index")
login_url = reverse("admin:login")
expected_url = f"{reverse('account_login')}?next={admin_url}"
response = self.client.get(login_url, {"next": admin_url})
self.assertRedirects(response, expected_url)
response = self.client.post(
login_url,
{"username": user.username, "password": "testpassword", "next": admin_url},
)
self.assertRedirects(response, expected_url)
self.assertNotIn("_auth_user_id", self.client.session)
def test_admin_access_requires_totp_for_enrolled_staff(self):
user = UserFactory(staff=True, password="testpassword")
secret = totp_auth.generate_totp_secret()
totp_auth.TOTP.activate(user, secret)
admin_url = reverse("admin:index")
mfa_url = reverse("mfa_authenticate")
response = self.client.post(
reverse("account_login"),
{"login": user.username, "password": "testpassword", "next": admin_url},
)
self.assertRedirects(response, mfa_url)
self.assertNotIn("_auth_user_id", self.client.session)
self.assertRedirects(
self.client.get(admin_url),
f"{reverse('admin:login')}?next={admin_url}",
fetch_redirect_response=False,
)
response = self.client.post(mfa_url, {"code": "invalid"})
self.assertEqual(response.status_code, 200)
self.assertNotIn("_auth_user_id", self.client.session)
code = totp_auth.format_hotp_value(
totp_auth.hotp_value(secret, int(time.time()) // mfa_settings.TOTP_PERIOD),
)
response = self.client.post(mfa_url, {"code": code})
self.assertRedirects(response, admin_url)
self.assertEqual(self.client.session["_auth_user_id"], str(user.pk))
def test_staff_without_totp_can_still_log_in(self):
user = UserFactory(staff=True, password="testpassword")
admin_url = reverse("admin:index")
response = self.client.post(
reverse("account_login"),
{"login": user.username, "password": "testpassword", "next": admin_url},
)
self.assertRedirects(response, admin_url)
self.assertEqual(self.client.session["_auth_user_id"], str(user.pk))
+4 -1
View File
@@ -1,4 +1,5 @@
from allauth.account import views as allauth_account_views
from allauth.account.decorators import secure_admin_login
from allauth.mfa.base import views as allauth_mfa_views
from allauth.socialaccount import views as allauth_social_account_views
from allauth.urls import build_provider_urlpatterns
@@ -68,6 +69,8 @@ from paperless_mail.views import MailRuleViewSet
from paperless_mail.views import OauthCallbackView
from paperless_mail.views import ProcessedMailViewSet
admin.site.login = secure_admin_login(admin.site.login)
api_router = DefaultRouter()
api_router.register(r"correspondents", CorrespondentViewSet)
api_router.register(r"document_types", DocumentTypeViewSet)
@@ -297,7 +300,7 @@ urlpatterns = [
),
re_path(r"^share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"),
re_path(r"admin/", admin.site.urls),
re_path(r"^admin/", admin.site.urls),
re_path(
r"^fetch/",
include(
+4 -4
View File
@@ -2,14 +2,14 @@ from __future__ import annotations
import factory
from django.utils import timezone
from factory.django import DjangoModelFactory
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail
from paperless_testing.typed_factory import TypedModelFactory
class MailAccountFactory(DjangoModelFactory[MailAccount]):
class MailAccountFactory(TypedModelFactory[MailAccount]):
class Meta:
model = MailAccount
@@ -24,7 +24,7 @@ class MailAccountFactory(DjangoModelFactory[MailAccount]):
is_token = False
class MailRuleFactory(DjangoModelFactory[MailRule]):
class MailRuleFactory(TypedModelFactory[MailRule]):
class Meta:
model = MailRule
@@ -44,7 +44,7 @@ class MailRuleFactory(DjangoModelFactory[MailRule]):
stop_processing = False
class ProcessedMailFactory(DjangoModelFactory[ProcessedMail]):
class ProcessedMailFactory(TypedModelFactory[ProcessedMail]):
class Meta:
model = ProcessedMail
+10 -12
View File
@@ -5,8 +5,7 @@ Factory-boy factories for documents app models.
from __future__ import annotations
import factory
from django.contrib.auth import get_user_model
from factory.django import DjangoModelFactory
from django.contrib.auth.models import User
from documents.models import Correspondent
from documents.models import Document
@@ -15,11 +14,10 @@ from documents.models import MatchingModel
from documents.models import PaperlessTask
from documents.models import StoragePath
from documents.models import Tag
UserModelT = get_user_model()
from paperless_testing.typed_factory import TypedModelFactory
class CorrespondentFactory(DjangoModelFactory[Correspondent]):
class CorrespondentFactory(TypedModelFactory[Correspondent]):
class Meta:
model = Correspondent
@@ -28,7 +26,7 @@ class CorrespondentFactory(DjangoModelFactory[Correspondent]):
matching_algorithm = MatchingModel.MATCH_NONE
class DocumentTypeFactory(DjangoModelFactory[DocumentType]):
class DocumentTypeFactory(TypedModelFactory[DocumentType]):
class Meta:
model = DocumentType
@@ -37,7 +35,7 @@ class DocumentTypeFactory(DjangoModelFactory[DocumentType]):
matching_algorithm = MatchingModel.MATCH_NONE
class TagFactory(DjangoModelFactory[Tag]):
class TagFactory(TypedModelFactory[Tag]):
class Meta:
model = Tag
@@ -47,7 +45,7 @@ class TagFactory(DjangoModelFactory[Tag]):
is_inbox_tag = False
class StoragePathFactory(DjangoModelFactory[StoragePath]):
class StoragePathFactory(TypedModelFactory[StoragePath]):
class Meta:
model = StoragePath
@@ -59,7 +57,7 @@ class StoragePathFactory(DjangoModelFactory[StoragePath]):
matching_algorithm = MatchingModel.MATCH_NONE
class DocumentFactory(DjangoModelFactory[Document]):
class DocumentFactory(TypedModelFactory[Document]):
class Meta:
model = Document
@@ -71,9 +69,9 @@ class DocumentFactory(DjangoModelFactory[Document]):
storage_path = None
class UserFactory(DjangoModelFactory[UserModelT]):
class UserFactory(TypedModelFactory[User]):
class Meta:
model = UserModelT
model = User
username = factory.Sequence(lambda n: f"user{n}")
is_staff = False
@@ -88,7 +86,7 @@ class UserFactory(DjangoModelFactory[UserModelT]):
staff = factory.Trait(is_staff=True)
class PaperlessTaskFactory(DjangoModelFactory[PaperlessTask]):
class PaperlessTaskFactory(TypedModelFactory[PaperlessTask]):
class Meta:
model = PaperlessTask
+20
View File
@@ -0,0 +1,20 @@
"""
A DjangoModelFactory base whose calls are typed as the model they build.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import Any
from typing import TypeVar
from factory.django import DjangoModelFactory
T = TypeVar("T")
class TypedModelFactory(DjangoModelFactory[T]):
if TYPE_CHECKING:
# factory-boy leaves Factory() unannotated, so mypy takes it to build a
# factory instance. At runtime it builds the model.
def __new__(cls, *args: Any, **kwargs: Any) -> T: ... # type: ignore[misc]
Generated
+377 -342
View File
File diff suppressed because it is too large. Load diff