Compare commits

...
63 Commits
Author SHA1 Message Date
shamoon 6b17ba2934 Bump version to 2.7.0 2024-04-05 18:00:05 -07:00
shamoon 4d3616cda9 Merge branch 'dev' 2024-04-05 17:59:32 -07:00
github-actions[bot]andCrowdin Bot c4a9697e02 New Crowdin translations by GitHub Action (#6127)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-04-05 17:59:03 -07:00
shamoon c57b7520b9 Update translation strings 2024-04-04 14:32:00 -07:00
dependabot[bot] 46bd09227f Chore(deps): Bump pillow from 10.2.0 to 10.3.0 (#6268)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 10.2.0 to 10.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/10.2.0...10.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 20:39:08 +00:00
dependabot[bot]andTrenton H 971f92a05c Chore(deps-dev): Bump the development group with 2 updates (#6276)
* 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.3.4 to 0.3.5
- [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/v0.3.4...v0.3.5)

Updates `mkdocs-material` from 9.5.15 to 9.5.17
- [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.15...9.5.17)

---
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-patch
  dependency-group: development
...

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

* Updates ruff hook version to match

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2024-04-04 20:26:40 +00:00
Trenton H 2c43b06910 Chore: Standardize subprocess running and logging (#6275) 2024-04-04 13:11:43 -07:00
shamoon 0f8b2e69c9 Change: enable auditlog by default, fix import / export (#6267) 2024-04-04 18:51:15 +00:00
Trenton H 00b04c2e86 Escape the secret key when writing it to the env file (#6243) 2024-04-02 03:02:27 +00:00
shamoon b3c66cae06 Fix: hide sidebar labels if group is empty (#6254) 2024-04-01 22:00:51 +00:00
shamoon 6a79d417b4 Fix: management list clear all should clear header checkbox (#6253) 2024-04-01 21:43:40 +00:00
dependabot[bot] 98ef68f720 Chore(deps): Bump the frontend-angular-dependencies group (#6248)
Bumps the frontend-angular-dependencies group in /src-ui with 17 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `17.2.1` | `17.3.2` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `17.2.3` | `17.3.2` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `17.2.3` | `17.3.2` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `17.2.3` | `17.3.2` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `17.2.3` | `17.3.2` |
| [@angular/localize](https://github.com/angular/angular) | `17.2.3` | `17.3.2` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `17.2.3` | `17.3.2` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `17.2.3` | `17.3.2` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `17.2.3` | `17.3.2` |
| [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `17.2.2` | `17.3.2` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `17.2.1` | `17.3.0` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `17.2.1` | `17.3.0` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `17.2.1` | `17.3.0` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `17.2.1` | `17.3.0` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `17.2.1` | `17.3.0` |
| [@angular/cli](https://github.com/angular/angular-cli) | `17.2.2` | `17.3.2` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `17.2.3` | `17.3.2` |


Updates `@angular/cdk` from 17.2.1 to 17.3.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/17.2.1...17.3.2)

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

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

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

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

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

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

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

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

Updates `@angular-devkit/build-angular` from 17.2.2 to 17.3.2
- [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/17.2.2...17.3.2)

Updates `@angular-eslint/builder` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/builder/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/schematics/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/schematics)

Updates `@angular-eslint/template-parser` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/template-parser/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/template-parser)

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

Updates `@angular/compiler-cli` from 17.2.3 to 17.3.2
- [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/17.3.2/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: "@angular-devkit/build-angular"
  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-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin-template"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/schematics"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/template-parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  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>
2024-04-01 14:36:48 -07:00
dependabot[bot] ed3b7aa8f2 Chore(deps-dev): Bump @playwright/test from 1.42.0 to 1.42.1 in /src-ui (#6250)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.42.0 to 1.42.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.42.0...v1.42.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 21:16:18 +00:00
dependabot[bot] e536600052 Chore(deps-dev): Bump @types/node from 20.11.24 to 20.12.2 in /src-ui (#6251)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.24 to 20.12.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-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>
2024-04-01 21:05:56 +00:00
dependabot[bot] bb820a2127 Chore(deps-dev): Bump the frontend-eslint-dependencies group (#6249)
Bumps the frontend-eslint-dependencies group in /src-ui with 2 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser).


Updates `@typescript-eslint/eslint-plugin` from 7.1.0 to 7.4.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/v7.4.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 7.1.0 to 7.4.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/v7.4.0/packages/parser)

---
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
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 13:59:08 -07:00
shamoon 129933ff30 Enhancement: support custom fields in post_document endpoint (#6222) 2024-03-30 13:00:53 -07:00
Elias Probst 41fc11efff Enhancement: add ASN to consume rejection message (#6217) 2024-03-28 19:38:29 -07:00
a712bc72ca Documentation: document_importer does not directly support zip archives (#6209)
---------

Co-authored-by: Sander <biocoder-xbmc@xs4all.nl>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2024-03-27 19:53:04 -07:00
dependabot[bot] fbe7acc6b0 Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui (#6207)
Bumps [express](https://github.com/expressjs/express) from 4.18.3 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.3...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-27 11:43:33 -05:00
Trenton H c4153b6fbf Locks DRF version to 3.14, updates other dependencies (#6200) 2024-03-27 09:13:02 -07:00
shamoon 1f355a22e0 Documentation update info about logout URL 2024-03-27 00:59:18 -05:00
shamoon 4af8070450 Feature: PDF actions - merge, split & rotate (#6094) 2024-03-25 18:41:24 -07:00
shamoon d6d0071175 Fix: start-align object names in some UI lists (#6188) 2024-03-25 10:01:54 -07:00
shamoon ef51633b2c Fix: allow scroll long upload files alerts list (#6184) 2024-03-25 07:53:29 -07:00
shamoon d4963b9cbe Fix: document_renamer fails with audit_log enabled (#6175) 2024-03-24 07:26:25 -07:00
dependabot[bot] 01dabf7c05 Chore(deps-dev): Bump webpack-dev-middleware in /src-ui (#6161)
Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-21 13:25:29 -07:00
shamoon fc68f79cc8 Enhancement: always place search term first in autocomplete results (#6142) 2024-03-21 19:03:17 +00:00
shamoon ebe1479503 Fix: catch sessionStorage errors for large documents (#6150) 2024-03-21 06:31:25 -07:00
dependabot[bot]andTrenton Holmes 8c9fe4da06 Chore(deps-dev): Bump the development group with 4 updates (#6131)
* Chore(deps-dev): Bump the development group with 4 updates

Bumps the development group with 4 updates: [black](https://github.com/psf/black), [ruff](https://github.com/astral-sh/ruff), [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material).


Updates `black` from 24.2.0 to 24.3.0
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.2.0...24.3.0)

Updates `ruff` from 0.3.2 to 0.3.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/v0.3.2...v0.3.3)

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

Updates `mkdocs-material` from 9.5.13 to 9.5.14
- [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.13...9.5.14)

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

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

* Also matches pre-commit hook versions to Pipfile

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
2024-03-21 02:09:37 +00:00
shamoon b2ef51af55 Reset dev version string 2024-03-17 22:58:35 -07:00
github-actions[bot] 32b35d8e4b Changelog v2.6.3 - GHA (#6125)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-17 21:52:09 -07:00
shamoon c8bda18cf2 Bump version to 2.6.3 2024-03-17 20:45:09 -07:00
shamoon 0a944975cc Merge branch 'dev' 2024-03-17 20:44:39 -07:00
github-actions[bot]andCrowdin Bot 48eaa31ecf New Crowdin translations by GitHub Action (#6055)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-03-17 20:44:19 -07:00
shamoon 1540e88a06 Documentation: fix small typos in file handling docs 2024-03-17 16:43:48 -07:00
shamoon b1aa57abcb Fix: workflow action reordering 2024-03-17 15:44:10 -07:00
dependabot[bot] df359730fe Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui (#6120)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-17 00:01:11 +00:00
shamoon 43ec154bc2 Fix: allow setting allauth ACCOUNT_SESSION_REMEMBER (#6105) 2024-03-14 15:34:23 +00:00
shamoon 2c4a664df4 Change: remove credentials from redis url in system status (#6104) 2024-03-14 08:20:34 -07:00
dependabot[bot]andTrenton H a196c14a58 Chore(deps-dev): Bump the development group with 3 updates (#6079)
* Chore(deps-dev): Bump the development group with 3 updates

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


Updates `ruff` from 0.3.0 to 0.3.2
- [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/v0.3.0...v0.3.2)

Updates `pytest` from 8.0.2 to 8.1.1
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.2...8.1.1)

Updates `mkdocs-material` from 9.5.12 to 9.5.13
- [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.12...9.5.13)

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

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

* Updates pre-commit hook versions and runs it against all files

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2024-03-12 07:56:01 -07:00
dependabot[bot] 6f549506d6 Chore(deps): Bump the django group with 1 update (#6080)
Bumps the django group with 1 update: [django-filter](https://github.com/carltongibson/django-filter).


Updates `django-filter` from 23.5 to 24.1
- [Release notes](https://github.com/carltongibson/django-filter/releases)
- [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst)
- [Commits](https://github.com/carltongibson/django-filter/compare/23.5...24.1)

---
updated-dependencies:
- dependency-name: django-filter
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: django
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 17:33:50 -07:00
dependabot[bot] 8d463e05ae Chore(deps): Bump the small-changes group with 2 updates (#6081)
Bumps the small-changes group with 2 updates: [rapidfuzz](https://github.com/rapidfuzz/RapidFuzz) and [redis](https://github.com/redis/redis-py).


Updates `rapidfuzz` from 3.6.1 to 3.6.2
- [Release notes](https://github.com/rapidfuzz/RapidFuzz/releases)
- [Changelog](https://github.com/rapidfuzz/RapidFuzz/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/rapidfuzz/RapidFuzz/compare/v3.6.1...v3.6.2)

Updates `redis` from 5.0.2 to 5.0.3
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v5.0.2...v5.0.3)

---
updated-dependencies:
- dependency-name: rapidfuzz
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: redis
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 13:32:28 -07:00
shamoon 373c91911d Documentation: improve permissions docs (#6046) 2024-03-11 08:20:52 -07:00
shamoon 1d3ac99c02 Merge branch 'main' into dev 2024-03-09 15:11:53 -08:00
Matt Patterson cda4c8f87e Documentation: correct bulk edit documents endpoint (#6057) 2024-03-09 15:11:09 -08:00
shamoon ef4f589094 Change: dont require empty bulk edit parameters (#6059) 2024-03-09 07:39:35 -08:00
shamoon 3aeb45bf34 Merge branch 'main' into dev 2024-03-08 17:05:33 -08:00
shamoon b91da77a8a Reset dev version string 2024-03-08 17:04:09 -08:00
github-actions[bot] 33357a3fc2 Documentation: Add v2.6.2 changelog (#6049) 2024-03-08 12:14:25 -08:00
Dimitriandshamoon 025001499d Fix: missing translation string (#6054)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2024-03-08 07:55:56 -08:00
shamoon ccd6ad9936 Bump version to 2.6.2 2024-03-07 20:13:29 -08:00
shamoon 979fcb0570 Merge branch 'dev' 2024-03-07 20:12:51 -08:00
github-actions[bot]andCrowdin Bot 58afec98f1 New Crowdin translations by GitHub Action (#6018)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-03-07 20:12:30 -08:00
shamoon 91434a5c6f Enhancement: move and rename files when storage paths deleted, update file handling docs (#6033) 2024-03-07 20:39:55 +00:00
shamoon 37c4545444 Documentation: tweak PAPERLESS_DISABLE_REGULAR_LOGIN info 2024-03-07 09:13:01 -08:00
shamoon 7b7b257725 Fix: make document count lists for objects permissions-aware (#6019) 2024-03-07 15:15:46 +00:00
shamoon 1eff4b306f Enhancement: better detection of default currency code (#6020) 2024-03-07 15:04:32 +00:00
shamoon 700af8caa2 Fix: custom logo size in Webkit (#6036) 2024-03-06 16:08:44 -08:00
Andrew Berry 90b43e154a Documentation: add additional information about Django admin remaining accessible with PAPERLESS_DISABLE_REGULAR_LOGIN (#5993) 2024-03-06 15:46:14 +00:00
shamoon a3892302b0 Fix: formatting, tiny large card rounded corners bug 2024-03-05 22:40:52 -08:00
shamoon d2ee319684 Fix: remove outdated mail rule text re. consumption templates 2024-03-05 16:26:35 -08:00
github-actions[bot] 5a6923a9aa Changelog v2.6.1 - GHA (#6014)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-05 08:42:04 -08:00
shamoon 0a634883a7 Reset dev version string 2024-03-05 08:00:32 -08:00
164 changed files with 23634 additions and 13173 deletions

No files matched your search

+3 -3
View File
@@ -47,11 +47,11 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.3.0'
rev: 'v0.3.5'
hooks:
- id: ruff
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.2.0
rev: 24.3.0
hooks:
- id: black
# Dockerfile hooks
@@ -67,6 +67,6 @@ repos:
args:
- "--tab"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: "v0.9.0.6"
rev: "v0.10.0.1"
hooks:
- id: shellcheck
+2 -2
View File
@@ -14,10 +14,10 @@ django-celery-results = "*"
django-compression-middleware = "*"
django-cors-headers = "*"
django-extensions = "*"
django-filter = "~=23.5"
django-filter = "~=24.1"
django-guardian = "*"
django-multiselectfield = "*"
djangorestframework = "~=3.14"
djangorestframework = "==3.14.0"
djangorestframework-guardian = "*"
drf-writable-nested = "*"
bleach = "*"
Generated
+415 -414
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -349,11 +349,17 @@ When you use the provided docker compose script, put the export inside
the `export` folder in your paperless source directory. Specify
`../export` as the `source`.
Note that .zip files (as can be generated from the exporter) are not supported.
!!! note
Importing from a previous version of Paperless may work, but for best
results it is suggested to match the versions.
!!! warning
The importer should be run against a completely empty installation (database and directories) of Paperless-ngx.
### Document retagger {#retagger}
Say you've imported a few hundred documents and now want to introduce a
+33 -41
View File
@@ -256,7 +256,8 @@ document. You will end up getting files like `0000123.pdf` in your media
directory. This isn't necessarily a bad thing, because you normally
don't have to access these files manually. However, if you wish to name
your files differently, you can do that by adjusting the
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option. Paperless adds the
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option
or using [storage paths (see below)](#storage-paths). Paperless adds the
correct file extension e.g. `.pdf`, `.jpg` automatically.
This variable allows you to configure the filename (folders are allowed)
@@ -289,6 +290,15 @@ will create a directory structure as follows:
paperless will report your files as missing and won't be able to find
them.
!!! tip
Paperless checks the filename of a document whenever it is saved. Changing (or deleting)
a [storage path](#storage-paths) will automatically be reflected in the file system. However,
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
[`document renamer`](administration.md#renamer) to move any existing documents.
#### Placeholders
Paperless provides the following placeholders within filenames:
- `{asn}`: The archive serial number of the document, or "none".
@@ -321,6 +331,12 @@ Paperless provides the following placeholders within filenames:
- `{original_name}`: Document original filename, minus the extension, if any, or "none"
- `{doc_pk}`: The paperless identifier (primary key) for the document.
!!! warning
When using file name placeholders, in particular when using `{tag_list}`,
you may run into the limits of your operating system's maximum path lengths.
In that case, files will retain the previous path instead and the issue logged.
Paperless will try to conserve the information from your database as
much as possible. However, some characters that you can use in document
titles and correspondent names (such as `: \ /` and a couple more) are
@@ -331,34 +347,12 @@ paperless will automatically append `_01`, `_02`, etc to the filename.
This happens if all the placeholders in a filename evaluate to the same
value.
!!! tip
You can affect how empty placeholders are treated by changing the
following setting to `true`.
```
PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=True
```
Doing this results in all empty placeholders resolving to "" instead
of "none" as stated above. Spaces before empty placeholders are
removed as well, empty directories are omitted.
!!! tip
Paperless checks the filename of a document whenever it is saved.
Therefore, you need to update the filenames of your documents and move
them after altering this setting by invoking the
[`document renamer`](administration.md#renamer).
!!! warning
Make absolutely sure you get the spelling of the placeholders right, or
else paperless will use the default naming scheme instead.
If there are any errors in the placeholders included in `PAPERLESS_FILENAME_FORMAT`,
paperless will fall back to using the default naming scheme instead.
!!! caution
As of now, you could totally tell paperless to store your files anywhere
As of now, you could potentially tell paperless to store your files anywhere
outside the media directory by setting
```
@@ -366,28 +360,25 @@ value.
```
However, keep in mind that inside docker, if files get stored outside of
the predefined volumes, they will be lost after a restart of paperless.
the predefined volumes, they will be lost after a restart.
!!! warning
##### Empty placeholders
When file naming handling, in particular when using `{tag_list}`,
you may run into the limits of your operating system's maximum
path lengths. Files will retain the previous path instead and
the issue logged.
You can affect how empty placeholders are treated by changing the
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
## Storage paths
Enabling this results in all empty placeholders resolving to "" instead of "none" as stated above. Spaces
before empty placeholders are removed as well, empty directories are omitted.
One of the best things in Paperless is that you can not only access the
documents via the web interface, but also via the file system.
### Storage paths
When a single storage layout is not sufficient for your use case,
storage paths come to the rescue. Storage paths allow you to configure
more precisely where each document is stored in the file system.
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
structure to set precisely where each document is stored in the file system.
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
follows the rules described above
- Each document is assigned a storage path using the matching
algorithms described above, but can be overwritten at any time
- Each document is assigned a storage path using the matching algorithms described above, but can be
overwritten at any time
For example, you could define the following two storage paths:
@@ -658,8 +649,9 @@ external authentication solution using one of the following methods:
This is a simple option that uses remote user authentication made available by certain SSO
applications. See the relevant configuration options for more information:
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER),
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
and [PAPERLESS_LOGOUT_REDIRECT_URL](configuration.md#PAPERLESS_LOGOUT_REDIRECT_URL)
### OpenID Connect and social authentication
+15 -1
View File
@@ -288,6 +288,8 @@ The endpoint supports the following optional form fields:
- `tags`: Similar to correspondent. Specify this multiple times to
have multiple tags added to the document.
- `archive_serial_number`: An optional archive serial number to set.
- `custom_fields`: An array of custom field ids to assign (with an empty
value) to the document.
The endpoint will immediately return HTTP 200 if the document consumption
process was started successfully, with the UUID of the consumption task
@@ -340,7 +342,7 @@ The API supports various bulk-editing operations which are executed asynchronous
### Documents
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
a json payload of the format:
```json
@@ -376,6 +378,18 @@ The following methods are supported:
- `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions.
- `merge`
- No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs.
- Optional `parameters`:
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
- `split`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
- The split operation only accepts a single document.
- `rotate`
- Requires `parameters`:
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
### Objects
+60
View File
@@ -1,5 +1,65 @@
# Changelog
## paperless-ngx 2.6.3
### Bug Fixes
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
### Dependencies
<details>
<summary>4 changes</summary>
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
</details>
### All App Changes
<details>
<summary>8 changes</summary>
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
- Change: remove credentials from redis url in system status [@shamoon](https://github.com/shamoon) ([#6104](https://github.com/paperless-ngx/paperless-ngx/pull/6104))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
- Fix: missing translation string [@DimitriDR](https://github.com/DimitriDR) ([#6054](https://github.com/paperless-ngx/paperless-ngx/pull/6054))
</details>
## paperless-ngx 2.6.2
### Features
- Enhancement: move and rename files when storage paths deleted, update file handling docs [@shamoon](https://github.com/shamoon) ([#6033](https://github.com/paperless-ngx/paperless-ngx/pull/6033))
- Enhancement: better detection of default currency code [@shamoon](https://github.com/shamoon) ([#6020](https://github.com/paperless-ngx/paperless-ngx/pull/6020))
### Bug Fixes
- Fix: make document counts in object lists permissions-aware [@shamoon](https://github.com/shamoon) ([#6019](https://github.com/paperless-ngx/paperless-ngx/pull/6019))
### All App Changes
<details>
<summary>3 changes</summary>
- Enhancement: move and rename files when storage paths deleted, update file handling docs [@shamoon](https://github.com/shamoon) ([#6033](https://github.com/paperless-ngx/paperless-ngx/pull/6033))
- Fix: make document counts in object lists permissions-aware [@shamoon](https://github.com/shamoon) ([#6019](https://github.com/paperless-ngx/paperless-ngx/pull/6019))
- Enhancement: better detection of default currency code [@shamoon](https://github.com/shamoon) ([#6020](https://github.com/paperless-ngx/paperless-ngx/pull/6020))
</details>
## paperless-ngx 2.6.1
### All App Changes
- Change: tweaks to system status [@shamoon](https://github.com/shamoon) ([#6008](https://github.com/paperless-ngx/paperless-ngx/pull/6008))
## paperless-ngx 2.6.0
### Features
+12 -8
View File
@@ -264,7 +264,7 @@ directory. See [File name handling](advanced_usage.md#file-name-handling) for de
: Tells paperless to replace placeholders in
`PAPERLESS_FILENAME_FORMAT` that would resolve to
'none' to be omitted from the resulting filename. This also holds
true for directory names. See [File name handling](advanced_usage.md#file-name-handling) for
true for directory names. See [File name handling](advanced_usage.md#empty-placeholders) for
details.
Defaults to `false` which disables this feature.
@@ -491,8 +491,9 @@ followed by the normalized actual header name.
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
: URL to redirect the user to after a logout. This can be used
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER to
redirect the user back to the SSO application's logout page.
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER and SSO to
redirect the user back to the SSO application's logout page to
complete the logout process.
Defaults to None, which disables this feature.
@@ -585,10 +586,15 @@ system. See the corresponding
#### [`PAPERLESS_DISABLE_REGULAR_LOGIN=<bool>`](#PAPERLESS_DISABLE_REGULAR_LOGIN) {#PAPERLESS_DISABLE_REGULAR_LOGIN}
: Disables the regular frontend username / password login, i.e. once you have setup SSO. Note that the Django admin login cannot be disabled.
: Disables the regular frontend username / password login, i.e. once you have setup SSO. Note that this setting does not disable the Django admin login. To prevent logins directly to Django, consider blocking `/admin/` in your [web server or reverse proxy configuration](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx).
Defaults to False
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
: See the corresponding
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
## OCR settings {#ocr}
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
@@ -1306,11 +1312,9 @@ assigns or creates tags if a properly formatted barcode is detected.
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
: Enables an audit trail for documents, document types, correspondents, and tags. Log entries can be viewed in the Django backend only.
: Enables the audit trail for documents, document types, correspondents, and tags.
!!! warning
Once enabled cannot be disabled
Defaults to true.
## Collate Double-Sided Documents {#collate}
+64 -26
View File
@@ -206,12 +206,12 @@ for details.
## Permissions
As of version 1.14.0 Paperless-ngx added core support for user / group permissions. Permissions is
based around 'global' permissions as well as 'object-level' permissions. Global permissions designate
which parts of the application a user can access (e.g. Documents, Tags, Settings) and object-level
determine which objects are visible or editable. All objects have an 'owner' and 'view' and 'edit'
permissions which can be granted to other users or groups. The paperless-ngx permissions system uses
the built-in user model of the backend framework, Django.
Permissions in Paperless-ngx are based around ['global' permissions](#global-permissions) as well as
['object-level' permissions](#object-permissions). Global permissions determine which parts of the
application a user can access (e.g. Documents, Tags, Settings) and object-level determine which
objects are visible or editable. All objects have an 'owner' and 'view' and 'edit' permissions which
can be granted to other users or groups. The paperless-ngx permissions system uses the built-in user
model of the backend framework, Django.
!!! tip
@@ -219,37 +219,63 @@ the built-in user model of the backend framework, Django.
for a Tag will _not_ affect the permissions of documents that have the Tag.
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
in the UI by selecting documents and choosing the "Permissions" button. Owner can also optionally
be set for documents uploaded via the API. Documents consumed via the consumption dir currently
do not have an owner set.
!!! note
After migration to version 1.14.0 all existing documents, tags etc. will have no explicit owner
set which means they will be visible / editable by all users. Once an object has an owner set,
only the owner can explicitly grant / revoke permissions.
!!! note
When first migrating to permissions it is recommended to use a 'superuser' account (which
would usually have been setup during installation) to ensure you have full permissions.
Note that superusers have access to all objects.
in the UI by selecting documents and choosing the "Permissions" button.
### Default permissions
Default permissions for documents can be set using workflows.
[Workflows](#workflows) provide advanced ways to control permissions.
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
as owner and no extra permissions, but you explicitly set these under Settings > Permissions.
as owner and no extra permissions, but you can explicitly set these under Settings > Permissions.
Documents consumed via the consumption directory do not have an owner or additional permissions set by default, but again, can be controlled with [Workflows](#workflows).
### Users and Groups
Paperless-ngx versions after 1.14.0 allow creating and editing users and groups via the 'frontend' UI.
These can be found under Settings > Users & Groups, assuming the user has access. If a user is designated
Paperless-ngx supports editing users and groups via the 'frontend' UI, which can be found under
Settings > Users & Groups, assuming the user has access. If a user is designated
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
!!! note
Superusers can access all parts of the front and backend application as well as any and all objects.
#### Detailed Explanation of Global Permissions {#global-permissions}
Global permissions define what areas of the app and API endpoints the user can access. For example, they
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
still have "object-level" permissions.
| Type | Details |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Admin | _View_ or higher permissions grants access to the logs view as well as the system status. |
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
| Correspondent | Grants global permissions to add, edit, delete or view Correspondents. |
| CustomField | Grants global permissions to add, edit, delete or view Custom Fields. |
| Document | Grants global permissions to add, edit, delete or view Documents. |
| DocumentType | Grants global permissions to add, edit, delete or view Document Types. |
| Group | Grants global permissions to add, edit, delete or view Groups. |
| MailAccount | Grants global permissions to add, edit, delete or view Mail Accounts. |
| MailRule | Grants global permissions to add, edit, delete or view Mail Rules. |
| Note | Grants global permissions to add, edit, delete or view Notes. |
| PaperlessTask | Grants global permissions to view or dismiss (_Change_) File Tasks. |
| SavedView | Grants global permissions to add, edit, delete or view Saved Views. |
| ShareLink | Grants global permissions to add, delete or view Share Links. |
| StoragePath | Grants global permissions to add, edit, delete or view Storage Paths. |
| Tag | Grants global permissions to add, edit, delete or view Tags. |
| UISettings | Grants global permissions to add, edit, delete or view the UI settings that are used by the web app.<br/>Users expected to access the web UI should usually be granted at least _View_ permissions. |
| User | Grants global permissions to add, edit, delete or view Users. |
| Workflow | Grants global permissions to add, edit, delete or view Workflows.<br/>Note that Workflows are global, in other words all users who can access workflows have access to the same set of them. |
#### Detailed Explanation of Object Permissions {#object-permissions}
| Type | Details |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Owner | By default objects are only visible and editable by their owner.<br/>Only the object owner can grant permissions to other users or groups.<br/>Additionally, only document owners can create share links and add / remove custom fields.<br/>For backwards compatibility objects can have no owner which makes them visible to any user. |
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see
@@ -430,6 +456,18 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
## PDF Actions
Paperless-ngx supports 3 basic editing operations for PDFs (these operations cannot be performed on non-PDF files):
- Merging documents: available when selecting multiple documents for 'bulk editing'
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
- Splitting documents: available from an individual document's details page
!!! important
Note that rotation alters the Paperless-ngx original file.
## Best practices {#basic-searching}
Paperless offers a couple tools that help you organize your document
+1 -1
View File
@@ -335,7 +335,7 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
fi
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
echo "PAPERLESS_SECRET_KEY=$SECRET_KEY"
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
fi
+404 -210
View File
File diff suppressed because it is too large. Load diff
+1052 -872
View File
File diff suppressed because it is too large. Load diff
+21 -21
View File
@@ -11,15 +11,15 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^17.2.1",
"@angular/common": "~17.2.3",
"@angular/compiler": "~17.2.3",
"@angular/core": "~17.2.3",
"@angular/forms": "~17.2.3",
"@angular/localize": "~17.2.3",
"@angular/platform-browser": "~17.2.3",
"@angular/platform-browser-dynamic": "~17.2.3",
"@angular/router": "~17.2.3",
"@angular/cdk": "^17.3.2",
"@angular/common": "~17.3.2",
"@angular/compiler": "~17.3.2",
"@angular/core": "~17.3.2",
"@angular/forms": "~17.3.2",
"@angular/localize": "~17.3.2",
"@angular/platform-browser": "~17.3.2",
"@angular/platform-browser-dynamic": "~17.3.2",
"@angular/router": "~17.3.2",
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@ng-select/ng-select": "^12.0.7",
"@ngneat/dirty-check-forms": "^3.0.3",
@@ -41,19 +41,19 @@
},
"devDependencies": {
"@angular-builders/jest": "17.0.2",
"@angular-devkit/build-angular": "~17.2.2",
"@angular-eslint/builder": "17.2.1",
"@angular-eslint/eslint-plugin": "17.2.1",
"@angular-eslint/eslint-plugin-template": "17.2.1",
"@angular-eslint/schematics": "17.2.1",
"@angular-eslint/template-parser": "17.2.1",
"@angular/cli": "~17.2.2",
"@angular/compiler-cli": "~17.2.2",
"@playwright/test": "^1.42.0",
"@angular-devkit/build-angular": "~17.3.2",
"@angular-eslint/builder": "17.3.0",
"@angular-eslint/eslint-plugin": "17.3.0",
"@angular-eslint/eslint-plugin-template": "17.3.0",
"@angular-eslint/schematics": "17.3.0",
"@angular-eslint/template-parser": "17.3.0",
"@angular/cli": "~17.3.2",
"@angular/compiler-cli": "~17.3.2",
"@playwright/test": "^1.42.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.24",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@types/node": "^20.12.2",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"jest": "29.7.0",
+4 -1
View File
@@ -76,7 +76,10 @@ const mock = () => {
let storage: { [key: string]: string } = {}
return {
getItem: (key: string) => (key in storage ? storage[key] : null),
setItem: (key: string, value: string) => (storage[key] = value || ''),
setItem: (key: string, value: string) => {
if (value.length > 1000000) throw new Error('localStorage overflow')
storage[key] = value || ''
},
removeItem: (key: string) => delete storage[key],
clear: () => (storage = {}),
}
+14
View File
@@ -116,9 +116,13 @@ import { ConfirmButtonComponent } from './components/common/confirm-button/confi
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
import { NgxFilesizeModule } from 'ngx-filesize'
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import {
airplane,
archive,
arrowClockwise,
arrowCounterclockwise,
arrowDown,
arrowLeft,
@@ -127,6 +131,7 @@ import {
arrowRightShort,
arrowUpRight,
asterisk,
bodyText,
boxArrowUp,
boxArrowUpRight,
boxes,
@@ -174,6 +179,7 @@ import {
hddStack,
house,
infoCircle,
journals,
link,
listTask,
listUl,
@@ -188,6 +194,7 @@ import {
plus,
plusCircle,
questionCircle,
scissors,
search,
slashCircle,
sliders2Vertical,
@@ -209,6 +216,7 @@ import {
const icons = {
airplane,
archive,
arrowClockwise,
arrowCounterclockwise,
arrowDown,
arrowLeft,
@@ -217,6 +225,7 @@ const icons = {
arrowRightShort,
arrowUpRight,
asterisk,
bodyText,
boxArrowUp,
boxArrowUpRight,
boxes,
@@ -264,6 +273,7 @@ const icons = {
hddStack,
house,
infoCircle,
journals,
link,
listTask,
listUl,
@@ -278,6 +288,7 @@ const icons = {
plus,
plusCircle,
questionCircle,
scissors,
search,
slashCircle,
sliders2Vertical,
@@ -458,6 +469,9 @@ function initializeApp(settings: SettingsService) {
ConfirmButtonComponent,
MonetaryComponent,
SystemStatusDialogComponent,
RotateConfirmDialogComponent,
MergeConfirmDialogComponent,
SplitConfirmDialogComponent,
],
imports: [
BrowserModule,
@@ -26,7 +26,7 @@
@for (user of users; track user) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
<div class="col">
@@ -65,7 +65,7 @@
@for (group of groups; track group) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
<div class="col"></div>
<div class="col"></div>
<div class="col">
@@ -85,14 +85,14 @@
</button>
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
<ul class="nav flex-column">
<li class="nav-item">
<li class="nav-item app-link">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="house"></i-bs><span>&nbsp;<ng-container i18n>Dashboard</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
@@ -100,9 +100,10 @@
</a>
</li>
</ul>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Saved views</span>
@if (savedViewService.loading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
@@ -111,7 +112,7 @@
}
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view) {
<li class="nav-item w-100" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
@@ -128,18 +129,18 @@
</li>
}
</ul>
</ng-container>
</div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (openDocuments.length > 0) {
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Open documents</span>
</h6>
}
<ul class="nav flex-column mb-2">
@for (d of openDocuments; track d) {
<li class="nav-item w-100">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
<li class="nav-item w-100 app-link">
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
@@ -151,8 +152,8 @@
</li>
}
@if (openDocuments.length >= 1) {
<li class="nav-item w-100">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
<li class="nav-item w-100 app-link">
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="x"></i-bs><span>&nbsp;<ng-container i18n>Close all</ng-container></span>
@@ -160,178 +161,182 @@
</li>
}
</ul>
</ng-container>
</div>
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
<span i18n>Manage</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
tourAnchor="tour.tags">
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a>
</li>
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a>
</li>
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="boxes"></i-bs><span>&nbsp;<ng-container i18n>Workflows</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="envelope"></i-bs><span>&nbsp;<ng-container i18n>Mail</ng-container></span>
</a>
</li>
</ul>
<div class="nav-group mt-3 mb-1">
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Manage</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
tourAnchor="tour.tags">
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="boxes"></i-bs><span>&nbsp;<ng-container i18n>Workflows</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="envelope"></i-bs><span>&nbsp;<ng-container i18n>Mail</ng-container></span>
</a>
</li>
</ul>
</div>
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">
<span i18n>Administration</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a>
</li>
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
}</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
}
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
<li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
</a>
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
{{ versionString }}
</a>
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<p class="small mb-2">
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p>
<div class="btn-group btn-group-xs flex-fill w-100">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
</div>
<p class="small mb-0 mt-2">
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
How does this work?
</a>
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
<div class="nav-group mt-auto mb-1">
<h6 class="sidebar-heading px-3 pt-4 text-muted">
<span i18n>Administration</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
}</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
}
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
<li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
</a>
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
{{ versionString }}
</a>
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<p class="small mb-2">
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p>
<div class="btn-group btn-group-xs flex-fill w-100">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
</div>
<p class="small mb-0 mt-2">
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
How does this work?
</a>
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
<ng-container i18n>Update available</ng-container>
}
</a>
}
} @else {
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
<ng-container i18n>Update available</ng-container>
}
</a>
}
} @else {
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</a>
}
</div>
}
</div>
</li>
</ul>
</div>
}
</div>
</li>
</ul>
</div>
</div>
</nav>
@@ -18,6 +18,10 @@
height: 0.8em;
}
.nav-group:not(:has(.app-link)) .sidebar-heading {
display: none !important;
}
// These come from the col-* classes for non-slim sidebar, needed for animation
@media (min-width: 768px) {
max-width: 25%;
@@ -0,0 +1,39 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="form-group">
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
<ul class="list-group"
cdkDropList
(cdkDropListDropped)="onDrop($event)">
@for (documentID of documentIDs; track documentID) {
<li class="list-group-item" cdkDrag>
<i-bs name="grip-vertical" class="me-2"></i-bs>
{{getDocument(documentID)?.title}}
</li>
}
</ul>
</div>
<div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select class="form-select" [(ngModel)]="metadataDocumentID">
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents; track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -0,0 +1,3 @@
.list-group-item {
cursor: move;
}
@@ -0,0 +1,73 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { MergeConfirmDialogComponent } from './merge-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
describe('MergeConfirmDialogComponent', () => {
let component: MergeConfirmDialogComponent
let fixture: ComponentFixture<MergeConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MergeConfirmDialogComponent],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
],
}).compileComponents()
fixture = TestBed.createComponent(MergeConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should fetch documents on ngOnInit', () => {
const documents = [
{ id: 1, name: 'Document 1' },
{ id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' },
]
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
component.ngOnInit()
expect(component.documents).toEqual(documents)
expect(documentService.getCachedMany).toHaveBeenCalledWith(
component.documentIDs
)
})
it('should move documentIDs on drop', () => {
component.documentIDs = [1, 2, 3]
const event = {
previousIndex: 1,
currentIndex: 2,
}
component.onDrop(event as any)
expect(component.documentIDs).toEqual([1, 3, 2])
})
it('should get document by ID', () => {
const documents = [
{ id: 1, name: 'Document 1' },
{ id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' },
]
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
component.ngOnInit()
expect(component.getDocument(2)).toEqual({ id: 2, name: 'Document 2' })
})
})
@@ -0,0 +1,51 @@
import { Component, OnInit } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
import { Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
@Component({
selector: 'pngx-merge-confirm-dialog',
templateUrl: './merge-confirm-dialog.component.html',
styleUrl: './merge-confirm-dialog.component.scss',
})
export class MergeConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
public documentIDs: number[] = []
private _documents: Document[] = []
get documents(): Document[] {
return this._documents
}
public metadataDocumentID: number = -1
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
}
ngOnInit() {
this.documentService
.getCachedMany(this.documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documents) => {
this._documents = documents
})
}
onDrop(event: CdkDragDrop<number[]>) {
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
}
getDocument(documentID: number): Document {
return this.documents.find((d) => d.id === documentID)
}
}
@@ -0,0 +1,48 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-2 d-flex justify-content-end">
<button class="btn btn-secondary mt-auto" (click)="rotate(false)">
<i-bs name="arrow-counterclockwise"></i-bs>
</button>
</div>
<div class="col-8 d-flex align-items-center">
@if (documentID) {
<img class="w-50 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
}
</div>
<div class="col-2 d-flex">
<button class="btn btn-secondary mt-auto" (click)="rotate()">
<i-bs name="arrow-clockwise"></i-bs>
</button>
</div>
</div>
<div class="row mt-4">
<div class="col">
@if (messageBold) {
<p><b>{{messageBold}}</b></p>
}
@if (message) {
<p class="mb-0" [innerHTML]="message | safeHtml"></p>
}
</div>
</div>
@if (showPDFNote) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
}
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
{{btnCaption}}
@if (!confirmButtonEnabled) {
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
}
</button>
</div>
@@ -0,0 +1,3 @@
img {
transition: all 0.25s ease;
}
@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('RotateConfirmDialogComponent', () => {
let component: RotateConfirmDialogComponent
let fixture: ComponentFixture<RotateConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RotateConfirmDialogComponent, SafeHtmlPipe],
providers: [NgbActiveModal, SafeHtmlPipe],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(RotateConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support rotating the image', () => {
component.documentID = 1
fixture.detectChanges()
component.rotate()
fixture.detectChanges()
expect(component.degrees).toBe(90)
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
'rotate(90deg)'
)
component.rotate()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
'rotate(180deg)'
)
})
it('should normalize degrees', () => {
expect(component.degrees).toBe(0)
component.rotate()
expect(component.degrees).toBe(90)
component.rotate()
expect(component.degrees).toBe(180)
component.rotate()
expect(component.degrees).toBe(270)
component.rotate()
expect(component.degrees).toBe(0)
component.rotate()
expect(component.degrees).toBe(90)
component.rotate(false)
expect(component.degrees).toBe(0)
component.rotate(false)
expect(component.degrees).toBe(270)
})
})
@@ -0,0 +1,34 @@
import { Component } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
@Component({
selector: 'pngx-rotate-confirm-dialog',
templateUrl: './rotate-confirm-dialog.component.html',
styleUrl: './rotate-confirm-dialog.component.scss',
})
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
public documentID: number
public showPDFNote: boolean = true
// animation is better if we dont normalize yet
public rotation: number = 0
public get degrees(): number {
let degrees = this.rotation % 360
if (degrees < 0) degrees += 360
return degrees
}
constructor(
activeModal: NgbActiveModal,
public documentService: DocumentService
) {
super(activeModal)
}
rotate(clockwise: boolean = true) {
this.rotation += clockwise ? 90 : -90
}
}
@@ -0,0 +1,55 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="row mb-2">
<div class="col-6">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pngx-pdf-viewer [src]="pdfSrc" [(page)]="page"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
(after-load-complete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
</div>
</div>
<div class="col-6">
<div class="d-grid">
<button class="btn btn-sm btn-primary" (click)="addSplit()">
<i-bs name="plus-circle"></i-bs>&nbsp;
<span i18n>Add Split</span>
</button>
</div>
<ul class="list-group mt-3">
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
<li class="list-group-item">
{{pageStr}}
@if (pagesString.split(',').length > 1) {
&nbsp;
<button class="btn btn-sm btn-danger" (click)="removeSplit(i)">
<i-bs name="trash"></i-bs>
</button>
}
</li>
}
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -0,0 +1,9 @@
.pdf-viewer-container {
background-color: gray;
height: 300px;
pngx-pdf-viewer {
width: 100%;
height: 100%;
}
}
@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ReactiveFormsModule, FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PdfViewerComponent } from '../../pdf-viewer/pdf-viewer.component'
describe('SplitConfirmDialogComponent', () => {
let component: SplitConfirmDialogComponent
let fixture: ComponentFixture<SplitConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SplitConfirmDialogComponent, PdfViewerComponent],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
],
}).compileComponents()
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should update pagesString when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-5')
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
})
it('should update pagesString when pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
component.removeSplit(0)
expect(component.pagesString).toEqual('1-4,5')
})
it('should enable confirm button when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.confirmButtonEnabled).toBeTruthy()
})
it('should disable confirm button when all pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.removeSplit(0)
expect(component.confirmButtonEnabled).toBeFalsy()
})
it('should not add split if page is the last page', () => {
component.totalPages = 5
component.page = 5
component.addSplit()
expect(component.pagesString).toEqual('1-5')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
})
@@ -0,0 +1,66 @@
import { Component } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PDFDocumentProxy } from '../../pdf-viewer/typings'
@Component({
selector: 'pngx-split-confirm-dialog',
templateUrl: './split-confirm-dialog.component.html',
styleUrl: './split-confirm-dialog.component.scss',
})
export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
public get pagesString(): string {
let pagesStr = ''
let lastPage = 1
for (let i = 1; i <= this.totalPages; i++) {
if (this.pages.has(i) || i === this.totalPages) {
if (lastPage === i) {
pagesStr += `${i},`
lastPage = Math.min(i + 1, this.totalPages)
} else {
pagesStr += `${lastPage}-${i},`
lastPage = Math.min(i + 1, this.totalPages)
}
}
}
return pagesStr.replace(/,$/, '')
}
private pages: Set<number> = new Set()
public documentID: number
public page: number = 1
public totalPages: number
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
this.confirmButtonEnabled = this.pages.size > 0
}
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
addSplit() {
if (this.page === this.totalPages) return
this.pages.add(this.page)
this.pages = new Set(Array.from(this.pages).sort())
this.confirmButtonEnabled = this.pages.size > 0
}
removeSplit(i: number) {
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
this.pages.delete(page)
this.confirmButtonEnabled = this.pages.size > 0
}
}
@@ -32,7 +32,6 @@
@if (showActionParamField) {
<pngx-input-text i18n-title title="Action parameter" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text>
}
<p class="small fst-italic mt-5" i18n>Assignments specified here will supersede any consumption templates.</p>
<pngx-input-select i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></pngx-input-select>
<pngx-input-tags [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
@@ -481,5 +481,8 @@ export class WorkflowEditDialogComponent
this.actionFields.insert(event.currentIndex, actionField)
// removing id will effectively re-create the actions in this order
this.object.actions.forEach((a) => (a.id = null))
this.actionFields.controls.forEach((c) =>
c.get('id').setValue(null, { emitEvent: false })
)
}
}
@@ -56,4 +56,10 @@ describe('MonetaryComponent', () => {
component.monetaryValue = 10.5
expect(component.value).toEqual('EUR10.50')
})
it('should set the default currency code based on LOCALE_ID', () => {
expect(component.defaultCurrencyCode).toEqual('USD') // default
component = new MonetaryComponent('pt-BR')
expect(component.defaultCurrencyCode).toEqual('BRL')
})
})
@@ -1,13 +1,14 @@
import {
Component,
DEFAULT_CURRENCY_CODE,
ElementRef,
forwardRef,
Inject,
LOCALE_ID,
ViewChild,
} from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
import { getLocaleCurrencyCode } from '@angular/common'
@Component({
providers: [
@@ -24,11 +25,12 @@ import { AbstractInputComponent } from '../abstract-input'
export class MonetaryComponent extends AbstractInputComponent<string> {
@ViewChild('currencyField')
currencyField: ElementRef
defaultCurrencyCode: string
constructor(
@Inject(DEFAULT_CURRENCY_CODE) public defaultCurrencyCode: string
) {
constructor(@Inject(LOCALE_ID) currentLocale: string) {
super()
this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale)
}
get currencyCode(): string {
@@ -1,5 +1,5 @@
@if (customLogo) {
<img src="{{customLogo}}" height="100%" width="100%" [attr.style]="'height:'+height" />
<img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height" />
} @else {
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" [attr.style]="'height:'+height">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
@@ -56,7 +56,7 @@
<dt i18n>Migration Status</dt>
<dd class="d-flex align-items-center">
@if (status.database.migration_status.unapplied_migrations.length === 0) {
<ng-container>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
<ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
}
@@ -1,5 +1,5 @@
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title>
<pngx-logo extra_classes="d-none d-md-block mt-n2 me-1" height="3.5rem"></pngx-logo>
<pngx-logo extra_classes="d-none d-md-block mt-n2" height="3.5rem"></pngx-logo>
</pngx-page-header>
<div class="row">
@@ -6,7 +6,7 @@
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form>
@if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40 overflow-y-scroll" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
@@ -39,3 +39,7 @@ form {
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.max-vh100-40 {
max-height: calc(100vh - 40px);
}
@@ -44,11 +44,19 @@
</button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs><span class="ps-1" i18n>Redo OCR</span>
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Redo OCR</span>
</button>
<button ngbDropdownItem (click)="moreLike()">
<i-bs width="1em" height="1em" name="diagram-3"></i-bs><span class="ps-1" i18n>More like this</span>
<i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
</button>
<button ngbDropdownItem (click)="splitDocument()" [disabled]="contentRenderType !== ContentRenderType.PDF || previewNumPages < 2">
<i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
</button>
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || contentRenderType !== ContentRenderType.PDF">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button>
</div>
</div>
@@ -75,6 +75,8 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { environment } from 'src/environments/environment'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
const doc: Document = {
id: 3,
@@ -171,6 +173,8 @@ describe('DocumentDetailComponent', () => {
ShareLinksDropdownComponent,
CustomFieldsDropdownComponent,
PdfViewerComponent,
SplitConfirmDialogComponent,
RotateConfirmDialogComponent,
],
providers: [
DocumentTitlePipe,
@@ -1070,6 +1074,58 @@ describe('DocumentDetailComponent', () => {
).not.toBeUndefined()
})
it('should support split', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.splitDocument()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.totalPages = 5
modal.componentInstance.page = 2
modal.componentInstance.addSplit()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'split',
parameters: { pages: '1-2,3-5' },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
})
it('should support rotate', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.rotateDocument()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.rotate()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'rotate',
parameters: { degrees: 90 },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
})
function initNormally() {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
@@ -67,6 +67,8 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
enum DocumentDetailNavIDs {
Details = 1,
@@ -1040,4 +1042,83 @@ export class DocumentDetailComponent
this.updateFormForCustomFields(true)
this.documentForm.updateValueAndValidity()
}
splitDocument() {
let modal = this.modalService.open(SplitConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Split confirm`
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'split', {
pages: modal.componentInstance.pagesString,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Split operation will begin in the background.`
)
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing split operation`,
error
)
},
})
})
}
rotateDocument() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Rotate confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the current document.`
modal.componentInstance.message = $localize`This will alter the original copy.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.showPDFNote = false
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'rotate', {
degrees: modal.componentInstance.degrees,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.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.`,
delay: 8000,
action: this.close.bind(this),
actionName: $localize`Close`,
})
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing rotate operation`,
error
)
},
})
})
}
}
@@ -80,18 +80,26 @@
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Permissions</ng-container></div>
</button>
</button>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<i-bs name="three-dots"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<i-bs name="three-dots"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll">
<i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Redo OCR</ng-container>
</button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanEditAll || list.selected.size < 2">
<i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll" i18n>Redo OCR</button>
</div>
</div>
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@@ -113,22 +121,16 @@
<div class="form-group ps-3 mb-2">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
<label class="form-check-label" for="downloadFileType_archive" i18n>
Archived files
</label>
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
<label class="form-check-label" for="downloadFileType_originals" i18n>
Original files
</label>
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
<label class="form-check-label" for="downloadUseFormatting" i18n>
Use formatted filename
</label>
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
</div>
</form>
</div>
@@ -52,6 +52,9 @@ import { StoragePath } from 'src/app/data/storage-path'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
const selectionData: SelectionData = {
selected_tags: [
@@ -97,6 +100,9 @@ describe('BulkEditorComponent', () => {
PermissionsGroupComponent,
PermissionsUserComponent,
SwitchComponent,
RotateConfirmDialogComponent,
IsNumberPipe,
MergeConfirmDialogComponent,
],
providers: [
PermissionsService,
@@ -818,6 +824,79 @@ describe('BulkEditorComponent', () => {
) // listAllFilteredIds
})
it('should support rotate', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
fixture.detectChanges()
component.rotateSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.rotate()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'rotate',
parameters: { degrees: 90 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should support merge', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentService, 'getCachedMany')
.mockReturnValue(of([{ id: 3 }, { id: 4 }]))
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
fixture.detectChanges()
component.mergeSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.metadataDocumentID = 3
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'merge',
parameters: { metadata_document_id: 3 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should support bulk download with archive, originals or both and file formatting', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -6,7 +6,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import {
DocumentService,
SelectionDataItem,
@@ -39,6 +39,8 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
@Component({
selector: 'pngx-bulk-editor',
@@ -192,12 +194,21 @@ export class BulkEditorComponent
this.unsubscribeNotifier.complete()
}
private executeBulkOperation(modal, method: string, args) {
private executeBulkOperation(
modal: NgbModalRef,
method: string,
args: any,
overrideDocumentIDs?: number[]
) {
if (modal) {
modal.componentInstance.buttonsEnabled = false
}
this.documentService
.bulkEdit(Array.from(this.list.selected), method, args)
.bulkEdit(
overrideDocumentIDs ?? Array.from(this.list.selected),
method,
args
)
.pipe(first())
.subscribe({
next: () => {
@@ -641,4 +652,49 @@ export class BulkEditorComponent
}
)
}
rotateSelected() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will permanently rotate ${this.list.selected.size} selected document(s).`
rotateDialog.message = $localize`This will alter the original copy.`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
rotateDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
rotateDialog.buttonsEnabled = false
this.executeBulkOperation(modal, 'rotate', {
degrees: rotateDialog.degrees,
})
})
}
mergeSelected() {
let modal = this.modalService.open(MergeConfirmDialogComponent, {
backdrop: 'static',
})
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
mergeDialog.title = $localize`Merge confirm`
mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs = Array.from(this.list.selected)
mergeDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
const args = {}
if (mergeDialog.metadataDocumentID > -1) {
args['metadata_document_id'] = mergeDialog.metadataDocumentID
}
mergeDialog.buttonsEnabled = false
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
)
})
}
}
@@ -1,4 +1,4 @@
<div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
<div class="row g-0">
<div class="col-md-2 doc-img-background rounded-start" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
@@ -12,8 +12,7 @@
</div>
<div class="col">
<div class="card-body bg-light">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title">
@if (document.correspondent) {
@@ -45,82 +44,79 @@
}
</p>
<div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<i-bs name="diagram-3"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
<i-bs name="diagram-3"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="pencil"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>
</a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<i-bs name="eye"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>View</span>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="document"></pngx-preview-popup>
</ng-template>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<i-bs name="download"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Download</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="pencil"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>
</a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<i-bs name="eye"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>View</span>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="document"></pngx-preview-popup>
</ng-template>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<i-bs name="download"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Download</span>
</a>
</div>
</div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
@if (notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small>
</button>
}
@if (document.document_type) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
</button>
}
@if (document.storage_path) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
</button>
}
@if (document.archive_serial_number | isNumber) {
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
</div>
}
<ng-template #dateTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
</div>
</ng-template>
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
</div>
@if (document.owner && document.owner !== settingsService.currentUser.id) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
</div>
}
@if (document.is_shared_by_requester) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
</div>
}
@if (document.__search_hit__?.score) {
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div>
}
</div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
@if (notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small>
</button>
}
@if (document.document_type) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
</button>
}
@if (document.storage_path) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
</button>
}
@if (document.archive_serial_number | isNumber) {
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
</div>
}
<ng-template #dateTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
</div>
</ng-template>
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
</div>
@if (document.owner && document.owner !== settingsService.currentUser.id) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
</div>
}
@if (document.is_shared_by_requester) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
</div>
}
@if (document.__search_hit__?.score) {
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="me-2 text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -23,7 +23,7 @@
@for (field of fields; track field) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
<div class="col">
<div class="btn-group">
@@ -26,7 +26,7 @@
@for (account of mailAccounts; track account) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col">
<div class="btn-group">
@@ -70,7 +70,7 @@
@for (rule of mailRules; track rule) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col">
<div class="btn-group">
@@ -30,7 +30,7 @@
<tr>
<th scope="col">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
@@ -85,6 +85,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
private _nameFilter: string
public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false
ngOnInit(): void {
this.reloadData()
@@ -268,6 +269,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
}
clearSelection() {
this.togggleAll = false
this.selectedObjects.clear()
}
@@ -25,7 +25,7 @@
@for (workflow of workflows; track workflow.id) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editWorkflow(workflow)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Workflow)">{{workflow.name}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editWorkflow(workflow)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Workflow)">{{workflow.name}}</button></div>
<div class="col d-flex align-items-center"><code>{{workflow.order}}</code></div>
<div class="col d-flex align-items-center"><code> @if(workflow.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code></div>
<div class="col d-flex align-items-center">{{getTypesList(workflow)}}</div>
@@ -5,10 +5,11 @@ import {
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
import { Subscription } from 'rxjs'
import { Subscription, throwError } from 'rxjs'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
import { OPEN_DOCUMENT_SERVICE } from '../data/storage-keys'
import { wind } from 'ngx-bootstrap-icons'
const documents = [
{
@@ -233,4 +234,12 @@ describe('OpenDocumentsService', () => {
req.error(new ErrorEvent('timeout'))
expect(openDocumentsService.getOpenDocuments()).toHaveLength(0)
})
it('should log error on sessionStorage save', () => {
const doc = { ...documents[0] }
doc.content = 'a'.repeat(1000000)
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
openDocumentsService.openDocument(doc)
expect(consoleSpy).toHaveBeenCalled()
})
})
@@ -152,9 +152,13 @@ export class OpenDocumentsService {
}
save() {
sessionStorage.setItem(
OPEN_DOCUMENT_SERVICE.DOCUMENTS,
JSON.stringify(this.openDocuments)
)
try {
sessionStorage.setItem(
OPEN_DOCUMENT_SERVICE.DOCUMENTS,
JSON.stringify(this.openDocuments)
)
} catch (e) {
console.error('Error saving open documents to session storage', e)
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '5',
appTitle: 'Paperless-ngx',
version: '2.6.1',
version: '2.7.0',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -155,7 +155,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
}
}
.row-cols-paperless-cards .card {
.row-cols-paperless-cards .card, pngx-document-card-large .card {
--bs-border-color-translucent: rgba(0, 0, 0, .3);
}
+146
View File
@@ -1,15 +1,27 @@
import hashlib
import itertools
import logging
import os
from typing import Optional
from celery import chord
from django.conf import settings
from django.db.models import Q
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.models import Correspondent
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.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import update_document_archive_file
logger = logging.getLogger("paperless.bulk_edit")
def set_correspondent(doc_ids, correspondent):
if correspondent:
@@ -146,3 +158,137 @@ def set_permissions(doc_ids, set_permissions, owner=None, merge=False):
bulk_update_documents.delay(document_ids=affected_docs)
return "OK"
def rotate(doc_ids: list[int], degrees: int):
logger.info(
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
)
qs = Document.objects.filter(id__in=doc_ids)
affected_docs = []
import pikepdf
rotate_tasks = []
for doc in qs:
if doc.mime_type != "application/pdf":
logger.warning(
f"Document {doc.id} is not a PDF, skipping rotation.",
)
continue
try:
with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf:
for page in pdf.pages:
page.rotate(degrees, relative=True)
pdf.save()
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
doc.save()
rotate_tasks.append(
update_document_archive_file.s(
document_id=doc.id,
),
)
logger.info(
f"Rotated document {doc.id} by {degrees} degrees",
)
affected_docs.append(doc.id)
except Exception as e:
logger.exception(f"Error rotating document {doc.id}: {e}")
if len(affected_docs) > 0:
bulk_update_task = bulk_update_documents.s(document_ids=affected_docs)
chord(header=rotate_tasks, body=bulk_update_task).delay()
return "OK"
def merge(doc_ids: list[int], metadata_document_id: Optional[int] = None):
logger.info(
f"Attempting to merge {len(doc_ids)} documents into a single document.",
)
qs = Document.objects.filter(id__in=doc_ids)
affected_docs = []
import pikepdf
merged_pdf = pikepdf.new()
version = merged_pdf.pdf_version
# use doc_ids to preserve order
for doc_id in doc_ids:
doc = qs.get(id=doc_id)
try:
with pikepdf.open(str(doc.source_path)) as pdf:
version = max(version, pdf.pdf_version)
merged_pdf.pages.extend(pdf.pages)
affected_docs.append(doc.id)
except Exception as e:
logger.exception(
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
)
if len(affected_docs) == 0:
logger.warning("No documents were merged")
return "OK"
filepath = os.path.join(
settings.SCRATCH_DIR,
f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf",
)
merged_pdf.remove_unreferenced_resources()
merged_pdf.save(filepath, min_version=version)
merged_pdf.close()
if metadata_document_id:
metadata_document = qs.get(id=metadata_document_id)
if metadata_document is not None:
overrides = DocumentMetadataOverrides.from_document(metadata_document)
overrides.title = metadata_document.title + " (merged)"
else:
overrides = DocumentMetadataOverrides()
logger.info("Adding merged document to the task queue.")
consume_file.delay(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
),
overrides,
)
return "OK"
def split(doc_ids: list[int], pages: list[list[int]]):
logger.info(
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
)
doc = Document.objects.get(id=doc_ids[0])
import pikepdf
try:
with pikepdf.open(doc.source_path) as pdf:
for idx, split_doc in enumerate(pages):
dst = pikepdf.new()
for page in split_doc:
dst.pages.append(pdf.pages[page - 1])
filepath = os.path.join(
settings.SCRATCH_DIR,
f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf",
)
dst.remove_unreferenced_resources()
dst.save(filepath)
dst.close()
overrides = DocumentMetadataOverrides().from_document(doc)
overrides.title = f"{doc.title} (split {idx + 1})"
logger.info(
f"Adding split document with pages {split_doc} to the task queue.",
)
consume_file.delay(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
),
overrides,
)
except Exception as e:
logger.exception(f"Error splitting document {doc.id}: {e}")
return "OK"
+13 -5
View File
@@ -189,13 +189,21 @@ def refresh_metadata_cache(
cache.touch(doc_key, timeout)
def clear_metadata_cache(document_id: int) -> None:
doc_key = get_metadata_cache_key(document_id)
cache.delete(doc_key)
def get_thumbnail_modified_key(document_id: int) -> str:
"""
Builds the key to store a thumbnail's timestamp
"""
return f"doc_{document_id}_thumbnail_modified"
def clear_document_caches(document_id: int) -> None:
"""
Removes all cached items for the given document
"""
cache.delete_many(
[
get_suggestion_cache_key(document_id),
get_metadata_cache_key(document_id),
get_thumbnail_modified_key(document_id),
],
)
+2 -8
View File
@@ -237,14 +237,8 @@ class DocumentClassifier:
num_storage_paths = len(set(labels_storage_path) | {-1}) - 1
logger.debug(
"{} documents, {} tag(s), {} correspondent(s), "
"{} document type(s). {} storage path(es)".format(
docs_queryset.count(),
num_tags,
num_correspondents,
num_document_types,
num_storage_paths,
),
f"{docs_queryset.count()} documents, {num_tags} tag(s), {num_correspondents} correspondent(s), "
f"{num_document_types} document type(s). {num_storage_paths} storage path(es)",
)
from sklearn.feature_extraction.text import CountVectorizer
+10 -56
View File
@@ -5,8 +5,6 @@ import tempfile
import uuid
from enum import Enum
from pathlib import Path
from subprocess import CompletedProcess
from subprocess import run
from typing import TYPE_CHECKING
from typing import Optional
@@ -51,6 +49,7 @@ from documents.signals import document_consumption_finished
from documents.signals import document_consumption_started
from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess
class WorkflowTriggerPlugin(
@@ -368,7 +367,7 @@ class Consumer(LoggingMixin):
if Document.objects.filter(archive_serial_number=self.override_asn).exists():
self._fail(
ConsumerStatusShortMessage.ASN_ALREADY_EXISTS,
f"Not consuming {self.filename}: Given ASN already exists!",
f"Not consuming {self.filename}: Given ASN {self.override_asn} already exists!",
)
def run_pre_consume_script(self):
@@ -397,20 +396,15 @@ class Consumer(LoggingMixin):
script_env["TASK_ID"] = self.task_id or ""
try:
completed_proc = run(
args=[
run_subprocess(
[
settings.PRE_CONSUME_SCRIPT,
original_file_path,
],
env=script_env,
capture_output=True,
script_env,
self.log,
)
self._log_script_outputs(completed_proc)
# Raises exception on non-zero output
completed_proc.check_returncode()
except Exception as e:
self._fail(
ConsumerStatusShortMessage.PRE_CONSUME_SCRIPT_ERROR,
@@ -468,8 +462,8 @@ class Consumer(LoggingMixin):
script_env["TASK_ID"] = self.task_id or ""
try:
completed_proc = run(
args=[
run_subprocess(
[
settings.POST_CONSUME_SCRIPT,
str(document.pk),
document.get_public_filename(),
@@ -480,15 +474,10 @@ class Consumer(LoggingMixin):
str(document.correspondent),
str(",".join(document.tags.all().values_list("name", flat=True))),
],
env=script_env,
capture_output=True,
script_env,
self.log,
)
self._log_script_outputs(completed_proc)
# Raises exception on non-zero output
completed_proc.check_returncode()
except Exception as e:
self._fail(
ConsumerStatusShortMessage.POST_CONSUME_SCRIPT_ERROR,
@@ -929,41 +918,6 @@ class Consumer(LoggingMixin):
except Exception: # pragma: no cover
pass
def _log_script_outputs(self, completed_process: CompletedProcess):
"""
Decodes a process stdout and stderr streams and logs them to the main log
"""
# Log what the script exited as
self.log.info(
f"{completed_process.args[0]} exited {completed_process.returncode}",
)
# Decode the output (if any)
if len(completed_process.stdout):
stdout_str = (
completed_process.stdout.decode("utf8", errors="ignore")
.strip()
.split(
"\n",
)
)
self.log.info("Script stdout:")
for line in stdout_str:
self.log.info(line)
if len(completed_process.stderr):
stderr_str = (
completed_process.stderr.decode("utf8", errors="ignore")
.strip()
.split(
"\n",
)
)
self.log.warning("Script stderr:")
for line in stderr_str:
self.log.warning(line)
def parse_doc_title_w_placeholders(
title: str,
+2 -2
View File
@@ -1,5 +1,4 @@
from pathlib import Path
from subprocess import run
import img2pdf
from django.conf import settings
@@ -7,6 +6,7 @@ from PIL import Image
from documents.utils import copy_basic_file_stats
from documents.utils import maybe_override_pixel_limit
from documents.utils import run_subprocess
def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
@@ -27,7 +27,7 @@ def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
# Note the save into the temp folder, so as not to trigger a new
# consume
scratch_image = target_directory / tiff_path.name
run(
run_subprocess(
[
settings.CONVERT_BINARY,
"-alpha",
+40
View File
@@ -5,6 +5,8 @@ from pathlib import Path
from typing import Optional
import magic
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
@dataclasses.dataclass
@@ -88,6 +90,44 @@ class DocumentMetadataOverrides:
return self
@staticmethod
def from_document(doc) -> "DocumentMetadataOverrides":
"""
Fills in the overrides from a document object
"""
overrides = DocumentMetadataOverrides()
overrides.title = doc.title
overrides.correspondent_id = doc.correspondent.id if doc.correspondent else None
overrides.document_type_id = doc.document_type.id if doc.document_type else None
overrides.storage_path_id = doc.storage_path.id if doc.storage_path else None
overrides.owner_id = doc.owner.id if doc.owner else None
overrides.tag_ids = list(doc.tags.values_list("id", flat=True))
overrides.view_users = get_users_with_perms(
doc,
only_with_perms_in=["view_document"],
).values_list("id", flat=True)
overrides.change_users = get_users_with_perms(
doc,
only_with_perms_in=["change_document"],
).values_list("id", flat=True)
overrides.custom_field_ids = list(
doc.custom_fields.values_list("id", flat=True),
)
groups_with_perms = get_groups_with_perms(
doc,
attach_perms=True,
)
overrides.view_groups = [
group.id for group, perms in groups_with_perms if "view_document" in perms
]
overrides.change_groups = [
group.id for group, perms in groups_with_perms if "change_document" in perms
]
return overrides
class DocumentSource(IntEnum):
"""
+6 -2
View File
@@ -468,10 +468,14 @@ def autocomplete(
termCounts = Counter()
if results.has_matched_terms():
for hit in results:
for _, term in hit.matched_terms():
termCounts[term] += 1
for _, match in hit.matched_terms():
termCounts[match] += 1
terms = [t for t, _ in termCounts.most_common(limit)]
term_encoded = term.encode("UTF-8")
if term_encoded in terms:
terms.insert(0, terms.pop(terms.index(term_encoded)))
return terms
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more