Compare commits

...
Author SHA1 Message Date
shamoon 40c14c1545 Chore: switch to whoosh-reloaded 2025-02-06 20:26:05 -08:00
shamoonandGitHub 52ab07c673 Fix: deselect and trigger refresh for deleted documents from bulk operations with "delete originals" (#8996) 2025-02-07 00:47:50 +00:00
XstreamGitandGitHub 046d8456e2 Tweak: improve date matching regex for dates after numbers (#8964) 2025-02-06 19:09:35 +00:00
shamoonandGitHub 3314c59828 Tweak: more accurate classifier last trained time (#9004) 2025-02-06 10:54:31 -08:00
shamoonandGitHub 2103a499eb Enhancement: allow setting default pdf zoom (#9017) 2025-02-03 21:54:18 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>shamoon
7ab779e78a Chore(deps-dev): Bump the development group with 2 updates (#9013)
* Chore(deps-dev): Bump the development group with 2 updates

Bumps the development group with 2 updates: [ruff](https://github.com/astral-sh/ruff) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material).


Updates `ruff` from 0.9.3 to 0.9.4
- [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.9.3...0.9.4)

Updates `mkdocs-material` from 9.5.50 to 9.6.2
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.50...9.6.2)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

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

* Update .pre-commit-config.yaml

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-02-04 03:21:55 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
49390c9427 Chore(deps): Bump django-soft-delete in the django group (#9014)
Bumps the django group with 1 update: [django-soft-delete](https://github.com/san4ezy/django_softdelete).


Updates `django-soft-delete` from 1.0.16 to 1.0.18
- [Changelog](https://github.com/san4ezy/django_softdelete/blob/master/CHANGELOG.md)
- [Commits](https://github.com/san4ezy/django_softdelete/commits)

---
updated-dependencies:
- dependency-name: django-soft-delete
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: django
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 18:00:47 -08:00
shamoonandGitHub 6f29d64325 Fix: allow empty email in profile (#9012) 2025-02-03 11:59:45 -08:00
shamoon 065724befb Tweak: more verbose toast messages 2025-02-01 21:57:57 -08:00
shamoon e877beea4e Fix: use button toolbar for management lists for better wrapping 2025-02-01 20:01:49 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3e848e6e0f Chore(deps): Bump uuid from 11.0.2 to 11.0.5 in /src-ui (#8992)
Bumps [uuid](https://github.com/uuidjs/uuid) from 11.0.2 to 11.0.5.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v11.0.2...v11.0.5)

---
updated-dependencies:
- dependency-name: uuid
  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>
2025-02-02 01:55:54 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2e5656e1ce Chore(deps-dev): Bump @codecov/webpack-plugin in /src-ui (#8991)
Bumps @codecov/webpack-plugin from 1.2.1 to 1.8.0.

---
updated-dependencies:
- dependency-name: "@codecov/webpack-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 01:46:24 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
438650bf17 Chore(deps-dev): Bump @playwright/test from 1.48.2 to 1.50.1 in /src-ui (#8993)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.48.2 to 1.50.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.48.2...v1.50.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 01:35:21 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7df0b621a5 Chore(deps-dev): Bump @types/node from 22.8.6 to 22.13.0 in /src-ui (#8989)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.8.6 to 22.13.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 01:21:35 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4cd755f641 Chore(deps-dev): Bump the frontend-eslint-dependencies group (#8988)
Bumps the frontend-eslint-dependencies group in /src-ui with 4 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) and [eslint](https://github.com/eslint/eslint).


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

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

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

Updates `eslint` from 9.14.0 to 9.19.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.14.0...v9.19.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 01:07:16 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8597911d85 Chore(deps): Bump the frontend-angular-dependencies group (#8986)
Bumps the frontend-angular-dependencies group in /src-ui with 23 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `19.0.2` | `19.1.2` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `19.0.3` | `19.1.4` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `19.0.3` | `19.1.4` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `19.0.3` | `19.1.4` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `19.0.3` | `19.1.4` |
| [@angular/localize](https://github.com/angular/angular) | `19.0.3` | `19.1.4` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `19.0.3` | `19.1.4` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `19.0.3` | `19.1.4` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `19.0.3` | `19.1.4` |
| [@ng-select/ng-select](https://github.com/ng-select/ng-select) | `14.1.0` | `14.2.0` |
| [ngx-cookie-service](https://github.com/stevermeister/ngx-cookie-service) | `19.0.0` | `19.1.0` |
| [@angular-builders/custom-webpack](https://github.com/just-jeb/angular-builders/tree/HEAD/packages/custom-webpack) | `19.0.0-beta.0` | `19.0.0` |
| [@angular-builders/jest](https://github.com/just-jeb/angular-builders/tree/HEAD/packages/jest) | `19.0.0-beta.1` | `19.0.0` |
| [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `19.0.4` | `19.1.5` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `19.0.4` | `19.1.5` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `19.0.4` | `19.1.5` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `19.0.0` | `19.0.2` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `19.0.0` | `19.0.2` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `19.0.0` | `19.0.2` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `19.0.0` | `19.0.2` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `19.0.0` | `19.0.2` |
| [@angular/cli](https://github.com/angular/angular-cli) | `19.0.4` | `19.1.5` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `19.0.3` | `19.1.4` |


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

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

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

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

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

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

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

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

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

Updates `@ng-select/ng-select` from 14.1.0 to 14.2.0
- [Release notes](https://github.com/ng-select/ng-select/releases)
- [Changelog](https://github.com/ng-select/ng-select/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ng-select/ng-select/compare/v14.1.0...v14.2.0)

Updates `ngx-cookie-service` from 19.0.0 to 19.1.0
- [Release notes](https://github.com/stevermeister/ngx-cookie-service/releases)
- [Changelog](https://github.com/stevermeister/ngx-cookie-service/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stevermeister/ngx-cookie-service/compare/v19.0.0...19.1.0)

Updates `@angular-builders/custom-webpack` from 19.0.0-beta.0 to 19.0.0
- [Release notes](https://github.com/just-jeb/angular-builders/releases)
- [Changelog](https://github.com/just-jeb/angular-builders/blob/master/packages/custom-webpack/CHANGELOG.md)
- [Commits](https://github.com/just-jeb/angular-builders/commits/@angular-builders/custom-webpack@19.0.0/packages/custom-webpack)

Updates `@angular-builders/jest` from 19.0.0-beta.1 to 19.0.0
- [Release notes](https://github.com/just-jeb/angular-builders/releases)
- [Changelog](https://github.com/just-jeb/angular-builders/blob/master/packages/jest/CHANGELOG.md)
- [Commits](https://github.com/just-jeb/angular-builders/commits/@angular-builders/jest@19.0.0/packages/jest)

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

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

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

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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser-dynamic"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@ng-select/ng-select"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: ngx-cookie-service
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-builders/custom-webpack"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-builders/jest"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/build-angular"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/core"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/schematics"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/builder"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin-template"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/schematics"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/template-parser"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-01 16:51:46 -08:00
shamoon befb80bddf Fix: Dont include frame component in component router service 2025-02-01 00:18:11 -08:00
shamoon 7af6983cab Merge branch 'main' into dev 2025-01-31 08:36:56 -08:00
shamoonandGitHub 54e72d5b60 Fix: also ensure symmetric doc link removal on bulk edit (#8963) 2025-01-31 07:39:22 -08:00
shamoon cf7422346a Fix: resolve error in trackBy 2025-01-31 00:35:12 -08:00
shamoonandGitHub 5b8c9ef5fc Fix: reflect doc links in bulk modify custom fields (#8962) 2025-01-30 10:55:05 -08:00
shamoonandGitHub f56974f158 Feature: use 'share sheet' for download buttons on mobile (#8949) 2025-01-30 16:31:52 +00:00
Trenton HandGitHub 427508edf1 Feature: Enable zxing for all platforms (#8947) 2025-01-30 15:43:23 +00:00
Trenton HandGitHub 311b259cff Chore: Updates the curl command to download in parallel (#8945) 2025-01-29 21:00:49 +00:00
Sebastian SteinbeißerandGitHub fce7b03324 Chore: Switch from os.path to pathlib.Path (#8644) 2025-01-29 10:58:53 -08:00
shamoonandGitHub 79956d6a7b Enhancement: require totp code for obtain auth token (#8936) 2025-01-29 15:23:44 +00:00
shamoonandGitHub 978b072bff Enhancement: improve doc details close behavior (#8937) 2025-01-28 22:26:30 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>shamoon
9c6f695dbf Chore(deps-dev): Bump ruff from 0.9.2 to 0.9.3 in the development group (#8928)
* Chore(deps-dev): Bump ruff from 0.9.2 to 0.9.3 in the development group

Bumps the development group with 1 update: [ruff](https://github.com/astral-sh/ruff).


Updates `ruff` from 0.9.2 to 0.9.3
- [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.9.2...0.9.3)

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

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

* Update .pre-commit-config.yaml

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-01-28 15:25:21 +00:00
59 changed files with 3868 additions and 3345 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ repos:
- 'prettier-plugin-organize-imports@4.1.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.3
rev: v0.9.4
hooks:
- id: ruff
- id: ruff-format
-3
View File
@@ -38,7 +38,6 @@ ignore = ["DJ001", "SIM105", "RUF012"]
[lint.per-file-ignores]
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
"docker/wait-for-redis.py" = ["INP001", "T201"]
"src/documents/consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/file_handling.py" = ["PTH"] # TODO Enable & remove
"src/documents/management/commands/document_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/management/commands/document_exporter.py" = ["PTH"] # TODO Enable & remove
@@ -51,8 +50,6 @@ ignore = ["DJ001", "SIM105", "RUF012"]
"src/documents/signals/handlers.py" = ["PTH"] # TODO Enable & remove
"src/documents/tasks.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_api_app_config.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_api_bulk_download.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_api_documents.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_classifier.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_file_handling.py" = ["PTH"] # TODO Enable & remove
+12 -24
View File
@@ -127,32 +127,21 @@ RUN set -eux \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \
&& echo "Installing pre-built updates" \
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing qpdf ${QPDF_VERSION}" \
&& curl --fail --silent --show-error --location \
--output libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing Ghostscript ${GS_VERSION}" \
&& curl --fail --silent --show-error --location \
--output libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \
&& curl --fail --silent --show-error --location \
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \
@@ -232,12 +221,11 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing Python requirements" \
&& curl --fail --silent --show-error --location \
--output psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl \
&& curl --fail --silent --show-error --location \
--output psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl \
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl \
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
&& echo "Installing NLTK data" \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
+2 -2
View File
@@ -57,8 +57,8 @@ tqdm = "*"
uvicorn = {extras = ["standard"], version = "==0.25.0"}
watchdog = "~=6.0"
whitenoise = "~=6.8"
whoosh = "~=2.7"
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
whoosh-reloaded = "*"
zxing-cpp = "*"
[dev-packages]
Generated
+50 -47
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "3806c1dbfde8e9383e748c106c217170d6dcdbb8b95d573030b2294dab32d462"
"sha256": "f1567fb098714a4bb2999bcf04c7d8b3ad6fb04dced2f971f95c21fe8792a878"
},
"pipfile-spec": 6,
"requires": {},
@@ -193,6 +193,14 @@
],
"version": "==1.1.0"
},
"cached-property": {
"hashes": [
"sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641",
"sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb"
],
"markers": "python_version >= '3.8'",
"version": "==2.0.1"
},
"celery": {
"extras": [
"redis"
@@ -589,12 +597,12 @@
},
"django-soft-delete": {
"hashes": [
"sha256:cc40398ccd869c75a6d6ba7f526e16c4afe2b0c0811c213a318d96bb4c58a787",
"sha256:fdaf2788d404930557f1300ce40bbd764f6938775a35a3175c66fe7778666093"
"sha256:603a29e82bbb7a5bada69f2754fad225ccd8cd7f485320ec06d0fc4e9dfddcf0",
"sha256:d2f9db449a4f008e9786f82fa4bafbe4075f7a0b3284844735007e988b2a4df6"
],
"index": "pypi",
"markers": "python_version >= '3.6'",
"version": "==1.0.16"
"version": "==1.0.18"
},
"djangorestframework": {
"hashes": [
@@ -2264,7 +2272,7 @@
"sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
"sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
],
"markers": "python_version >= '3.8'",
"markers": "python_version < '3.11'",
"version": "==4.12.2"
},
"tzdata": {
@@ -2565,14 +2573,13 @@
"markers": "python_version >= '3.9'",
"version": "==6.8.2"
},
"whoosh": {
"whoosh-reloaded": {
"hashes": [
"sha256:7ca5633dbfa9e0e0fa400d3151a8a0c4bec53bd2ecedc0a67705b17565c31a83",
"sha256:aa39c3c3426e3fd107dcb4bde64ca1e276a65a889d9085a6e4b54ba82420a852",
"sha256:e0857375f63e9041e03fedd5b7541f97cf78917ac1b6b06c1fcc9b45375dda69"
"sha256:2ab6aeeafb359fbff4beb3c704b960fd88240354f3363f1c5bdb5c2325cae80e",
"sha256:39ed7dfbd1fec97af33933107bdf78110728375ed0f2abb25dec6dbfdcb279d8"
],
"index": "pypi",
"version": "==2.7.4"
"version": "==2.7.5"
},
"wrapt": {
"hashes": [
@@ -2791,7 +2798,6 @@
"sha256:fbd5b253ad0f8823c5c104feaaa19acab95c217cb924b012d55ff339c42b3583",
"sha256:fd3f175f7b57cfbdea56afdb5335eaebaadeebc06e20a087d9aa3f99637c4aa5"
],
"markers": "platform_machine == 'x86_64'",
"version": "==2.3.0"
}
},
@@ -2838,19 +2844,19 @@
},
"babel": {
"hashes": [
"sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b",
"sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"
"sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d",
"sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"
],
"markers": "python_version >= '3.8'",
"version": "==2.16.0"
"version": "==2.17.0"
},
"certifi": {
"hashes": [
"sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56",
"sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"
"sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651",
"sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"
],
"markers": "python_version >= '3.6'",
"version": "==2024.12.14"
"version": "==2025.1.31"
},
"cffi": {
"hashes": [
@@ -3303,7 +3309,6 @@
"sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb",
"sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==3.1.5"
},
@@ -3416,12 +3421,12 @@
},
"mkdocs-material": {
"hashes": [
"sha256:ae5fe16f3d7c9ccd05bb6916a7da7420cf99a9ce5e33debd9d40403a090d5825",
"sha256:f24100f234741f4d423a9d672a909d859668a4f404796be3cf035f10d6050385"
"sha256:71d90dbd63b393ad11a4d90151dfe3dcbfcd802c0f29ce80bebd9bbac6abc753",
"sha256:a3de1c5d4c745f10afa78b1a02f917b9dce0808fb206adc0f5bb48b58c1ca21f"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==9.5.50"
"version": "==9.6.2"
},
"mkdocs-material-extensions": {
"hashes": [
@@ -3659,11 +3664,11 @@
},
"pymdown-extensions": {
"hashes": [
"sha256:637951cbfbe9874ba28134fb3ce4b8bcadd6aca89ac4998ec29dcbafd554ae08",
"sha256:b65801996a0cd4f42a3110810c306c45b7313c09b0610a6f773730f2a9e3c96b"
"sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9",
"sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b"
],
"markers": "python_version >= '3.8'",
"version": "==10.14.1"
"version": "==10.14.3"
},
"pyopenssl": {
"hashes": [
@@ -3758,8 +3763,7 @@
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
],
"index": "pypi",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.9.0.post0"
},
"pywavelets": {
@@ -3983,28 +3987,28 @@
},
"ruff": {
"hashes": [
"sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2",
"sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4",
"sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439",
"sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730",
"sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4",
"sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5",
"sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624",
"sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b",
"sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a",
"sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b",
"sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5",
"sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4",
"sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c",
"sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519",
"sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1",
"sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4",
"sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6",
"sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c"
"sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e",
"sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214",
"sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137",
"sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c",
"sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b",
"sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b",
"sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41",
"sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706",
"sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7",
"sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf",
"sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec",
"sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6",
"sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231",
"sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0",
"sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402",
"sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e",
"sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a",
"sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==0.9.3"
"version": "==0.9.4"
},
"scipy": {
"hashes": [
@@ -4073,7 +4077,7 @@
"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274",
"sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.17.0"
},
"sniffio": {
@@ -4206,7 +4210,6 @@
"sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c",
"sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==6.0.0"
},
-2
View File
@@ -1073,8 +1073,6 @@ or hidden folders some tools use to store data.
If you have problems that your Barcodes/QR-Codes are not detected
(especially with bad scan quality and/or small codes), try the other one.
zxing is not available on all platforms.
#### [`PAPERLESS_PRE_CONSUME_SCRIPT=<filename>`](#PAPERLESS_PRE_CONSUME_SCRIPT) {#PAPERLESS_PRE_CONSUME_SCRIPT}
: After some initial validation, Paperless can trigger an arbitrary
+606 -565
View File
File diff suppressed because it is too large Load Diff
+1836 -1897
View File
File diff suppressed because it is too large Load Diff
+30 -29
View File
@@ -11,17 +11,17 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^19.0.2",
"@angular/common": "~19.0.3",
"@angular/compiler": "~19.0.3",
"@angular/core": "~19.0.3",
"@angular/forms": "~19.0.3",
"@angular/localize": "~19.0.3",
"@angular/platform-browser": "~19.0.3",
"@angular/platform-browser-dynamic": "~19.0.3",
"@angular/router": "~19.0.3",
"@angular/cdk": "^19.1.2",
"@angular/common": "~19.1.4",
"@angular/compiler": "~19.1.4",
"@angular/core": "~19.1.4",
"@angular/forms": "~19.1.4",
"@angular/localize": "~19.1.4",
"@angular/platform-browser": "~19.1.4",
"@angular/platform-browser-dynamic": "~19.1.4",
"@angular/router": "~19.1.4",
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ng-select/ng-select": "^14.1.0",
"@ng-select/ng-select": "^14.2.0",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
@@ -30,36 +30,37 @@
"ng2-pdf-viewer": "^10.4.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^19.0.0",
"ngx-cookie-service": "^19.1.0",
"ngx-device-detector": "^9.0.0",
"ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^16.0.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^11.0.2",
"uuid": "^11.0.5",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^19.0.0-beta.0",
"@angular-builders/jest": "^19.0.0-beta.1",
"@angular-builders/custom-webpack": "^19.0.0",
"@angular-builders/jest": "^19.0.0",
"@angular-devkit/build-angular": "^19.0.4",
"@angular-devkit/core": "^19.0.4",
"@angular-devkit/schematics": "^19.0.4",
"@angular-eslint/builder": "19.0.0",
"@angular-eslint/eslint-plugin": "19.0.0",
"@angular-eslint/eslint-plugin-template": "19.0.0",
"@angular-eslint/schematics": "19.0.0",
"@angular-eslint/template-parser": "19.0.0",
"@angular/cli": "~19.0.4",
"@angular/compiler-cli": "~19.0.3",
"@codecov/webpack-plugin": "^1.2.1",
"@playwright/test": "^1.48.2",
"@angular-devkit/core": "^19.1.5",
"@angular-devkit/schematics": "^19.1.5",
"@angular-eslint/builder": "19.0.2",
"@angular-eslint/eslint-plugin": "19.0.2",
"@angular-eslint/eslint-plugin-template": "19.0.2",
"@angular-eslint/schematics": "19.0.2",
"@angular-eslint/template-parser": "19.0.2",
"@angular/cli": "~19.1.5",
"@angular/compiler-cli": "~19.1.4",
"@codecov/webpack-plugin": "^1.8.0",
"@playwright/test": "^1.50.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.8.6",
"@typescript-eslint/eslint-plugin": "^8.12.2",
"@typescript-eslint/parser": "^8.12.2",
"@types/node": "^22.13.0",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.14.0",
"eslint": "^9.19.0",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^14.4.2",
+9
View File
@@ -100,6 +100,15 @@ Object.defineProperty(navigator, 'clipboard', {
},
})
Object.defineProperty(navigator, 'canShare', { value: () => true })
if (!navigator.share) {
Object.defineProperty(navigator, 'share', { value: jest.fn() })
}
if (!URL.createObjectURL) {
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
}
if (!URL.revokeObjectURL) {
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
}
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
Object.defineProperty(window, 'location', {
configurable: true,
+11 -11
View File
@@ -18,20 +18,20 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
import { PermissionsGuard } from './guards/permissions.guard'
import {
ConsumerStatusService,
FileStatus,
} from './services/consumer-status.service'
import { HotKeyService } from './services/hot-key.service'
import { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { Toast, ToastService } from './services/toast.service'
import {
FileStatus,
WebsocketStatusService,
} from './services/websocket-status.service'
describe('AppComponent', () => {
let component: AppComponent
let fixture: ComponentFixture<AppComponent>
let tourService: TourService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let permissionsService: PermissionsService
let toastService: ToastService
let router: Router
@@ -59,7 +59,7 @@ describe('AppComponent', () => {
}).compileComponents()
tourService = TestBed.inject(TourService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
@@ -90,7 +90,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
const status = new FileStatus()
@@ -109,7 +109,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
@@ -122,7 +122,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentDetected')
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
@@ -136,7 +136,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentDetected')
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
@@ -148,7 +148,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFailed')
.spyOn(websocketStatusService, 'onDocumentConsumptionFailed')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
+10 -8
View File
@@ -5,7 +5,7 @@ import { first, Subscription } from 'rxjs'
import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { SETTINGS_KEYS } from './data/ui-settings'
import { ConsumerStatusService } from './services/consumer-status.service'
import { ComponentRouterService } from './services/component-router.service'
import { HotKeyService } from './services/hot-key.service'
import {
PermissionAction,
@@ -15,6 +15,7 @@ import {
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service'
@Component({
selector: 'pngx-root',
@@ -34,14 +35,15 @@ export class AppComponent implements OnInit, OnDestroy {
constructor(
private settings: SettingsService,
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
private toastService: ToastService,
private router: Router,
private tasksService: TasksService,
public tourService: TourService,
private renderer: Renderer2,
private permissionsService: PermissionsService,
private hotKeyService: HotKeyService
private hotKeyService: HotKeyService,
private componentRouterService: ComponentRouterService
) {
let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.mjs'
@@ -49,7 +51,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.consumerStatusService.disconnect()
this.websocketStatusService.disconnect()
if (this.successSubscription) {
this.successSubscription.unsubscribe()
}
@@ -74,9 +76,9 @@ export class AppComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.consumerStatusService.connect()
this.websocketStatusService.connect()
this.successSubscription = this.consumerStatusService
this.successSubscription = this.websocketStatusService
.onDocumentConsumptionFinished()
.subscribe((status) => {
this.tasksService.reload()
@@ -106,7 +108,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
})
this.failedSubscription = this.consumerStatusService
this.failedSubscription = this.websocketStatusService
.onDocumentConsumptionFailed()
.subscribe((status) => {
this.tasksService.reload()
@@ -119,7 +121,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
})
this.newDocumentSubscription = this.consumerStatusService
this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected()
.subscribe((status) => {
this.tasksService.reload()
@@ -41,7 +41,7 @@
<ng-template ngbNavContent>
<div class="row">
<div class="col-xl-6 pe-xl-5">
<h4 i18n>Appearance</h4>
<h5 i18n>Appearance</h5>
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Display language</span>
@@ -154,28 +154,7 @@
</div>
</div>
<h4 class="mt-4" i18n>Document editing</h4>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
</div>
</div>
</div>
<div class="col-xl-6 ps-xl-5">
<h4 class="mt-4 mt-md-0" id="update-checking" i18n>Update checking</h4>
<h5 class="mt-3" id="update-checking" i18n>Update checking</h5>
<div class="row mb-3">
<div class="col d-flex flex-row align-items-start">
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
@@ -193,7 +172,56 @@
</div>
</div>
<h4 class="mt-4" i18n>Bulk editing</h4>
<h5 class="mt-3" i18n>Saved Views</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
</div>
</div>
</div>
<div class="col-xl-6 ps-xl-5">
<h5 class="mt-3 mt-md-0" i18n>Document editing</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<span i18n>Default zoom:</span>
</div>
<div class="col">
<select class="form-select" formControlName="pdfViewerDefaultZoom">
<option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option>
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
</select>
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
</div>
</div>
<h5 class="mt-3" i18n>Notes</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
</div>
</div>
<h5 class="mt-3" i18n>Bulk editing</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs"></pngx-input-check>
@@ -201,7 +229,7 @@
</div>
</div>
<h4 class="mt-4" i18n>Global search</h4>
<h5 class="mt-3" i18n>Global search</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
@@ -224,19 +252,6 @@
</div>
</div>
<h4 class="mt-4" i18n>Saved Views</h4>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
</div>
</div>
<h4 class="mt-4" i18n>Notes</h4>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
</div>
</div>
</div>
</div>
@@ -247,7 +262,7 @@
<a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent>
<h4 i18n>Default Permissions</h4>
<h5 i18n>Default Permissions</h5>
<div class="row mb-3">
<div class="col">
@@ -329,7 +344,7 @@
<a ngbNavLink i18n>Notifications</a>
<ng-template ngbNavContent>
<h4 i18n>Document processing</h4>
<h5 i18n>Document processing</h5>
<div class="row mb-3">
<div class="col">
@@ -212,7 +212,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(28)
expect(setSpy).toHaveBeenCalledTimes(29)
// succeed
storeSpy.mockReturnValueOnce(of(true))
@@ -63,6 +63,7 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
import { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { ZoomSetting } from '../../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs {
@@ -125,6 +126,7 @@ export class SettingsComponent
defaultPermsEditUsers: new FormControl(null),
defaultPermsEditGroups: new FormControl(null),
useNativePdfViewer: new FormControl(null),
pdfViewerDefaultZoom: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null),
searchDbOnly: new FormControl(null),
@@ -154,6 +156,8 @@ export class SettingsComponent
public readonly GlobalSearchType = GlobalSearchType
public readonly ZoomSetting = ZoomSetting
get systemStatusHasErrors(): boolean {
return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
@@ -276,6 +280,9 @@ export class SettingsComponent
useNativePdfViewer: this.settings.get(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
),
pdfViewerDefaultZoom: this.settings.get(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
),
displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
@@ -435,6 +442,10 @@ export class SettingsComponent
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,
this.settingsForm.value.useNativePdfViewer
)
this.settings.set(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
this.settingsForm.value.pdfViewerDefaultZoom
)
this.settings.set(
SETTINGS_KEYS.DATE_LOCALE,
this.settingsForm.value.dateLocale
@@ -86,12 +86,17 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled = false
this.trashService.emptyTrash([document.id]).subscribe({
next: () => {
this.toastService.showInfo($localize`Document deleted`)
this.toastService.showInfo(
$localize`Document "${document.title}" deleted`
)
modal.close()
this.reload()
},
error: (err) => {
this.toastService.showError($localize`Error deleting document`, err)
this.toastService.showError(
$localize`Error deleting document "${document.title}"`,
err
)
modal.close()
},
})
@@ -136,7 +141,7 @@ export class TrashComponent
this.trashService.restoreDocuments([document.id]).subscribe({
next: () => {
this.toastService.show({
content: $localize`Document restored`,
content: $localize`Document "${document.title}" restored`,
delay: 5000,
actionName: $localize`Open document`,
action: () => {
@@ -146,7 +151,10 @@ export class TrashComponent
this.reload()
},
error: (err) => {
this.toastService.showError($localize`Error restoring document`, err)
this.toastService.showError(
$localize`Error restoring document "${document.title}"`,
err
)
},
})
}
@@ -134,7 +134,7 @@ describe('UsersAndGroupsComponent', () => {
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
})
it('should logout current user if password changed, after delay', fakeAsync(() => {
@@ -178,7 +178,7 @@ describe('UsersAndGroupsComponent', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteGroup(users[0])
component.deleteGroup(groups[0])
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -192,7 +192,7 @@ describe('UsersAndGroupsComponent', () => {
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group "group1"')
})
it('should get group name', () => {
@@ -129,13 +129,16 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted user`)
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.usersService.listAll().subscribe((r) => {
this.users = r.results
})
},
error: (e) => {
this.toastService.showError($localize`Error deleting user.`, e)
this.toastService.showError(
$localize`Error deleting user "${user.username}".`,
e
)
},
})
})
@@ -179,13 +182,16 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted group`)
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
})
},
error: (e) => {
this.toastService.showError($localize`Error deleting group.`, e)
this.toastService.showError(
$localize`Error deleting group "${group.name}".`,
e
)
},
})
})
@@ -33,14 +33,14 @@ 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'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
FileStatus,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { SavedViewWidgetComponent } from './saved-view-widget.component'
@@ -112,7 +112,7 @@ describe('SavedViewWidgetComponent', () => {
let component: SavedViewWidgetComponent
let fixture: ComponentFixture<SavedViewWidgetComponent>
let documentService: DocumentService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let documentListViewService: DocumentListViewService
let router: Router
@@ -176,7 +176,7 @@ describe('SavedViewWidgetComponent', () => {
}).compileComponents()
documentService = TestBed.inject(DocumentService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
documentListViewService = TestBed.inject(DocumentListViewService)
router = TestBed.inject(Router)
fixture = TestBed.createComponent(SavedViewWidgetComponent)
@@ -235,7 +235,7 @@ describe('SavedViewWidgetComponent', () => {
it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
const reloadSpy = jest.spyOn(component, 'reload')
component.ngOnInit()
@@ -42,7 +42,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
@@ -53,6 +52,7 @@ import {
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@Component({
@@ -94,7 +94,7 @@ export class SavedViewWidgetComponent
private documentService: DocumentService,
private router: Router,
private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService,
@@ -124,7 +124,7 @@ export class SavedViewWidgetComponent
ngOnInit(): void {
this.reload()
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
this.consumerStatusService
this.websocketStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
@@ -12,9 +12,9 @@ import { routes } from 'src/app/app-routing.module'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { StatisticsWidgetComponent } from './statistics-widget.component'
@@ -23,7 +23,7 @@ describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent
let fixture: ComponentFixture<StatisticsWidgetComponent>
let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
const fileStatusSubject = new Subject<FileStatus>()
beforeEach(async () => {
@@ -44,9 +44,9 @@ describe('StatisticsWidgetComponent', () => {
}).compileComponents()
fixture = TestBed.createComponent(StatisticsWidgetComponent)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component = fixture.componentInstance
@@ -8,8 +8,8 @@ import { first, Subject, Subscription, takeUntil } from 'rxjs'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@@ -51,7 +51,7 @@ export class StatisticsWidgetComponent
constructor(
private http: HttpClient,
private consumerStatusService: ConsumerStatusService,
private websocketConnectionService: WebsocketStatusService,
private documentListViewService: DocumentListViewService
) {
super()
@@ -109,7 +109,7 @@ export class StatisticsWidgetComponent
ngOnInit(): void {
this.reload()
this.subscription = this.consumerStatusService
this.subscription = this.websocketConnectionService
.onDocumentConsumptionFinished()
.subscribe(() => {
this.reload()
@@ -12,13 +12,13 @@ import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import {
ConsumerStatusService,
FileStatus,
FileStatusPhase,
} from 'src/app/services/consumer-status.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
const FAILED_STATUSES = [new FileStatus()]
@@ -42,7 +42,7 @@ const DEFAULT_STATUSES = [
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent>
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => {
@@ -65,7 +65,7 @@ describe('UploadFileWidgetComponent', () => {
],
}).compileComponents()
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance
@@ -91,14 +91,14 @@ describe('UploadFileWidgetComponent', () => {
})
it('should generate stats summary', () => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
@@ -117,7 +117,7 @@ describe('UploadFileWidgetComponent', () => {
})
it('should enforce a maximum number of alerts', () => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
// 5 total, 1 hidden
expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
@@ -131,19 +131,19 @@ describe('UploadFileWidgetComponent', () => {
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', fakeAsync(() => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
component.alertsExpanded = true
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
tick(1000)
fixture.detectChanges()
@@ -12,13 +12,13 @@ import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
ConsumerStatusService,
FileStatus,
FileStatusPhase,
} from 'src/app/services/consumer-status.service'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
const MAX_ALERTS = 5
@@ -46,7 +46,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
constructor(
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
private uploadDocumentsService: UploadDocumentsService,
public settingsService: SettingsService
) {
@@ -54,13 +54,13 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
getStatus() {
return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.consumerStatusService.getConsumerStatusNotCompleted().length
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
@@ -78,27 +78,30 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
getStatusHidden() {
if (this.consumerStatusService.getConsumerStatus().length < MAX_ALERTS)
if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS)
return []
else return this.consumerStatusService.getConsumerStatus().slice(MAX_ALERTS)
else
return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS)
}
getStatusUploading() {
return this.consumerStatusService.getConsumerStatus(
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.consumerStatusService.getConsumerStatusCompleted()
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
@@ -134,12 +137,12 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
dismiss(status: FileStatus) {
this.consumerStatusService.dismiss(status)
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.consumerStatusService.dismiss(status)
this.websocketStatusService.dismiss(status)
)
}
@@ -9,9 +9,9 @@
}
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="onZoomSelect($event)">
<select class="form-select" (change)="setZoom($event.target.value)">
@for (setting of zoomSettings; track setting) {
<option [value]="setting" [selected]="previewZoomSetting === setting">
<option [value]="setting" [attr.selected]="isZoomSelected(setting) ? 'selected' : null">
{{ getZoomSettingTitle(setting) }}
</option>
}
@@ -25,15 +25,20 @@
</button>
<div class="btn-group">
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<i-bs width="1.2em" height="1.2em" name="download"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Download</span>
</a>
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading">
@if (downloading) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
<i-bs width="1.2em" height="1.2em" name="download"></i-bs>
}
<span class="d-none d-lg-inline ps-1" i18n>Download</span>
</button>
@if (metadata?.has_archive_version) {
<div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle></button>
<button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading" ngbDropdownToggle></button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
<button ngbDropdownItem (click)="download(true)" [disabled]="downloading" i18n>Download original</button>
</div>
</div>
}
@@ -351,9 +356,9 @@
</ng-template>
<ng-template #previewContent>
<div class="thumb-preview position-absolute pe-none" [class.fade]="previewLoaded">
<div class="thumb-preview position-absolute pe-none text-center" [class.fade]="previewLoaded">
@if (showThumbnailOverlay) {
<img [src]="thumbUrl | safeUrl" class="" width="100%" height="auto" alt="Document loading..." i18n-alt />
<img [src]="thumbUrl | safeUrl" class="mx-auto" [attr.width]="previewZoomScale === 'page-fit' ? 'auto' : '100%'" [attr.height]="previewZoomScale === 'page-fit' ? '100%' : 'auto'" alt="Document loading..." i18n-alt />
}
<div class="position-absolute top-0 start-0 m-2 p-2 d-flex align-items-center justify-content-center">
<div>
@@ -85,5 +85,8 @@ textarea.rtl {
> img {
filter: blur(1px);
max-width: 100%;
object-fit: contain;
object-position: top;
}
}
@@ -24,6 +24,7 @@ import {
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { Correspondent } from 'src/app/data/correspondent'
@@ -45,6 +46,7 @@ import { Tag } from 'src/app/data/tag'
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'
import { ComponentRouterService } from 'src/app/services/component-router.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -60,7 +62,10 @@ import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { DocumentDetailComponent } from './document-detail.component'
import {
DocumentDetailComponent,
ZoomSetting,
} from './document-detail.component'
const doc: Document = {
id: 3,
@@ -126,7 +131,9 @@ describe('DocumentDetailComponent', () => {
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let customFieldsService: CustomFieldsService
let deviceDetectorService: DeviceDetectorService
let httpTestingController: HttpTestingController
let componentRouterService: ComponentRouterService
let currentUserCan = true
let currentUserHasObjectPermissions = true
@@ -262,8 +269,10 @@ describe('DocumentDetailComponent', () => {
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 1 }
customFieldsService = TestBed.inject(CustomFieldsService)
deviceDetectorService = TestBed.inject(DeviceDetectorService)
fixture = TestBed.createComponent(DocumentDetailComponent)
httpTestingController = TestBed.inject(HttpTestingController)
componentRouterService = TestBed.inject(ComponentRouterService)
component = fixture.componentInstance
})
@@ -448,7 +457,9 @@ describe('DocumentDetailComponent', () => {
component.save(true)
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
expect(toastSpy).toHaveBeenCalledWith(
'Document "Doc 3" saved successfully.'
)
})
it('should support save without close and show success toast', () => {
@@ -461,7 +472,9 @@ describe('DocumentDetailComponent', () => {
component.save()
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
expect(toastSpy).toHaveBeenCalledWith(
'Document "Doc 3" saved successfully.'
)
})
it('should show toast error on save if error occurs', () => {
@@ -476,7 +489,10 @@ describe('DocumentDetailComponent', () => {
component.save()
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Error saving document', error)
expect(toastSpy).toHaveBeenCalledWith(
'Error saving document "Doc 3"',
error
)
})
it('should show error toast on save but close if user can no longer edit', () => {
@@ -492,7 +508,9 @@ describe('DocumentDetailComponent', () => {
component.save(true)
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
expect(toastSpy).toHaveBeenCalledWith(
'Document "Doc 3" saved successfully.'
)
})
it('should allow save and next', () => {
@@ -568,6 +586,16 @@ describe('DocumentDetailComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['documents'])
})
it('should allow close and navigate to the last view if available', () => {
initNormally()
jest
.spyOn(componentRouterService, 'getComponentURLBefore')
.mockReturnValue('dashboard')
const navigateSpy = jest.spyOn(router, 'navigate')
component.close()
expect(navigateSpy).toHaveBeenCalledWith(['dashboard'])
})
it('should allow close and navigate to documents by default', () => {
initNormally()
jest
@@ -728,7 +756,7 @@ describe('DocumentDetailComponent', () => {
it('should support zoom controls', () => {
initNormally()
component.onZoomSelect({ target: { value: '1' } } as any) // from select
component.setZoom(ZoomSetting.One) // from select
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
@@ -736,18 +764,18 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomSetting).toEqual('2')
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
component.onZoomSelect({ target: { value: '1' } } as any) // from select
component.setZoom(ZoomSetting.One) // from select
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('.75')
component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select
component.setZoom(ZoomSetting.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
expect(component.previewZoomScale).toEqual('page-width')
component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select
component.setZoom(ZoomSetting.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.decreaseZoom()
@@ -755,6 +783,19 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomScale).toEqual('page-width')
})
it('should select correct zoom setting in dropdown', () => {
initNormally()
component.setZoom(ZoomSetting.PageFit)
expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeTruthy()
expect(component.isZoomSelected(ZoomSetting.One)).toBeFalsy()
component.setZoom(ZoomSetting.PageWidth)
expect(component.isZoomSelected(ZoomSetting.One)).toBeTruthy()
expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeFalsy()
component.setZoom(ZoomSetting.Quarter)
expect(component.isZoomSelected(ZoomSetting.Quarter)).toBeTruthy()
expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeFalsy()
})
it('should support updating notes dynamically', () => {
const notes = [
{
@@ -1255,4 +1296,38 @@ describe('DocumentDetailComponent', () => {
.error(new ErrorEvent('failed'))
expect(component.tiffError).not.toBeUndefined()
})
it('should support download using share sheet on mobile, direct download otherwise', () => {
const shareSpy = jest.spyOn(navigator, 'share')
const createSpy = jest.spyOn(document, 'createElement')
const urlRevokeSpy = jest.spyOn(URL, 'revokeObjectURL')
initNormally()
// Mobile
jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(false)
component.download()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`)
.error(new ProgressEvent('failed'))
expect(shareSpy).not.toHaveBeenCalled()
component.download(true)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/download/?original=true`
)
.flush(new ArrayBuffer(100))
expect(shareSpy).toHaveBeenCalled()
// Desktop
shareSpy.mockClear()
jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(true)
component.download()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`)
.flush(new ArrayBuffer(100))
expect(shareSpy).not.toHaveBeenCalled()
expect(createSpy).toHaveBeenCalledWith('a')
expect(urlRevokeSpy).toHaveBeenCalled()
})
})
@@ -20,6 +20,7 @@ import {
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, Subject } from 'rxjs'
import {
debounceTime,
@@ -59,6 +60,7 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { ComponentRouterService } from 'src/app/services/component-router.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@@ -122,7 +124,7 @@ enum ContentRenderType {
TIFF = 'tiff',
}
enum ZoomSetting {
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
@@ -194,8 +196,6 @@ export class DocumentDetailComponent
previewUrl: string
thumbUrl: string
previewText: string
downloadUrl: string
downloadOriginalUrl: string
previewLoaded: boolean = false
tiffURL: string
tiffError: string
@@ -233,6 +233,9 @@ export class DocumentDetailComponent
ogDate: Date
customFields: CustomField[]
public downloading: boolean = false
public readonly CustomFieldDataType = CustomFieldDataType
public readonly ContentRenderType = ContentRenderType
@@ -272,7 +275,9 @@ export class DocumentDetailComponent
private userService: UserService,
private customFieldsService: CustomFieldsService,
private http: HttpClient,
private hotKeyService: HotKeyService
private hotKeyService: HotKeyService,
private componentRouterService: ComponentRouterService,
private deviceDetectorService: DeviceDetectorService
) {
super()
}
@@ -323,6 +328,7 @@ export class DocumentDetailComponent
}
ngOnInit(): void {
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
@@ -415,13 +421,6 @@ export class DocumentDetailComponent
.pipe(
switchMap((doc) => {
this.documentId = doc.id
this.downloadUrl = this.documentsService.getDownloadUrl(
this.documentId
)
this.downloadOriginalUrl = this.documentsService.getDownloadUrl(
this.documentId,
true
)
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
@@ -810,7 +809,9 @@ export class DocumentDetailComponent
this.store.next(newValues)
this.openDocumentService.setDirty(this.document, false)
this.openDocumentService.save()
this.toastService.showInfo($localize`Document saved successfully.`)
this.toastService.showInfo(
$localize`Document "${newValues.title}" saved successfully.`
)
this.networkActive = false
this.error = null
if (close) {
@@ -824,11 +825,16 @@ export class DocumentDetailComponent
error: (error) => {
this.networkActive = false
if (!this.userCanEdit) {
this.toastService.showInfo($localize`Document saved successfully.`)
this.toastService.showInfo(
$localize`Document "${this.document.title}" saved successfully.`
)
close && this.close()
} else {
this.error = error.error
this.toastService.showError($localize`Error saving document`, error)
this.toastService.showError(
$localize`Error saving document "${this.document.title}"`,
error
)
}
},
})
@@ -888,6 +894,10 @@ export class DocumentDetailComponent
'view',
this.documentListViewService.activeSavedViewId,
])
} else if (this.componentRouterService.getComponentURLBefore()) {
this.router.navigate([
this.componentRouterService.getComponentURLBefore(),
])
} else {
this.router.navigate(['documents'])
}
@@ -953,7 +963,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Reprocess operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
$localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
)
if (modal) {
modal.close()
@@ -972,6 +982,52 @@ export class DocumentDetailComponent
})
}
download(original: boolean = false) {
this.downloading = true
const downloadUrl = this.documentsService.getDownloadUrl(
this.documentId,
original
)
this.http.get(downloadUrl, { responseType: 'blob' }).subscribe({
next: (blob) => {
this.downloading = false
const blobParts = [blob]
const file = new File(
blobParts,
original
? this.document.original_file_name
: this.document.archived_file_name,
{
type: original ? this.document.mime_type : 'application/pdf',
}
)
if (
!this.deviceDetectorService.isDesktop() &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
navigator.share({
files: [file],
})
} else {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = this.document.title
a.click()
URL.revokeObjectURL(url)
}
},
error: (error) => {
this.downloading = false
this.toastService.showError(
$localize`Error downloading document`,
error
)
},
})
}
hasNext() {
return this.documentListViewService.hasNext(this.documentId)
}
@@ -1017,14 +1073,13 @@ export class DocumentDetailComponent
}
}
onZoomSelect(event: Event) {
const setting = (event.target as HTMLSelectElement)?.value as ZoomSetting
if (ZoomSetting.PageFit === setting) {
this.previewZoomSetting = ZoomSetting.One
setZoom(setting: ZoomSetting) {
if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
this.previewZoomScale = setting
this.previewZoomSetting = ZoomSetting.One
} else {
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting = setting
this.previewZoomScale = ZoomSetting.PageWidth
}
}
@@ -1034,6 +1089,14 @@ export class DocumentDetailComponent
)
}
isZoomSelected(setting: ZoomSetting): boolean {
if (this.previewZoomScale === ZoomSetting.PageFit) {
return setting === ZoomSetting.PageFit
}
return this.previewZoomSetting === setting
}
getZoomSettingTitle(setting: ZoomSetting): string {
switch (setting) {
case ZoomSetting.PageFit:
@@ -1267,7 +1330,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Split operation will begin in the background.`
$localize`Split operation for "${this.document.title}" will begin in the background.`
)
modal.close()
},
@@ -1306,7 +1369,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
delay: 8000,
action: this.close.bind(this),
actionName: $localize`Close`,
@@ -1346,7 +1409,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Delete pages operation will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
$localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
)
modal.close()
},
@@ -1039,6 +1039,7 @@ describe('BulkEditorComponent', () => {
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
expect(documentListViewService.selected.size).toEqual(0)
})
it('should support bulk download with archive, originals or both and file formatting', () => {
@@ -268,6 +268,9 @@ export class BulkEditorComponent
.pipe(first())
.subscribe({
next: () => {
if (args['delete_originals']) {
this.list.selected.clear()
}
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
@@ -38,16 +38,16 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import {
FileStatus,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component'
import { DocumentCardSmallComponent } from './document-card-small/document-card-small.component'
import { DocumentListComponent } from './document-list.component'
@@ -81,7 +81,7 @@ describe('DocumentListComponent', () => {
let fixture: ComponentFixture<DocumentListComponent>
let documentListService: DocumentListViewService
let documentService: DocumentService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let savedViewService: SavedViewService
let router: Router
let activatedRoute: ActivatedRoute
@@ -112,7 +112,7 @@ describe('DocumentListComponent', () => {
documentListService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
savedViewService = TestBed.inject(SavedViewService)
router = TestBed.inject(Router)
activatedRoute = TestBed.inject(ActivatedRoute)
@@ -128,13 +128,24 @@ describe('DocumentListComponent', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
fixture.detectChanges()
fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled()
})
it('should reload on document deleted', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload')
const documentDeletedSubject = new Subject<boolean>()
jest
.spyOn(websocketStatusService, 'onDocumentDeleted')
.mockReturnValue(documentDeletedSubject)
fixture.detectChanges()
documentDeletedSubject.next(true)
expect(reloadSpy).toHaveBeenCalled()
})
it('should show score sort fields on fulltext queries', () => {
documentListService.filterRules = [
{
@@ -43,7 +43,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@@ -51,6 +50,7 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import {
filterRulesDiffer,
isFullTextFilterRule,
@@ -113,7 +113,7 @@ export class DocumentListComponent
private router: Router,
private toastService: ToastService,
private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
public openDocumentsService: OpenDocumentsService,
public settingsService: SettingsService,
private hotKeyService: HotKeyService,
@@ -234,13 +234,17 @@ export class DocumentListComponent
}
ngOnInit(): void {
this.consumerStatusService
this.websocketStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.list.reload()
})
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
this.list.reload()
})
this.route.paramMap
.pipe(
filter((params) => params.has('id')), // only on saved view e.g. /view/id
@@ -108,13 +108,16 @@ export class CustomFieldsComponent
this.customFieldsService.delete(field).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted field`)
this.toastService.showInfo($localize`Deleted field "${field.name}"`)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error deleting field.`, e)
this.toastService.showError(
$localize`Error deleting field "${field.name}".`,
e
)
},
})
})
@@ -214,7 +214,7 @@ describe('MailComponent', () => {
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account "account1"')
})
it('should support process mail account, show error if needed', () => {
@@ -231,7 +231,9 @@ describe('MailComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
processSpy.mockReturnValueOnce(of(true))
component.processAccount(mailAccounts[0] as MailAccount)
expect(toastInfoSpy).toHaveBeenCalledWith('Processing mail account')
expect(toastInfoSpy).toHaveBeenCalledWith(
'Processing mail account "account1"'
)
})
it('should support edit / create mail rule, show error if needed', () => {
@@ -274,14 +276,14 @@ describe('MailComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule'))
throwError(() => new Error('error deleting mail rule "rule1"'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule "rule1"')
})
it('should support edit permissions on mail rule objects', () => {
@@ -200,7 +200,9 @@ export class MailComponent
this.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail account`)
this.toastService.showInfo(
$localize`Deleted mail account "${account.name}"`
)
this.mailAccountService.clearCache()
this.mailAccountService
.listAll(null, null, { full_perms: true })
@@ -210,7 +212,7 @@ export class MailComponent
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting mail account.`,
$localize`Error deleting mail account "${account.name}".`,
e
)
},
@@ -221,10 +223,15 @@ export class MailComponent
processAccount(account: MailAccount) {
this.mailAccountService.processAccount(account).subscribe({
next: () => {
this.toastService.showInfo($localize`Processing mail account`)
this.toastService.showInfo(
$localize`Processing mail account "${account.name}"`
)
},
error: (e) => {
this.toastService.showError($localize`Error processing mail account`, e)
this.toastService.showError(
$localize`Error processing mail account "${account.name}")`,
e
)
},
})
}
@@ -272,7 +279,10 @@ export class MailComponent
)
},
error: (e) => {
this.toastService.showError($localize`Error toggling rule.`, e)
this.toastService.showError(
$localize`Error toggling rule "${rule.name}".`,
e
)
},
})
}
@@ -291,7 +301,9 @@ export class MailComponent
this.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail rule`)
this.toastService.showInfo(
$localize`Deleted mail rule "${rule.name}"`
)
this.mailRuleService.clearCache()
this.mailRuleService
.listAll(null, null, { full_perms: true })
@@ -300,7 +312,10 @@ export class MailComponent
})
},
error: (e) => {
this.toastService.showError($localize`Error deleting mail rule.`, e)
this.toastService.showError(
$localize`Error deleting mail rule "${rule.name}".`,
e
)
},
})
})
@@ -73,35 +73,37 @@
</td>
}
<td scope="row">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
@if (object.document_count > 0) {
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
}
<div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
@if (object.document_count > 0) {
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
}
</div>
</div>
</div>
</div>
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
@if (object.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block ms-2">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ object.document_count }}</span>
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
}
@if (object.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ object.document_count }}</span>
</button>
</div>
}
</div>
</td>
</tr>
}
@@ -142,12 +142,17 @@ export class WorkflowsComponent
this.workflowService.delete(workflow).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted workflow`)
this.toastService.showInfo(
$localize`Deleted workflow "${workflow.name}".`
)
this.workflowService.clearCache()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error deleting workflow.`, e)
this.toastService.showError(
$localize`Error deleting workflow "${workflow.name}".`,
e
)
},
})
})
@@ -158,14 +163,17 @@ export class WorkflowsComponent
next: () => {
this.toastService.showInfo(
workflow.enabled
? $localize`Enabled workflow`
: $localize`Disabled workflow`
? $localize`Enabled workflow "${workflow.name}"`
: $localize`Disabled workflow "${workflow.name}"`
)
this.workflowService.clearCache()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error toggling workflow.`, e)
this.toastService.showError(
$localize`Error toggling workflow "${workflow.name}".`,
e
)
},
})
}
+7
View File
@@ -33,6 +33,8 @@ export const SETTINGS_KEYS = {
DARK_MODE_THUMB_INVERTED: 'general-settings:dark-mode:thumb-inverted',
THEME_COLOR: 'general-settings:theme:color',
USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer',
PDF_VIEWER_ZOOM_SETTING:
'general-settings:document-details:pdf-viewer-zoom-setting',
DATE_LOCALE: 'general-settings:date-display:date-locale',
DATE_FORMAT: 'general-settings:date-display:date-format',
NOTIFICATIONS_CONSUMER_NEW_DOCUMENT:
@@ -269,4 +271,9 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
type: 'string',
default: 'page-width', // ZoomSetting from 'document-detail.component'
},
]
@@ -0,0 +1,3 @@
export interface WebsocketDocumentsDeletedMessage {
documents: number[]
}
@@ -1,4 +1,4 @@
export interface WebsocketConsumerStatusMessage {
export interface WebsocketProgressMessage {
filename?: string
task_id?: string
current_progress?: number
@@ -0,0 +1,102 @@
import { TestBed } from '@angular/core/testing'
import { ActivationStart, Router } from '@angular/router'
import { Subject } from 'rxjs'
import { ComponentRouterService } from './component-router.service'
describe('ComponentRouterService', () => {
let service: ComponentRouterService
let router: Router
let eventsSubject: Subject<any>
beforeEach(() => {
eventsSubject = new Subject<any>()
TestBed.configureTestingModule({
providers: [
ComponentRouterService,
{
provide: Router,
useValue: {
events: eventsSubject.asObservable(),
},
},
],
})
service = TestBed.inject(ComponentRouterService)
router = TestBed.inject(Router)
})
it('should add to history and componentHistory on ActivationStart event', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url',
component: { name: 'TestComponent' },
} as any)
)
expect((service as any).history).toEqual(['test-url'])
expect((service as any).componentHistory).toEqual(['TestComponent'])
})
it('should not add duplicate component names to componentHistory', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url-1',
component: { name: 'TestComponent' },
} as any)
)
eventsSubject.next(
new ActivationStart({
url: 'test-url-2',
component: { name: 'TestComponent' },
} as any)
)
expect((service as any).componentHistory.length).toBe(1)
expect((service as any).componentHistory).toEqual(['TestComponent'])
})
it('should return the URL of the component before the current one', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url-1',
component: { name: 'TestComponent1' },
} as any)
)
eventsSubject.next(
new ActivationStart({
url: 'test-url-2',
component: { name: 'TestComponent2' },
} as any)
)
expect(service.getComponentURLBefore()).toBe('test-url-1')
})
it('should update the URL of the current component if the same component is loaded via a different URL', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url-1',
component: { name: 'TestComponent' },
} as any)
)
eventsSubject.next(
new ActivationStart({
url: 'test-url-2',
component: { name: 'TestComponent' },
} as any)
)
expect((service as any).history).toEqual(['test-url-2'])
})
it('should return null if there is no previous component', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url',
component: { name: 'TestComponent' },
} as any)
)
expect(service.getComponentURLBefore()).toBeNull()
})
})
@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core'
import { ActivationStart, Event, Router } from '@angular/router'
import { filter } from 'rxjs'
const EXCLUDE_COMPONENTS = ['AppFrameComponent']
@Injectable({
providedIn: 'root',
})
export class ComponentRouterService {
private history: string[] = []
private componentHistory: any[] = []
constructor(private router: Router) {
this.router.events
.pipe(filter((event: Event) => event instanceof ActivationStart))
.subscribe((event: ActivationStart) => {
if (
this.componentHistory[this.componentHistory.length - 1] !==
event.snapshot.component.name &&
!EXCLUDE_COMPONENTS.includes(event.snapshot.component.name)
) {
this.history.push(event.snapshot.url.toString())
this.componentHistory.push(event.snapshot.component.name)
} else {
// Update the URL of the current component in case the same component was loaded via a different URL
this.history[this.history.length - 1] = event.snapshot.url.toString()
}
})
}
public getComponentURLBefore(): any {
if (this.componentHistory.length > 1) {
return this.history[this.history.length - 2]
}
return null
}
}
@@ -1,326 +0,0 @@
import {
HttpEventType,
HttpResponse,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import WS from 'jest-websocket-mock'
import { environment } from 'src/environments/environment'
import {
ConsumerStatusService,
FILE_STATUS_MESSAGES,
FileStatusPhase,
} from './consumer-status.service'
import { DocumentService } from './rest/document.service'
import { SettingsService } from './settings.service'
describe('ConsumerStatusService', () => {
let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService
let documentService: DocumentService
let settingsService: SettingsService
const server = new WS(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`,
{ jsonProtocol: true }
)
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
ConsumerStatusService,
DocumentService,
SettingsService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
httpTestingController = TestBed.inject(HttpTestingController)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = {
id: 1,
username: 'testuser',
is_superuser: false,
}
consumerStatusService = TestBed.inject(ConsumerStatusService)
documentService = TestBed.inject(DocumentService)
})
afterEach(() => {
httpTestingController.verify()
})
it('should update status on websocket processing progress', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
expect(status.getProgress()).toEqual(0)
consumerStatusService.connect()
consumerStatusService
.onDocumentConsumptionFinished()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS)
})
consumerStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
})
server.send({
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'WORKING',
})
expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
task_id,
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: FILE_STATUS_MESSAGES.finished,
})
expect(status.getProgress()).toEqual(1)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
consumerStatusService.disconnect()
})
it('should update status on websocket failed progress', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
status.taskId = task_id
consumerStatusService.connect()
consumerStatusService
.onDocumentConsumptionFailed()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.FAILED)
})
server.send({
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
})
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'FAILED',
message: FILE_STATUS_MESSAGES.document_already_exists,
})
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should update status on upload progress', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
documentService.uploadDocument({}).subscribe((event) => {
if (event.type === HttpEventType.Response) {
status.taskId = event.body['task_id']
status.message = $localize`Upload complete, waiting...`
} else if (event.type === HttpEventType.UploadProgress) {
status.updateProgress(
FileStatusPhase.UPLOADING,
event.loaded,
event.total
)
}
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/post_document/`
)
req.event(
new HttpResponse({
body: {
task_id,
},
})
)
req.event({
type: HttpEventType.UploadProgress,
loaded: 100,
total: 300,
})
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status])
expect(consumerStatusService.getConsumerStatus()).toEqual([status])
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
req.event({
type: HttpEventType.UploadProgress,
loaded: 300,
total: 300,
})
expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300
})
it('should support dismiss completed', () => {
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: 'finished',
})
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
consumerStatusService.dismissCompleted()
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0)
consumerStatusService.disconnect()
})
it('should support dismiss', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
const status2 = consumerStatusService.newFileUpload('file2.pdf')
status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status, status2])
expect(consumerStatusService.getConsumerStatus()).toEqual([status, status2])
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
status2,
])
consumerStatusService.dismiss(status)
expect(consumerStatusService.getConsumerStatus()).toEqual([status2])
consumerStatusService.dismiss(status2)
expect(consumerStatusService.getConsumerStatus()).toHaveLength(0)
})
it('should support fail', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0)
consumerStatusService.fail(status, 'fail')
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should notify of document created on status message without upload', () => {
let detected = false
consumerStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
detected = true
})
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file.pdf',
current_progress: 0,
max_progress: 100,
message: 'new_file',
status: 'STARTED',
})
consumerStatusService.disconnect()
expect(detected).toBeTruthy()
})
it('should notify of document in progress without upload', () => {
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
status: 'WORKING',
})
consumerStatusService.disconnect()
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
it('should not notify current user if document has different expected owner', () => {
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file1.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
owner_id: 1,
status: 'WORKING',
})
server.send({
task_id: '5678',
filename: 'file2.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 13,
owner_id: 2,
status: 'WORKING',
})
consumerStatusService.disconnect()
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
})
@@ -9,11 +9,11 @@ import {
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
import { UploadDocumentsService } from './upload-documents.service'
import {
FileStatusPhase,
WebsocketStatusService,
} from './websocket-status.service'
const files = [
{
@@ -45,14 +45,14 @@ const fileList = {
describe('UploadDocumentsService', () => {
let httpTestingController: HttpTestingController
let uploadDocumentsService: UploadDocumentsService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
UploadDocumentsService,
ConsumerStatusService,
WebsocketStatusService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
@@ -60,7 +60,7 @@ describe('UploadDocumentsService', () => {
httpTestingController = TestBed.inject(HttpTestingController)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
})
afterEach(() => {
@@ -80,11 +80,11 @@ describe('UploadDocumentsService', () => {
it('updates progress during upload and failure', () => {
uploadDocumentsService.uploadFiles(fileList)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
2
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(0)
const req = httpTestingController.match(
@@ -98,7 +98,7 @@ describe('UploadDocumentsService', () => {
})
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(1)
})
@@ -110,7 +110,7 @@ describe('UploadDocumentsService', () => {
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(0)
req[0].flush(
@@ -122,7 +122,7 @@ describe('UploadDocumentsService', () => {
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1)
uploadDocumentsService.uploadFiles(fileList)
@@ -140,7 +140,7 @@ describe('UploadDocumentsService', () => {
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(2)
})
@@ -2,11 +2,11 @@ import { HttpEventType } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'
import { Subscription } from 'rxjs'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
import { DocumentService } from './rest/document.service'
import {
FileStatusPhase,
WebsocketStatusService,
} from './websocket-status.service'
@Injectable({
providedIn: 'root',
@@ -16,7 +16,7 @@ export class UploadDocumentsService {
constructor(
private documentService: DocumentService,
private consumerStatusService: ConsumerStatusService
private websocketStatusService: WebsocketStatusService
) {}
onNgxFileDrop(files: NgxFileDropEntry[]) {
@@ -37,7 +37,7 @@ export class UploadDocumentsService {
private uploadFile(file: File) {
let formData = new FormData()
formData.append('document', file, file.name)
let status = this.consumerStatusService.newFileUpload(file.name)
let status = this.websocketStatusService.newFileUpload(file.name)
status.message = $localize`Connecting...`
@@ -61,11 +61,11 @@ export class UploadDocumentsService {
error: (error) => {
switch (error.status) {
case 400: {
this.consumerStatusService.fail(status, error.error.document)
this.websocketStatusService.fail(status, error.error.document)
break
}
default: {
this.consumerStatusService.fail(
this.websocketStatusService.fail(
status,
$localize`HTTP error: ${error.status} ${error.statusText}`
)
@@ -0,0 +1,375 @@
import {
HttpEventType,
HttpResponse,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import WS from 'jest-websocket-mock'
import { environment } from 'src/environments/environment'
import { DocumentService } from './rest/document.service'
import { SettingsService } from './settings.service'
import {
FILE_STATUS_MESSAGES,
FileStatusPhase,
WebsocketStatusService,
WebsocketStatusType,
} from './websocket-status.service'
describe('ConsumerStatusService', () => {
let httpTestingController: HttpTestingController
let websocketStatusService: WebsocketStatusService
let documentService: DocumentService
let settingsService: SettingsService
const server = new WS(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`,
{ jsonProtocol: true }
)
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
WebsocketStatusService,
DocumentService,
SettingsService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
httpTestingController = TestBed.inject(HttpTestingController)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = {
id: 1,
username: 'testuser',
is_superuser: false,
}
websocketStatusService = TestBed.inject(WebsocketStatusService)
documentService = TestBed.inject(DocumentService)
})
afterEach(() => {
httpTestingController.verify()
})
it('should update status on websocket processing progress', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
expect(status.getProgress()).toEqual(0)
websocketStatusService.connect()
websocketStatusService
.onDocumentConsumptionFinished()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS)
})
websocketStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
})
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'WORKING',
},
})
expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: FILE_STATUS_MESSAGES.finished,
},
})
expect(status.getProgress()).toEqual(1)
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
websocketStatusService.disconnect()
})
it('should update status on websocket failed progress', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
status.taskId = task_id
websocketStatusService.connect()
websocketStatusService
.onDocumentConsumptionFailed()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.FAILED)
})
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
},
})
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'FAILED',
message: FILE_STATUS_MESSAGES.document_already_exists,
},
})
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should update status on upload progress', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
documentService.uploadDocument({}).subscribe((event) => {
if (event.type === HttpEventType.Response) {
status.taskId = event.body['task_id']
status.message = $localize`Upload complete, waiting...`
} else if (event.type === HttpEventType.UploadProgress) {
status.updateProgress(
FileStatusPhase.UPLOADING,
event.loaded,
event.total
)
}
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/post_document/`
)
req.event(
new HttpResponse({
body: {
task_id,
},
})
)
req.event({
type: HttpEventType.UploadProgress,
loaded: 100,
total: 300,
})
expect(
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status])
expect(websocketStatusService.getConsumerStatus()).toEqual([status])
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
req.event({
type: HttpEventType.UploadProgress,
loaded: 300,
total: 300,
})
expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300
})
it('should support dismiss completed', () => {
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: 'finished',
},
})
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
websocketStatusService.dismissCompleted()
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0)
websocketStatusService.disconnect()
})
it('should support dismiss', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
const status2 = websocketStatusService.newFileUpload('file2.pdf')
status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status, status2])
expect(websocketStatusService.getConsumerStatus()).toEqual([
status,
status2,
])
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
status2,
])
websocketStatusService.dismiss(status)
expect(websocketStatusService.getConsumerStatus()).toEqual([status2])
websocketStatusService.dismiss(status2)
expect(websocketStatusService.getConsumerStatus()).toHaveLength(0)
})
it('should support fail', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0)
websocketStatusService.fail(status, 'fail')
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should notify of document created on status message without upload', () => {
let detected = false
websocketStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
detected = true
})
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file.pdf',
current_progress: 0,
max_progress: 100,
message: 'new_file',
status: 'STARTED',
},
})
websocketStatusService.disconnect()
expect(detected).toBeTruthy()
})
it('should notify of document in progress without upload', () => {
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
status: 'WORKING',
},
})
websocketStatusService.disconnect()
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
it('should not notify current user if document has different expected owner', () => {
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file1.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
owner_id: 1,
status: 'WORKING',
},
})
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '5678',
filename: 'file2.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 13,
owner_id: 2,
status: 'WORKING',
},
})
websocketStatusService.disconnect()
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
it('should trigger deleted subject on document deleted', () => {
let deleted = false
websocketStatusService.onDocumentDeleted().subscribe(() => {
deleted = true
})
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.DOCUMENTS_DELETED,
data: {
documents: [1, 2, 3],
},
})
websocketStatusService.disconnect()
expect(deleted).toBeTruthy()
})
})
@@ -1,9 +1,15 @@
import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment'
import { WebsocketConsumerStatusMessage } from '../data/websocket-consumer-status-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
import { SettingsService } from './settings.service'
export enum WebsocketStatusType {
STATUS_UPDATE = 'status_update',
DOCUMENTS_DELETED = 'documents_deleted',
}
// see ProgressStatusOptions in src/documents/plugins/helpers.py
export enum FileStatusPhase {
STARTED = 0,
@@ -85,7 +91,7 @@ export class FileStatus {
@Injectable({
providedIn: 'root',
})
export class ConsumerStatusService {
export class WebsocketStatusService {
constructor(private settingsService: SettingsService) {}
private statusWebSocket: WebSocket
@@ -95,6 +101,7 @@ export class ConsumerStatusService {
private documentDetectedSubject = new Subject<FileStatus>()
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) {
let status =
@@ -145,63 +152,75 @@ export class ConsumerStatusService {
this.statusWebSocket = new WebSocket(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`
)
this.statusWebSocket.onmessage = (ev) => {
let statusMessage: WebsocketConsumerStatusMessage = JSON.parse(ev['data'])
this.statusWebSocket.onmessage = (ev: MessageEvent) => {
const {
type,
data: messageData,
}: {
type: WebsocketStatusType
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
} = JSON.parse(ev.data)
// fallback if backend didn't restrict message
if (
statusMessage.owner_id &&
statusMessage.owner_id !== this.settingsService.currentUser?.id &&
!this.settingsService.currentUser?.is_superuser
) {
return
}
let statusMessageGet = this.get(
statusMessage.task_id,
statusMessage.filename
)
let status = statusMessageGet.status
let created = statusMessageGet.created
status.updateProgress(
FileStatusPhase.WORKING,
statusMessage.current_progress,
statusMessage.max_progress
)
if (
statusMessage.message &&
statusMessage.message in FILE_STATUS_MESSAGES
) {
status.message = FILE_STATUS_MESSAGES[statusMessage.message]
} else if (statusMessage.message) {
status.message = statusMessage.message
}
status.documentId = statusMessage.document_id
if (statusMessage.status in FileStatusPhase) {
status.phase = FileStatusPhase[statusMessage.status]
}
switch (status.phase) {
case FileStatusPhase.STARTED:
if (created) this.documentDetectedSubject.next(status)
switch (type) {
case WebsocketStatusType.DOCUMENTS_DELETED:
this.documentDeletedSubject.next(true)
break
case FileStatusPhase.SUCCESS:
this.documentConsumptionFinishedSubject.next(status)
break
case FileStatusPhase.FAILED:
this.documentConsumptionFailedSubject.next(status)
break
default:
case WebsocketStatusType.STATUS_UPDATE:
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break
}
}
}
handleProgressUpdate(messageData: WebsocketProgressMessage) {
// fallback if backend didn't restrict message
if (
messageData.owner_id &&
messageData.owner_id !== this.settingsService.currentUser?.id &&
!this.settingsService.currentUser?.is_superuser
) {
return
}
let statusMessageGet = this.get(messageData.task_id, messageData.filename)
let status = statusMessageGet.status
let created = statusMessageGet.created
status.updateProgress(
FileStatusPhase.WORKING,
messageData.current_progress,
messageData.max_progress
)
if (messageData.message && messageData.message in FILE_STATUS_MESSAGES) {
status.message = FILE_STATUS_MESSAGES[messageData.message]
} else if (messageData.message) {
status.message = messageData.message
}
status.documentId = messageData.document_id
if (messageData.status in FileStatusPhase) {
status.phase = FileStatusPhase[messageData.status]
}
switch (status.phase) {
case FileStatusPhase.STARTED:
if (created) this.documentDetectedSubject.next(status)
break
case FileStatusPhase.SUCCESS:
this.documentConsumptionFinishedSubject.next(status)
break
case FileStatusPhase.FAILED:
this.documentConsumptionFailedSubject.next(status)
break
default:
break
}
}
fail(status: FileStatus, message: string) {
status.message = message
status.phase = FileStatusPhase.FAILED
@@ -250,4 +269,8 @@ export class ConsumerStatusService {
onDocumentDetected() {
return this.documentDetectedSubject
}
onDocumentDeleted() {
return this.documentDeletedSubject
}
}
+4
View File
@@ -24,6 +24,7 @@ from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.permissions import set_permissions_for_object
from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import update_document_content_maybe_archive_file
@@ -219,6 +220,9 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
with index.open_index_writer() as writer:
for id in doc_ids:
index.remove_document_by_id(writer, id)
status_mgr = DocumentsStatusManager()
status_mgr.send_documents_deleted(doc_ids)
except Exception as e:
if "Data too long for column" in str(e):
logger.warning(
+16
View File
@@ -1,6 +1,7 @@
import logging
import pickle
import re
import time
import warnings
from collections.abc import Iterator
from hashlib import sha256
@@ -141,6 +142,19 @@ class DocumentClassifier:
):
raise IncompatibleClassifierVersionError("sklearn version update")
def set_last_checked(self) -> None:
# save a timestamp of the last time we checked for retraining to a file
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("w") as f:
f.write(str(time.time()))
def get_last_checked(self) -> float | None:
# load the timestamp of the last time we checked for retraining
try:
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("r") as f:
return float(f.read())
except FileNotFoundError: # pragma: no cover
return None
def save(self) -> None:
target_file: Path = settings.MODEL_FILE
target_file_temp: Path = target_file.with_suffix(".pickle.part")
@@ -161,6 +175,7 @@ class DocumentClassifier:
pickle.dump(self.storage_path_classifier, f)
target_file_temp.rename(target_file)
self.set_last_checked()
def train(self) -> bool:
# Get non-inbox documents
@@ -229,6 +244,7 @@ class DocumentClassifier:
and self.last_doc_change_time >= latest_doc_change
) and self.last_auto_type_hash == hasher.digest():
logger.info("No updates since last training")
self.set_last_checked()
# Set the classifier information into the cache
# Caching for 50 minutes, so slightly less than the normal retrain time
cache.set(
+22 -14
View File
@@ -4,6 +4,7 @@ import os
import tempfile
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING
import magic
from django.conf import settings
@@ -154,7 +155,11 @@ class ConsumerPlugin(
"""
Confirm the input file still exists where it should
"""
if not os.path.isfile(self.input_doc.original_file):
if TYPE_CHECKING:
assert isinstance(self.input_doc.original_file, Path), (
self.input_doc.original_file
)
if not self.input_doc.original_file.is_file():
self._fail(
ConsumerStatusShortMessage.FILE_NOT_FOUND,
f"Cannot consume {self.input_doc.original_file}: File not found.",
@@ -164,7 +169,7 @@ class ConsumerPlugin(
"""
Using the MD5 of the file, check this exact file doesn't already exist
"""
with open(self.input_doc.original_file, "rb") as f:
with Path(self.input_doc.original_file).open("rb") as f:
checksum = hashlib.md5(f.read()).hexdigest()
existing_doc = Document.global_objects.filter(
Q(checksum=checksum) | Q(archive_checksum=checksum),
@@ -178,7 +183,7 @@ class ConsumerPlugin(
log_msg += " Note: existing document is in the trash."
if settings.CONSUMER_DELETE_DUPLICATES:
os.unlink(self.input_doc.original_file)
Path(self.input_doc.original_file).unlink()
self._fail(
msg,
log_msg,
@@ -237,7 +242,7 @@ class ConsumerPlugin(
if not settings.PRE_CONSUME_SCRIPT:
return
if not os.path.isfile(settings.PRE_CONSUME_SCRIPT):
if not Path(settings.PRE_CONSUME_SCRIPT).is_file():
self._fail(
ConsumerStatusShortMessage.PRE_CONSUME_SCRIPT_NOT_FOUND,
f"Configured pre-consume script "
@@ -280,7 +285,7 @@ class ConsumerPlugin(
if not settings.POST_CONSUME_SCRIPT:
return
if not os.path.isfile(settings.POST_CONSUME_SCRIPT):
if not Path(settings.POST_CONSUME_SCRIPT).is_file():
self._fail(
ConsumerStatusShortMessage.POST_CONSUME_SCRIPT_NOT_FOUND,
f"Configured post-consume script "
@@ -582,7 +587,7 @@ class ConsumerPlugin(
document.thumbnail_path,
)
if archive_path and os.path.isfile(archive_path):
if archive_path and Path(archive_path).is_file():
document.archive_filename = generate_unique_filename(
document,
archive_filename=True,
@@ -594,7 +599,7 @@ class ConsumerPlugin(
document.archive_path,
)
with open(archive_path, "rb") as f:
with Path(archive_path).open("rb") as f:
document.archive_checksum = hashlib.md5(
f.read(),
).hexdigest()
@@ -612,14 +617,14 @@ class ConsumerPlugin(
self.unmodified_original.unlink()
# https://github.com/jonaswinkler/paperless-ng/discussions/1037
shadow_file = os.path.join(
os.path.dirname(self.input_doc.original_file),
"._" + os.path.basename(self.input_doc.original_file),
shadow_file = (
Path(self.input_doc.original_file).parent
/ f"._{Path(self.input_doc.original_file).name}"
)
if os.path.isfile(shadow_file):
if Path(shadow_file).is_file():
self.log.debug(f"Deleting file {shadow_file}")
os.unlink(shadow_file)
Path(shadow_file).unlink()
except Exception as e:
self._fail(
@@ -704,7 +709,7 @@ class ConsumerPlugin(
create_date = date
self.log.debug(f"Creation date from parse_date: {create_date}")
else:
stats = os.stat(self.input_doc.original_file)
stats = Path(self.input_doc.original_file).stat()
create_date = timezone.make_aware(
datetime.datetime.fromtimestamp(stats.st_mtime),
)
@@ -800,7 +805,10 @@ class ConsumerPlugin(
) # adds to document
def _write(self, storage_type, source, target):
with open(source, "rb") as read_file, open(target, "wb") as write_file:
with (
Path(source).open("rb") as read_file,
Path(target).open("wb") as write_file,
):
write_file.write(read_file.read())
# Attempt to copy file's original stats, but it's ok if we can't
+1 -1
View File
@@ -41,7 +41,7 @@ DATE_REGEX = re.compile(
r"(\b|(?!=([_-])))(\d{1,2}[\. ]+[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{4}|[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{1,2}, \d{4})(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{1,2}, (\d{4}))(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{4})(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))(\d{1,2}[^ ]{2}[\. ]+[^ ]{3,9}[ \.\/-]\d{4})(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))(\d{1,2}[^ 0-9]{2}[\. ]+[^ ]{3,9}[ \.\/-]\d{4})(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))(\b\d{1,2}[ \.\/-][a-zéûäëčžúřěáíóńźçŞğü]{3}[ \.\/-]\d{4})(\b|(?=([_-])))",
re.IGNORECASE,
)
+33 -13
View File
@@ -15,16 +15,14 @@ class ProgressStatusOptions(str, enum.Enum):
FAILED = "FAILED"
class ProgressManager:
class BaseStatusManager:
"""
Handles sending of progress information via the channel layer, with proper management
of the open/close of the layer to ensure messages go out and everything is cleaned up
"""
def __init__(self, filename: str, task_id: str | None = None) -> None:
self.filename = filename
def __init__(self) -> None:
self._channel: RedisPubSubChannelLayer | None = None
self.task_id = task_id
def __enter__(self):
self.open()
@@ -49,6 +47,24 @@ class ProgressManager:
async_to_sync(self._channel.flush)
self._channel = None
def send(self, payload: dict[str, str | int | None]) -> None:
# Ensure the layer is open
self.open()
# Just for IDEs
if TYPE_CHECKING:
assert self._channel is not None
# Construct and send the update
async_to_sync(self._channel.group_send)("status_updates", payload)
class ProgressManager(BaseStatusManager):
def __init__(self, filename: str | None = None, task_id: str | None = None) -> None:
super().__init__()
self.filename = filename
self.task_id = task_id
def send_progress(
self,
status: ProgressStatusOptions,
@@ -57,13 +73,6 @@ class ProgressManager:
max_progress: int,
extra_args: dict[str, str | int | None] | None = None,
) -> None:
# Ensure the layer is open
self.open()
# Just for IDEs
if TYPE_CHECKING:
assert self._channel is not None
payload = {
"type": "status_update",
"data": {
@@ -78,5 +87,16 @@ class ProgressManager:
if extra_args is not None:
payload["data"].update(extra_args)
# Construct and send the update
async_to_sync(self._channel.group_send)("status_updates", payload)
self.send(payload)
class DocumentsStatusManager(BaseStatusManager):
def send_documents_deleted(self, documents: list[int]) -> None:
payload = {
"type": "documents_deleted",
"data": {
"documents": documents,
},
}
self.send(payload)
+1 -1
View File
@@ -353,7 +353,7 @@ def cleanup_document_deletion(sender, instance, **kwargs):
f"{filename} could not be deleted: {e}",
)
elif filename and not os.path.isfile(filename):
logger.warn(f"Expected {filename} tp exist, but it did not")
logger.warning(f"Expected {filename} to exist, but it did not")
delete_empty_directories(
os.path.dirname(instance.source_path),
+6 -18
View File
@@ -1,7 +1,6 @@
import datetime
import io
import json
import os
import shutil
import zipfile
@@ -15,9 +14,10 @@ from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import SampleDirMixin
class TestBulkDownload(DirectoriesMixin, APITestCase):
class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
ENDPOINT = "/api/documents/bulk_download/"
def setUp(self):
@@ -51,22 +51,10 @@ class TestBulkDownload(DirectoriesMixin, APITestCase):
archive_checksum="D",
)
shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
self.doc2.source_path,
)
shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "simple.png"),
self.doc2b.source_path,
)
shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "simple.jpg"),
self.doc3.source_path,
)
shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "test_with_bom.pdf"),
self.doc3.archive_path,
)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc2.source_path)
shutil.copy(self.SAMPLE_DIR / "simple.png", self.doc2b.source_path)
shutil.copy(self.SAMPLE_DIR / "simple.jpg", self.doc3.source_path)
shutil.copy(self.SAMPLE_DIR / "test_with_bom.pdf", self.doc3.archive_path)
def test_download_originals(self):
response = self.client.post(
+43 -101
View File
@@ -1,5 +1,4 @@
import datetime
import os
import shutil
import tempfile
import uuid
@@ -8,6 +7,7 @@ from binascii import hexlify
from datetime import date
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from unittest import mock
import celery
@@ -171,19 +171,18 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
content = b"This is a test"
content_thumbnail = b"thumbnail content"
with open(filename, "wb") as f:
with Path(filename).open("wb") as f:
f.write(content)
doc = Document.objects.create(
title="none",
filename=os.path.basename(filename),
filename=Path(filename).name,
mime_type="application/pdf",
)
with open(
os.path.join(self.dirs.thumbnail_dir, f"{doc.pk:07d}.webp"),
"wb",
) as f:
if TYPE_CHECKING:
assert isinstance(self.dirs.thumbnail_dir, Path), self.dirs.thumbnail_dir
with (self.dirs.thumbnail_dir / f"{doc.pk:07d}.webp").open("wb") as f:
f.write(content_thumbnail)
response = self.client.get(f"/api/documents/{doc.pk}/download/")
@@ -217,7 +216,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
content = b"This is a test"
content_thumbnail = b"thumbnail content"
with open(filename, "wb") as f:
with Path(filename).open("wb") as f:
f.write(content)
user1 = User.objects.create_user(username="test1")
@@ -229,15 +228,12 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
doc = Document.objects.create(
title="none",
filename=os.path.basename(filename),
filename=Path(filename).name,
mime_type="application/pdf",
owner=user1,
)
with open(
os.path.join(self.dirs.thumbnail_dir, f"{doc.pk:07d}.webp"),
"wb",
) as f:
with (Path(self.dirs.thumbnail_dir) / f"{doc.pk:07d}.webp").open("wb") as f:
f.write(content_thumbnail)
response = self.client.get(f"/api/documents/{doc.pk}/download/")
@@ -272,10 +268,10 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
mime_type="application/pdf",
)
with open(doc.source_path, "wb") as f:
with Path(doc.source_path).open("wb") as f:
f.write(content)
with open(doc.archive_path, "wb") as f:
with Path(doc.archive_path).open("wb") as f:
f.write(content_archive)
response = self.client.get(f"/api/documents/{doc.pk}/download/")
@@ -305,7 +301,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
def test_document_actions_not_existing_file(self):
doc = Document.objects.create(
title="none",
filename=os.path.basename("asd"),
filename=Path("asd").name,
mime_type="application/pdf",
)
@@ -1026,10 +1022,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f},
@@ -1061,10 +1054,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{
@@ -1095,10 +1085,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"documenst": f},
@@ -1111,10 +1098,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.zip"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.zip").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f},
@@ -1127,10 +1111,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "title": "my custom title"},
@@ -1152,10 +1133,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
)
c = Correspondent.objects.create(name="test-corres")
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "correspondent": c.id},
@@ -1176,10 +1154,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "correspondent": 3456},
@@ -1194,10 +1169,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
)
dt = DocumentType.objects.create(name="invoice")
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "document_type": dt.id},
@@ -1218,10 +1190,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "document_type": 34578},
@@ -1236,10 +1205,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
)
sp = StoragePath.objects.create(name="invoices")
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "storage_path": sp.id},
@@ -1260,10 +1226,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "storage_path": 34578},
@@ -1279,10 +1242,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
t1 = Tag.objects.create(name="tag1")
t2 = Tag.objects.create(name="tag2")
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "tags": [t2.id, t1.id]},
@@ -1305,10 +1265,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
t1 = Tag.objects.create(name="tag1")
t2 = Tag.objects.create(name="tag2")
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "tags": [t2.id, t1.id, 734563]},
@@ -1332,10 +1289,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
0,
tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "created": created},
@@ -1353,10 +1307,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f, "archive_serial_number": 500},
@@ -1385,10 +1336,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
data_type=CustomField.FieldDataType.STRING,
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{
@@ -1417,10 +1365,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
id=str(uuid.uuid4()),
)
with open(
os.path.join(os.path.dirname(__file__), "samples", "invalid_pdf.pdf"),
"rb",
) as f:
with (Path(__file__).parent / "samples" / "invalid_pdf.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{"document": f},
@@ -1437,14 +1382,14 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
archive_filename="archive.pdf",
)
source_file = os.path.join(
os.path.dirname(__file__),
"samples",
"documents",
"thumbnails",
"0000001.webp",
source_file: Path = (
Path(__file__).parent
/ "samples"
/ "documents"
/ "thumbnails"
/ "0000001.webp"
)
archive_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf")
archive_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
shutil.copy(source_file, doc.source_path)
shutil.copy(archive_file, doc.archive_path)
@@ -1460,8 +1405,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertGreater(len(meta["archive_metadata"]), 0)
self.assertEqual(meta["media_filename"], "file.pdf")
self.assertEqual(meta["archive_media_filename"], "archive.pdf")
self.assertEqual(meta["original_size"], os.stat(source_file).st_size)
self.assertEqual(meta["archive_size"], os.stat(archive_file).st_size)
self.assertEqual(meta["original_size"], Path(source_file).stat().st_size)
self.assertEqual(meta["archive_size"], Path(archive_file).stat().st_size)
response = self.client.get(f"/api/documents/{doc.pk}/metadata/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -1477,10 +1422,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
mime_type="application/pdf",
)
shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"),
doc.source_path,
)
shutil.copy(Path(__file__).parent / "samples" / "simple.pdf", doc.source_path)
response = self.client.get(f"/api/documents/{doc.pk}/metadata/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -1939,9 +1881,9 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
def test_get_logs(self):
log_data = "test\ntest2\n"
with open(os.path.join(settings.LOGGING_DIR, "mail.log"), "w") as f:
with (Path(settings.LOGGING_DIR) / "mail.log").open("w") as f:
f.write(log_data)
with open(os.path.join(settings.LOGGING_DIR, "paperless.log"), "w") as f:
with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f:
f.write(log_data)
response = self.client.get("/api/logs/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -1949,7 +1891,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
def test_get_logs_only_when_exist(self):
log_data = "test\ntest2\n"
with open(os.path.join(settings.LOGGING_DIR, "paperless.log"), "w") as f:
with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f:
f.write(log_data)
response = self.client.get("/api/logs/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -1966,7 +1908,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
def test_get_log(self):
log_data = "test\ntest2\n"
with open(os.path.join(settings.LOGGING_DIR, "paperless.log"), "w") as f:
with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f:
f.write(log_data)
response = self.client.get("/api/logs/paperless/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
+6 -11
View File
@@ -15,7 +15,6 @@ from urllib.parse import quote
from urllib.parse import urlparse
import pathvalidate
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
@@ -2174,18 +2173,14 @@ class SystemStatusView(PassUserMixin):
classifier_status = "WARNING"
raise FileNotFoundError(classifier_error)
classifier_status = "OK"
task_result_model = apps.get_model("django_celery_results", "taskresult")
result = (
task_result_model.objects.filter(
task_name="documents.tasks.train_classifier",
status="SUCCESS",
classifier_last_trained = (
make_aware(
datetime.fromtimestamp(classifier.get_last_checked()),
)
.order_by(
"-date_done",
)
.first()
if settings.MODEL_FILE.exists()
and classifier.get_last_checked() is not None
else None
)
classifier_last_trained = result.date_done if result else None
except Exception as e:
if classifier_status is None:
classifier_status = "ERROR"
+7 -1
View File
@@ -41,4 +41,10 @@ class StatusConsumer(WebsocketConsumer):
self.close()
else:
if self._is_owner_or_unowned(event["data"]):
self.send(json.dumps(event["data"]))
self.send(json.dumps(event))
def documents_deleted(self, event):
if not self._authenticated():
self.close()
else:
self.send(json.dumps(event))
+1 -1
View File
@@ -162,7 +162,7 @@ class SocialAccountSerializer(serializers.ModelSerializer):
class ProfileSerializer(serializers.ModelSerializer):
email = serializers.EmailField(allow_null=False)
email = serializers.EmailField(allow_blank=True, required=False)
password = ObfuscatedUserPasswordField(required=False, allow_null=False)
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
social_accounts = SocialAccountSerializer(
+109 -3
View File
@@ -5,6 +5,9 @@ from channels.testing import WebsocketCommunicator
from django.test import TestCase
from django.test import override_settings
from documents.plugins.helpers import DocumentsStatusManager
from documents.plugins.helpers import ProgressManager
from documents.plugins.helpers import ProgressStatusOptions
from paperless.asgi import application
TEST_CHANNEL_LAYERS = {
@@ -22,6 +25,39 @@ class TestWebSockets(TestCase):
self.assertFalse(connected)
await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer.close")
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_close_on_no_auth(self, _authenticated, mock_close):
_authenticated.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/")
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
message = {"type": "status_update", "data": {"task_id": "test"}}
_authenticated.return_value = False
channel_layer = get_channel_layer()
await channel_layer.group_send(
"status_updates",
message,
)
await communicator.receive_nothing()
mock_close.assert_called_once()
mock_close.reset_mock()
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
await channel_layer.group_send(
"status_updates",
message,
)
await communicator.receive_nothing()
mock_close.assert_called_once()
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_auth(self, _authenticated):
_authenticated.return_value = True
@@ -33,19 +69,19 @@ class TestWebSockets(TestCase):
await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_receive(self, _authenticated):
async def test_receive_status_update(self, _authenticated):
_authenticated.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/")
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
message = {"task_id": "test"}
message = {"type": "status_update", "data": {"task_id": "test"}}
channel_layer = get_channel_layer()
await channel_layer.group_send(
"status_updates",
{"type": "status_update", "data": message},
message,
)
response = await communicator.receive_json_from()
@@ -53,3 +89,73 @@ class TestWebSockets(TestCase):
self.assertEqual(response, message)
await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_receive_documents_deleted(self, _authenticated):
_authenticated.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/")
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
channel_layer = get_channel_layer()
await channel_layer.group_send(
"status_updates",
message,
)
response = await communicator.receive_json_from()
self.assertEqual(response, message)
await communicator.disconnect()
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_progress(self, mock_group_send):
with ProgressManager(task_id="test") as manager:
manager.send_progress(
ProgressStatusOptions.STARTED,
"Test message",
1,
10,
extra_args={
"foo": "bar",
},
)
message = mock_group_send.call_args[0][1]
self.assertEqual(
message,
{
"type": "status_update",
"data": {
"filename": None,
"task_id": "test",
"current_progress": 1,
"max_progress": 10,
"status": ProgressStatusOptions.STARTED,
"message": "Test message",
"foo": "bar",
},
},
)
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_documents_deleted(self, mock_group_send):
with DocumentsStatusManager() as manager:
manager.send_documents_deleted([1, 2, 3])
message = mock_group_send.call_args[0][1]
self.assertEqual(
message,
{
"type": "documents_deleted",
"data": {
"documents": [1, 2, 3],
},
},
)