Compare commits

...

99 Commits

Author SHA1 Message Date
shamoon
7a07f1e81d
Remove unnecessary check 2025-03-21 10:28:20 -07:00
shamoon
92524ae97a
Chore: remove a couple of console logs 2025-03-21 10:24:59 -07:00
shamoon
1c89f6da24
More narrow device tweaks 2025-03-21 10:24:59 -07:00
shamoon
d1a3e3b859
Fix: top nav layout with custom title on very narrow screens 2025-03-21 10:24:58 -07:00
shamoon
79ae594d54
Fix: fix saving docs with notes 2025-03-21 10:24:58 -07:00
github-actions[bot]
97fe5c4176
New Crowdin translations by GitHub Action (#9438)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-21 09:52:32 -07:00
shamoon
e60bd3a132
Fix: fix auto-close when doc update no longer has permissions (#9453) 2025-03-21 09:50:04 -07:00
github-actions[bot]
5d6cfa7349
New Crowdin translations by GitHub Action (#9430)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-18 19:17:49 -07:00
shamoon
3105317137
Fix: close email dialog after email send 2025-03-18 19:17:49 -07:00
github-actions[bot]
1456169d7f
New Crowdin translations by GitHub Action (#9408)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-17 23:36:52 -07:00
shamoon
22a6fe5e10
Enhancement: support more 'not assigned' filtering, refactor (#9429) 2025-03-17 23:35:03 -07:00
github-actions[bot]
a0c1a19263
New Crowdin translations by GitHub Action (#9401) 2025-03-14 10:30:45 -07:00
github-actions[bot]
1f5d1b6f26
New Crowdin translations by GitHub Action (#8929)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-14 17:22:24 +00:00
shamoon
3b19a727b8
Change: better handle permissions in patch requests (#9393) 2025-03-14 08:53:00 -07:00
shamoon
7146a5f4fc
Fix: use correct filename with webhook (#9392) 2025-03-14 07:44:40 -07:00
shamoon
6babc61ba2
Change: sync OIDC groups on first login too (#9387) 2025-03-13 07:19:34 -07:00
shamoon
db5e54c6e5
Fix: only parse custom field queries when valid (#9384) 2025-03-12 17:06:02 -07:00
shamoon
4ef5fbfb6e
Fix: remove deprecated debug x-frame-options 2025-03-12 17:02:55 -07:00
shamoon
b0390a92ea Update backend translation strings 2025-03-11 13:34:27 -07:00
shamoon
70898a5064 Fix: fix tag track expression in lists 2025-03-11 13:33:40 -07:00
shamoon
d07d425618 Fix: fix tag size in lists 2025-03-11 13:31:49 -07:00
dependabot[bot]
1f60f4a636
Chore(deps-dev): Bump the frontend-eslint-dependencies group (#9372)
Bumps the frontend-eslint-dependencies group in /src-ui with 4 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) and [eslint](https://github.com/eslint/eslint).


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

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

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

Updates `eslint` from 9.21.0 to 9.22.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.21.0...v9.22.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 19:48:13 +00:00
dependabot[bot]
b29395cde7
Chore(deps): Bump the frontend-angular-dependencies group (#9371)
Bumps the frontend-angular-dependencies group in /src-ui with 20 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `19.2.1` | `19.2.2` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `19.2.0` | `19.2.1` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `19.2.0` | `19.2.1` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `19.2.0` | `19.2.1` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `19.2.0` | `19.2.1` |
| [@angular/localize](https://github.com/angular/angular) | `19.2.0` | `19.2.1` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `19.2.0` | `19.2.1` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `19.2.0` | `19.2.1` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `19.2.0` | `19.2.1` |
| [@ng-select/ng-select](https://github.com/ng-select/ng-select) | `14.2.2` | `14.2.3` |
| [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `19.2.0` | `19.2.1` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `19.2.0` | `19.2.1` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `19.2.0` | `19.2.1` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `19.2.0` | `19.2.1` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `19.2.0` | `19.2.1` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `19.2.0` | `19.2.1` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `19.2.0` | `19.2.1` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `19.2.0` | `19.2.1` |
| [@angular/cli](https://github.com/angular/angular-cli) | `19.2.0` | `19.2.1` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `19.2.0` | `19.2.1` |


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

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

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

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

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

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

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

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

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

Updates `@ng-select/ng-select` from 14.2.2 to 14.2.3
- [Release notes](https://github.com/ng-select/ng-select/releases)
- [Changelog](https://github.com/ng-select/ng-select/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ng-select/ng-select/compare/v14.2.2...v14.2.3)

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 12:38:08 -07:00
shamoon
2f70d58219
Development: change frontend package manager to pnpm (#9363) 2025-03-11 17:59:44 +00:00
Trenton H
9944f81512
Fix: Allow setting of other Granian options (#9360) 2025-03-11 17:33:56 +00:00
Trenton H
f4413e0a08
Fix: Always clean up INotify (#9359) 2025-03-11 10:01:00 -07:00
shamoon
169aa8c8bd
Merge pull request #9362 from paperless-ngx/empty-dashboard-hint
Tweak: add saved views hint to dashboard
2025-03-11 00:41:40 -07:00
shamoon
94556a2607
Tweak: add saved views hint to dashboard 2025-03-11 00:37:07 -07:00
shamoon
dcd50d5359
Fix: one more february relative date fix 2025-03-11 00:35:18 -07:00
dependabot[bot]
376823598e
Chore(deps): Update ocrmypdf requirement from ~=16.9.0 to ~=16.10.0 (#9348)
* Chore(deps): Update ocrmypdf requirement from ~=16.9.0 to ~=16.10.0

Updates the requirements on [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF) to permit the latest version.
- [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases)
- [Changelog](https://github.com/ocrmypdf/OCRmyPDF/blob/main/docs/release_notes.rst)
- [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v16.9.0...v16.10.0)

---
updated-dependencies:
- dependency-name: ocrmypdf
  dependency-type: direct:production
...

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

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>
2025-03-10 17:37:33 +00:00
dependabot[bot]
54bcbfa546
Chore(deps): Update drf-spectacular-sidecar requirement from ~=2025.2.1 to ~=2025.3.1 (#9347)
* Chore(deps): Update drf-spectacular-sidecar requirement

Updates the requirements on [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) to permit the latest version.
- [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2025.2.1...2025.3.1)

---
updated-dependencies:
- dependency-name: drf-spectacular-sidecar
  dependency-type: direct:production
...

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

* Updates uv.lock

---------

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>
2025-03-10 17:17:59 +00:00
dependabot[bot]
5421e54cb0
Chore(deps): Bump the small-changes group with 2 updates (#9345)
* Chore(deps): Bump the small-changes group with 2 updates

Bumps the small-changes group with 2 updates: [psycopg[c]](https://github.com/psycopg/psycopg) and [psycopg-c](https://github.com/psycopg/psycopg).


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

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

---
updated-dependencies:
- dependency-name: psycopg[c]
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: psycopg-c
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
...

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

* Builds the latest psycopg

---------

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>
2025-03-10 17:01:18 +00:00
dependabot[bot]
032bada221
docker-compose(deps): Bump library/postgres in /docker/compose (#9353)
Bumps library/postgres from 16 to 17.

---
updated-dependencies:
- dependency-name: library/postgres
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:37:53 -07:00
Trenton H
670ee6c5b0
Chore: Ensure the directories have been overridden and created for this test (#9354) 2025-03-10 15:49:35 +00:00
dependabot[bot]
309fb199f2
docker(deps): Bump astral-sh/uv (#9344)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.6.3-python3.12-bookworm-slim to 0.6.5-python3.12-bookworm-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.6.3...0.6.5)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 08:39:49 -07:00
Dominik Schilling
79328b1cec
Fix typo in inactive account template (#9356) 2025-03-10 06:38:00 -07:00
Trenton Holmes
5570d20625 Fixes the package ecosystem 2025-03-09 19:46:34 -07:00
Trenton H
ba2cb1dec8
Chore: Enables dependabot for Dockerfile and our Compose files (#9342) 2025-03-09 19:43:07 -07:00
shamoon
4d15544a3e
Fix: fix notes serializing in API document response (#9336) 2025-03-09 23:04:05 +00:00
shamoon
955ff32dcd Fix: fix improved close behavior with production minified javascript 2025-03-08 19:17:08 -08:00
shamoon
b746b6f2d6 Chore: prefix codecov flags 2025-03-07 17:08:36 -08:00
shamoon
b4b0f802e1
Fix: correct all results with whoosh queries (#9331) 2025-03-07 13:31:23 -08:00
Trenton H
5f16d5f5f1
Chore: Adds an option for funding through GitHub sponsors (#9328) 2025-03-07 17:39:23 +00:00
shamoon
8db04398c7
Chore: ensure codecov upload gets attempted (#9308)
---------

Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2025-03-07 09:29:55 -08:00
Sebastian Steinbeißer
485dad01b7
Bug fix: typo in migration file (#9321) 2025-03-06 19:32:54 -08:00
shamoon
cf48f47a8c
Fix: add account_inactive template / url (#9322) 2025-03-06 14:55:04 -08:00
shamoon
f2667f5afa Update backend translation strings 2025-03-06 14:43:29 -08:00
shamoon
d73118d226
Fix: fix error on open document button 2025-03-05 22:38:25 -08:00
shamoon
dd9e9a8c56
Fix: use --group dev instead of --dev for devcontainer (#9309) 2025-03-05 20:00:02 -08:00
Sebastian Steinbeißer
76d363f22d
Chore: switch from os.path to pathlib.Path (#9060) 2025-03-05 21:06:01 +00:00
shamoon
aaaa6c1393
Enhancement: reorganize dates dropdown, add more relative options (#9307) 2025-03-05 12:48:42 -08:00
shamoon
bed82215a0
Chore: remove popper preventOverflow fix (#9306) 2025-03-05 12:47:21 -08:00
shamoon
f8aaa5cb32 Chore: remove error on upload steps after jest tests 2025-03-05 12:47:05 -08:00
shamoon
1e489a0666
Enhancement: add switch to allow merging non-PDFs with archive version (#9305) 2025-03-05 20:46:51 +00:00
shamoon
edc7181843
Enhancement: support assigning custom field values in workflows (#9272) 2025-03-05 12:30:19 -08:00
shamoon
89e5c08a1f
Chore: add codecov frontend test results (#9296) 2025-03-04 22:57:29 +00:00
Trenton H
0faa9e8865
Chore: Split out some items into extras (#9297) 2025-03-04 22:16:09 +00:00
Trenton H
f205c4d0e2
Removes undocumented FileInfo (#9298) 2025-03-04 13:49:47 -08:00
dependabot[bot]
344b2bc0eb
Chore(deps-dev): Bump the frontend-angular-dependencies group in /src-ui with 5 updates (#9288)
* Chore(deps-dev): Bump the frontend-angular-dependencies group

Bumps the frontend-angular-dependencies group in /src-ui with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `19.1.0` | `19.2.0` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `19.1.0` | `19.2.0` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `19.1.0` | `19.2.0` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `19.1.0` | `19.2.0` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `19.1.0` | `19.2.0` |


Updates `@angular-eslint/builder` from 19.1.0 to 19.2.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/v19.2.0/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 19.1.0 to 19.2.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/v19.2.0/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 19.1.0 to 19.2.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/v19.2.0/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 19.1.0 to 19.2.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/v19.2.0/packages/schematics)

Updates `@angular-eslint/template-parser` from 19.1.0 to 19.2.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/v19.2.0/packages/template-parser)

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

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

* Fix lint error on toast close output name

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-03-04 19:09:38 +00:00
Trenton H
817aad7c8b
Fix: Switches data to content to upload raw bytes/text content (#9293) 2025-03-04 18:56:28 +00:00
Trenton H
d82555e644
Enables Codecov test reporting for the backend (#9295) 2025-03-04 18:38:06 +00:00
Trenton H
f3e6ed56b9
Removes the unused Log model and LogFilterSet (#9294) 2025-03-04 18:26:25 +00:00
Trenton H
780d1c67e9
Chore: Combine Python settings files (#9292) 2025-03-04 17:19:47 +00:00
dependabot[bot]
2b72397a4d
Chore(deps-dev): Bump @types/node from 22.13.8 to 22.13.9 in /src-ui (#9290)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.13.8 to 22.13.9.
- [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-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-04 17:03:07 +00:00
dependabot[bot]
6c13ffaa01
Chore(deps-dev): Bump the frontend-eslint-dependencies group (#9289)
Bumps the frontend-eslint-dependencies group in /src-ui with 3 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils).


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-04 08:53:29 -08:00
Trenton H
eb8e124971
Chore: Switch from pipenv to uv (#9251) 2025-03-04 16:15:51 +00:00
Harold Waterkeyn
1bc77546eb
Feature: Add slugify filter in templating (#9269) 2025-03-03 08:20:04 -08:00
shamoon
5a453653e2
Fix: random visual tweaks / fixes to settings 2025-03-02 17:59:05 -08:00
shamoon
16f17829b6
Fix: handle null workflow body and email subject (#9271) 2025-03-01 15:44:52 -08:00
dependabot[bot]
3cf1c04a83
Chore(deps-dev): Bump @types/node from 22.13.5 to 22.13.8 in /src-ui (#9267)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.13.5 to 22.13.8.
- [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-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-01 20:36:55 +00:00
shamoon
bc90ccc555 Fix: fix date pipe test for day after February 2025-03-01 09:54:03 -08:00
dependabot[bot]
90a332a02c
Chore(deps): Bump stumpylog/image-cleaner-action in the actions group (#9252)
Bumps the actions group with 1 update: [stumpylog/image-cleaner-action](https://github.com/stumpylog/image-cleaner-action).


Updates `stumpylog/image-cleaner-action` from 0.9.0 to 0.10.0
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/v0.9.0...v0.10.0)

---
updated-dependencies:
- dependency-name: stumpylog/image-cleaner-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-28 21:58:56 +00:00
dependabot[bot]
0098d1bdd5
Chore(deps-dev): Bump the development group with 2 updates (#9253)
* Chore(deps-dev): Bump the development group with 2 updates

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


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

Updates `mkdocs-material` from 9.6.4 to 9.6.5
- [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.6.4...9.6.5)

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

* Update .pre-commit-config.yaml

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-02-28 21:38:18 +00:00
dependabot[bot]
f6fef18a73
Chore(deps-dev): Bump @codecov/webpack-plugin in /src-ui (#9260)
Bumps @codecov/webpack-plugin from 1.8.0 to 1.9.0.

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

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


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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-28 21:20:07 +00:00
dependabot[bot]
755cf8619f
Chore(deps): Bump uuid from 11.0.5 to 11.1.0 in /src-ui (#9259)
Bumps [uuid](https://github.com/uuidjs/uuid) from 11.0.5 to 11.1.0.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v11.0.5...v11.1.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-28 21:10:40 +00:00
dependabot[bot]
c6d389100c
Chore(deps-dev): Bump jest-preset-angular (#9255)
Bumps the frontend-jest-dependencies group in /src-ui with 1 update: [jest-preset-angular](https://github.com/thymikee/jest-preset-angular).


Updates `jest-preset-angular` from 14.5.1 to 14.5.3
- [Release notes](https://github.com/thymikee/jest-preset-angular/releases)
- [Changelog](https://github.com/thymikee/jest-preset-angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/thymikee/jest-preset-angular/compare/v14.5.1...v14.5.3)

---
updated-dependencies:
- dependency-name: jest-preset-angular
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-jest-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-28 20:59:13 +00:00
dependabot[bot]
20c4b65273
Chore(deps): Bump rxjs from 7.8.1 to 7.8.2 in /src-ui (#9258)
Bumps [rxjs](https://github.com/reactivex/rxjs) from 7.8.1 to 7.8.2.
- [Release notes](https://github.com/reactivex/rxjs/releases)
- [Changelog](https://github.com/ReactiveX/rxjs/blob/7.8.2/CHANGELOG.md)
- [Commits](https://github.com/reactivex/rxjs/compare/7.8.1...7.8.2)

---
updated-dependencies:
- dependency-name: rxjs
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-28 20:45:37 +00:00
dependabot[bot]
86c94c7508
Chore(deps-dev): Bump @types/node from 22.13.0 to 22.13.5 in /src-ui (#9257)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.13.0 to 22.13.5.
- [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-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-28 20:29:22 +00:00
dependabot[bot]
798ece411e
Chore(deps): Bump the frontend-angular-dependencies group (#9254)
Bumps the frontend-angular-dependencies group in /src-ui with 22 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `19.1.2` | `19.2.1` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `19.1.4` | `19.2.0` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `19.1.4` | `19.2.0` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `19.1.4` | `19.2.0` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `19.1.4` | `19.2.0` |
| [@angular/localize](https://github.com/angular/angular) | `19.1.4` | `19.2.0` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `19.1.4` | `19.2.0` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `19.1.4` | `19.2.0` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `19.1.4` | `19.2.0` |
| [@ng-select/ng-select](https://github.com/ng-select/ng-select) | `14.2.0` | `14.2.2` |
| [ngx-color](https://github.com/scttcper/ngx-color) | `9.0.0` | `10.0.0` |
| [ngx-cookie-service](https://github.com/stevermeister/ngx-cookie-service) | `19.1.0` | `19.1.2` |
| [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `19.1.5` | `19.2.0` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `19.1.5` | `19.2.0` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `19.1.5` | `19.2.0` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `19.0.2` | `19.1.0` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `19.0.2` | `19.1.0` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `19.0.2` | `19.1.0` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `19.0.2` | `19.1.0` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `19.0.2` | `19.1.0` |
| [@angular/cli](https://github.com/angular/angular-cli) | `19.1.5` | `19.2.0` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `19.1.4` | `19.2.0` |


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

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

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

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

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

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

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

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

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

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

Updates `ngx-color` from 9.0.0 to 10.0.0
- [Release notes](https://github.com/scttcper/ngx-color/releases)
- [Commits](https://github.com/scttcper/ngx-color/compare/v9.0.0...v10.0.0)

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

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

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

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

Updates `@angular-eslint/builder` from 19.0.2 to 19.1.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/v19.1.0/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 19.0.2 to 19.1.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/v19.1.0/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 19.0.2 to 19.1.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/v19.1.0/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 19.0.2 to 19.1.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/v19.1.0/packages/schematics)

Updates `@angular-eslint/template-parser` from 19.0.2 to 19.1.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/v19.1.0/packages/template-parser)

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

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

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser-dynamic"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@ng-select/ng-select"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: ngx-color
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-angular-dependencies
- dependency-name: ngx-cookie-service
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/build-angular"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/core"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/schematics"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/builder"
  dependency-type: direct:development
  update-type: version-update:semver-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>
2025-02-28 12:15:22 -08:00
Trenton H
654c9ca273
Feature: Switch webserver to granian (#9218)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-02-28 19:37:45 +00:00
shamoon
628d85080f Fix: obsessing over pixels 2025-02-27 20:22:54 -08:00
shamoon
865e9fe233
Enhancement: relocate and smaller upload widget, dont limit upload list (#9244) 2025-02-27 13:13:36 -08:00
shamoon
0eb765c3e8 Fix: small frontend css class fixes, remove unused component 2025-02-27 10:28:44 -08:00
shamoon
ddeb741a85
Remove one more logger call 2025-02-27 00:01:11 -08:00
Trenton H
b9bcff22f8 Removes leftover logging calls 2025-02-26 14:55:40 -08:00
shamoon
2d52226732
Enhancement: system status report sanity check, simpler classifier check, styling updates (#9106) 2025-02-26 22:12:20 +00:00
Trenton H
ec34197b59
Chore: Switch remote version check to HTTPx (#9232) 2025-02-26 12:37:33 -08:00
shamoon
edc0e6f859
Fix: cleanup saved view references on custom field deletion, auto-refresh views, show error on saved view save (#9225) 2025-02-26 10:09:41 -08:00
shamoon
61cb5103ed
Fix: prune invalid custom fields (#9224) 2025-02-25 13:50:15 -08:00
shamoon
d364436817
Fix: fix safari thumbnails again (#9219) 2025-02-24 17:40:45 -08:00
Trenton H
827fcba277
Chore: Reduce imports for a slight memory improvement (#9217) 2025-02-24 15:06:14 -08:00
shamoon
3104417076
Enhancement: include celery log in logs view (#9214) 2025-02-24 12:51:52 -08:00
shamoon
047f7c3619
Enhancement: support default groups for regular and social account signup (#9039) 2025-02-24 09:23:20 -08:00
shamoon
a548c32c1f
Enhancement: allow disabling the filesystem consumer (#9199) 2025-02-23 13:52:41 -08:00
shamoon
ea911e73c6
Fix: correct split confirm removal (#9195) 2025-02-22 07:27:44 -08:00
Max Mehl
6b7fb286f7
Chore: bump gotenberg docker images (#9189)
* Chore: update gotenberg Docker images to latest minor version

* Chore: update gotenberg Docker images to latest minor version for devcontainer
2025-02-21 13:29:21 -08:00
Andy Grunwald
b40479632b
Development: Fix ImageMagick policy.xml path in devcontainer setup (#9188) 2025-02-21 18:20:40 +00:00
shamoon
c122c60d3f
Feature: email document button (#8950) 2025-02-21 16:44:03 +00:00
279 changed files with 113524 additions and 88343 deletions

View File

@ -1,18 +1,18 @@
codecov: codecov:
require_ci_to_pass: true require_ci_to_pass: true
# https://docs.codecov.com/docs/flags#recommended-automatic-flag-management # https://docs.codecov.com/docs/components
# Require each flag to have 1 upload before notification component_management:
flag_management: individual_components:
individual_flags: - component_id: backend
- name: backend
paths: paths:
- src/ - src/**
- name: frontend - component_id: frontend
paths: paths:
- src-ui/ - src-ui/**
# https://docs.codecov.com/docs/pull-request-comments # https://docs.codecov.com/docs/pull-request-comments
# codecov will only comment if coverage changes # codecov will only comment if coverage changes
comment: comment:
layout: "header, diff, components, flags, files"
require_changes: true require_changes: true
# https://docs.codecov.com/docs/javascript-bundle-analysis # https://docs.codecov.com/docs/javascript-bundle-analysis
require_bundle_changes: true require_bundle_changes: true

View File

@ -76,18 +76,15 @@ RUN set -eux \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES}
ARG PYTHON_PACKAGES="\ ARG PYTHON_PACKAGES="ca-certificates"
python3 \
python3-pip \
python3-wheel \
pipenv \
ca-certificates"
RUN set -eux \ RUN set -eux \
echo "Installing python packages" \ echo "Installing python packages" \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet ${PYTHON_PACKAGES} && apt-get install --yes --quiet ${PYTHON_PACKAGES}
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
RUN set -eux \ RUN set -eux \
&& echo "Installing pre-built updates" \ && echo "Installing pre-built updates" \
&& echo "Installing qpdf ${QPDF_VERSION}" \ && echo "Installing qpdf ${QPDF_VERSION}" \
@ -123,13 +120,15 @@ RUN set -eux \
WORKDIR /usr/src/paperless/src/docker/ WORKDIR /usr/src/paperless/src/docker/
COPY [ \ COPY [ \
"docker/imagemagick-policy.xml", \ "docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
"./" \ "./" \
] ]
RUN set -eux \ RUN set -eux \
&& echo "Configuring ImageMagick" \ && echo "Configuring ImageMagick" \
&& mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml && mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
# Packages needed only for building a few quick Python # Packages needed only for building a few quick Python
# dependencies # dependencies
@ -140,18 +139,17 @@ ARG BUILD_PACKAGES="\
libpq-dev \ libpq-dev \
# https://github.com/PyMySQL/mysqlclient#linux # https://github.com/PyMySQL/mysqlclient#linux
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config \ pkg-config"
pre-commit"
# hadolint ignore=DL3042 # hadolint ignore=DL3042
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \
set -eux \ set -eux \
&& echo "Installing build system packages" \ && echo "Installing build system packages" \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet ${BUILD_PACKAGES} && apt-get install --yes --quiet ${BUILD_PACKAGES}
RUN set -eux \ RUN set -eux \
&& npm update npm -g && npm update -g pnpm
# add users, setup scripts # add users, setup scripts
# Mount the compiled frontend to expected location # Mount the compiled frontend to expected location
@ -169,9 +167,6 @@ RUN set -eux \
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \ && mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \
&& echo "Adjusting all permissions" \ && echo "Adjusting all permissions" \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless
# && echo "Collecting static files" \
# && gosu paperless python3 manage.py collectstatic --clear --no-input --link \
# && gosu paperless python3 manage.py compilemessages
VOLUME ["/usr/src/paperless/paperless-ngx/data", \ VOLUME ["/usr/src/paperless/paperless-ngx/data", \
"/usr/src/paperless/paperless-ngx/media", \ "/usr/src/paperless/paperless-ngx/media", \

117
.devcontainer/README.md Normal file
View File

@ -0,0 +1,117 @@
# Paperless-ngx Development Environment
## Overview
Welcome to the Paperless-ngx development environment! This setup uses VSCode DevContainers to provide a consistent and seamless development experience.
### What are DevContainers?
DevContainers are a feature in VSCode that allows you to develop within a Docker container. This ensures that your development environment is consistent across different machines and setups. By defining a containerized environment, you can eliminate the "works on my machine" problem.
### Advantages of DevContainers
- **Consistency**: Same environment for all developers.
- **Isolation**: Separate development environment from your local machine.
- **Reproducibility**: Easily recreate the environment on any machine.
- **Pre-configured Tools**: Include all necessary tools and dependencies in the container.
## DevContainer Setup
The DevContainer configuration provides up all the necessary services for Paperless-ngx, including:
- Redis
- Gotenberg
- Tika
Data is stored using Docker volumes to ensure persistence across container restarts.
## Configuration Files
The setup includes debugging configurations (`launch.json`) and tasks (`tasks.json`) to help you manage and debug various parts of the project:
- **Backend Debugging:**
- `manage.py runserver`
- `manage.py document-consumer`
- `celery`
- **Maintenance Tasks:**
- Create superuser
- Run migrations
- Recreate virtual environment (`.venv` with `uv`)
- Compile frontend assets
## Getting Started
### Step 1: Running the DevContainer
To start the DevContainer:
1. Open VSCode.
2. Open the project folder.
3. Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
4. Type and select `Dev Containers: Rebuild and Reopen in Container`.
VSCode will build and start the DevContainer environment.
### Step 2: Initial Setup
Once the DevContainer is up and running, perform the following steps:
1. **Compile Frontend Assets**:
- Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
- Select `Tasks: Run Task`.
- Choose `Frontend Compile`.
2. **Run Database Migrations**:
- Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
- Select `Tasks: Run Task`.
- Choose `Migrate Database`.
3. **Create Superuser**:
- Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
- Select `Tasks: Run Task`.
- Choose `Create Superuser`.
### Debugging and Running Services
You can start and debug backend services either as debugging sessions via `launch.json` or as tasks.
#### Using `launch.json`
1. Press `F5` or go to the **Run and Debug** view in VSCode.
2. Select the desired configuration:
- `Runserver`
- `Document Consumer`
- `Celery`
#### Using Tasks
1. Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
2. Select `Tasks: Run Task`.
3. Choose the desired task:
- `Runserver`
- `Document Consumer`
- `Celery`
### Additional Maintenance Tasks
Additional tasks are available for common maintenance operations:
- **Recreate .venv**: For setting up the virtual environment using `uv`.
- **Migrate Database**: To apply database migrations.
- **Create Superuser**: To create an admin user for the application.
## Let's Get Started!
Follow the steps above to get your development environment up and running. Happy coding!

View File

@ -3,7 +3,7 @@
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml", "dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
"service": "paperless-development", "service": "paperless-development",
"workspaceFolder": "/usr/src/paperless/paperless-ngx", "workspaceFolder": "/usr/src/paperless/paperless-ngx",
"postCreateCommand": "pipenv install --dev && pipenv run pre-commit install", "postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [

View File

@ -43,7 +43,7 @@ services:
volumes: volumes:
- ..:/usr/src/paperless/paperless-ngx:delegated - ..:/usr/src/paperless/paperless-ngx:delegated
- ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files - ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files
- pipenv:/usr/src/paperless/paperless-ngx/.venv - virtualenv:/usr/src/paperless/paperless-ngx/.venv # Virtual environment persisted in volume
- /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container - /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container
- /usr/src/paperless/paperless-ngx/src/.pytest_cache - /usr/src/paperless/paperless-ngx/src/.pytest_cache
- /usr/src/paperless/paperless-ngx/.ruff_cache - /usr/src/paperless/paperless-ngx/.ruff_cache
@ -65,7 +65,7 @@ services:
command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done" command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done"
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:7.10 image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped restart: unless-stopped
# The Gotenberg Chromium route is used to convert .eml files. We do not # The Gotenberg Chromium route is used to convert .eml files. We do not
@ -80,4 +80,7 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
pipenv: data:
media:
redisdata:
virtualenv:

View File

@ -5,7 +5,7 @@
"label": "Start: Celery Worker", "label": "Start: Celery Worker",
"description": "Start the Celery Worker which processes background and consume tasks", "description": "Start the Celery Worker which processes background and consume tasks",
"type": "shell", "type": "shell",
"command": "pipenv run celery --app paperless worker -l DEBUG", "command": "uv run celery --app paperless worker -l DEBUG",
"isBackground": true, "isBackground": true,
"options": { "options": {
"cwd": "${workspaceFolder}/src" "cwd": "${workspaceFolder}/src"
@ -33,7 +33,7 @@
"label": "Start: Frontend Angular", "label": "Start: Frontend Angular",
"description": "Start the Frontend Angular Dev Server", "description": "Start the Frontend Angular Dev Server",
"type": "shell", "type": "shell",
"command": "npm start", "command": "pnpm start",
"isBackground": true, "isBackground": true,
"options": { "options": {
"cwd": "${workspaceFolder}/src-ui" "cwd": "${workspaceFolder}/src-ui"
@ -61,7 +61,7 @@
"label": "Start: Consumer Service (manage.py document_consumer)", "label": "Start: Consumer Service (manage.py document_consumer)",
"description": "Start the Consumer Service which processes files from a directory", "description": "Start the Consumer Service which processes files from a directory",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py document_consumer", "command": "uv run python manage.py document_consumer",
"group": "build", "group": "build",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -80,7 +80,7 @@
"label": "Start: Backend Server (manage.py runserver)", "label": "Start: Backend Server (manage.py runserver)",
"description": "Start the Backend Server which serves the Django API and the compiled Angular frontend", "description": "Start the Backend Server which serves the Django API and the compiled Angular frontend",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py runserver", "command": "uv run python manage.py runserver",
"group": "build", "group": "build",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -99,7 +99,7 @@
"label": "Maintenance: manage.py migrate", "label": "Maintenance: manage.py migrate",
"description": "Apply database migrations", "description": "Apply database migrations",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py migrate", "command": "uv run python manage.py migrate",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -118,7 +118,7 @@
"label": "Maintenance: Build Documentation", "label": "Maintenance: Build Documentation",
"description": "Build the documentation with MkDocs", "description": "Build the documentation with MkDocs",
"type": "shell", "type": "shell",
"command": "pipenv run mkdocs build --config-file mkdocs.yml && pipenv run mkdocs serve", "command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -137,7 +137,7 @@
"label": "Maintenance: manage.py createsuperuser", "label": "Maintenance: manage.py createsuperuser",
"description": "Create a superuser", "description": "Create a superuser",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py createsuperuser", "command": "uv run python manage.py createsuperuser",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -156,7 +156,7 @@
"label": "Maintenance: recreate .venv", "label": "Maintenance: recreate .venv",
"description": "Recreate the python virtual environment and install python dependencies", "description": "Recreate the python virtual environment and install python dependencies",
"type": "shell", "type": "shell",
"command": "rm -R -v .venv/* || pipenv install --dev", "command": "rm -R -v .venv/* || uv install --dev",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -173,8 +173,8 @@
}, },
{ {
"label": "Maintenance: Install Frontend Dependencies", "label": "Maintenance: Install Frontend Dependencies",
"description": "Install frontend (npm) dependencies", "description": "Install frontend (pnpm) dependencies",
"type": "npm", "type": "pnpm",
"script": "install", "script": "install",
"path": "src-ui", "path": "src-ui",
"group": "clean", "group": "clean",
@ -185,7 +185,7 @@
"description": "Clean install frontend dependencies and build the frontend for production", "description": "Clean install frontend dependencies and build the frontend for production",
"label": "Maintenance: Compile frontend for production", "label": "Maintenance: Compile frontend for production",
"type": "shell", "type": "shell",
"command": "npm ci && ./node_modules/.bin/ng build --configuration production", "command": "pnpm install && ./node_modules/.bin/ng build --configuration production",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,

View File

@ -27,9 +27,6 @@ indent_style = space
[*.md] [*.md]
indent_style = space indent_style = space
[Pipfile.lock]
indent_style = space
# Tests don't get a line width restriction. It's still a good idea to follow # Tests don't get a line width restriction. It's still a good idea to follow
# the 79 character rule, but in the interests of clarity, tests often need to # the 79 character rule, but in the interests of clarity, tests often need to
# violate it. # violate it.

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
github: [shamoon, stumpylog]

View File

@ -1,12 +1,15 @@
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem # Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2 version: 2
# Required for uv support for now
enable-beta-ecosystems: true
updates: updates:
# Enable version updates for npm # Enable version updates for pnpm
- package-ecosystem: "npm" - package-ecosystem: "npm"
target-branch: "dev" target-branch: "dev"
# Look for `package.json` and `lock` files in the `/src-ui` directory # Look for `pnpm-lock.yaml` file in the `/src-ui` directory
directory: "/src-ui" directory: "/src-ui"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
schedule: schedule:
@ -34,9 +37,8 @@ updates:
- "eslint" - "eslint"
# Enable version updates for Python # Enable version updates for Python
- package-ecosystem: "pip" - package-ecosystem: "uv"
target-branch: "dev" target-branch: "dev"
# Look for a `Pipfile` in the `root` directory
directory: "/" directory: "/"
# Check for updates once a week # Check for updates once a week
schedule: schedule:
@ -47,14 +49,13 @@ updates:
# Add reviewers # Add reviewers
reviewers: reviewers:
- "paperless-ngx/backend" - "paperless-ngx/backend"
ignore:
- dependency-name: "uvicorn"
groups: groups:
development: development:
patterns: patterns:
- "*pytest*" - "*pytest*"
- "ruff" - "ruff"
- "mkdocs-material" - "mkdocs-material"
- "pre-commit*"
django: django:
patterns: patterns:
- "*django*" - "*django*"
@ -65,6 +66,10 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
pre-built:
patterns:
- psycopg*
- zxing-cpp
# Enable updates for GitHub Actions # Enable updates for GitHub Actions
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
@ -85,3 +90,50 @@ updates:
- "major" - "major"
- "minor" - "minor"
- "patch" - "patch"
# Update Dockerfile in root directory
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
reviewers:
- "paperless-ngx/ci-cd"
labels:
- "ci-cd"
- "dependencies"
commit-message:
prefix: "docker"
include: "scope"
# Update Docker Compose files in docker/compose directory
- package-ecosystem: "docker-compose"
directory: "/docker/compose/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
reviewers:
- "paperless-ngx/ci-cd"
labels:
- "ci-cd"
- "dependencies"
commit-message:
prefix: "docker-compose"
include: "scope"
groups:
# Individual groups for each image
gotenberg:
patterns:
- "docker.io/gotenberg/gotenberg*"
tika:
patterns:
- "docker.io/apache/tika*"
redis:
patterns:
- "docker.io/library/redis*"
mariadb:
patterns:
- "docker.io/library/mariadb*"
postgres:
patterns:
- "docker.io/library/postgres*"

View File

@ -14,9 +14,7 @@ on:
- 'translations**' - 'translations**'
env: env:
# This is the version of pipenv all the steps will use DEFAULT_UV_VERSION: "0.6.x"
# If changing this, change Dockerfile
DEFAULT_PIP_ENV_VERSION: "2024.4.1"
# This is the default version of Python to use in most steps which aren't specific # This is the default version of Python to use in most steps which aren't specific
DEFAULT_PYTHON_VERSION: "3.11" DEFAULT_PYTHON_VERSION: "3.11"
@ -59,24 +57,25 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- -
name: Install dependencies name: Install Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
-
name: List installed Python dependencies
run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list
- -
name: Make documentation name: Make documentation
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs build --config-file ./mkdocs.yml uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs build --config-file ./mkdocs.yml
- -
name: Deploy documentation name: Deploy documentation
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
@ -84,7 +83,11 @@ jobs:
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME" echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}" git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com" git config --global user.email "${{ github.actor }}@users.noreply.github.com"
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history
- -
name: Upload artifact name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -117,12 +120,13 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- -
name: Install system dependencies name: Install system dependencies
run: | run: |
@ -135,12 +139,14 @@ jobs:
- -
name: Install Python dependencies name: Install Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python --version uv sync \
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev --python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--frozen
- -
name: List installed Python dependencies name: List installed Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list uv pip list
- -
name: Tests name: Tests
env: env:
@ -150,17 +156,26 @@ jobs:
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
run: | run: |
cd src/ uv run \
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pytest -ra --python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
pytest
- -
name: Upload coverage name: Upload backend test results to Codecov
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }} if: always()
uses: actions/upload-artifact@v4 uses: codecov/test-results-action@v1
with: with:
name: backend-coverage-report token: ${{ secrets.CODECOV_TOKEN }}
path: src/coverage.xml flags: backend-python-${{ matrix.python-version }}
retention-days: 7 files: junit.xml
if-no-files-found: warn -
name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-python-${{ matrix.python-version }}
files: coverage.xml
- -
name: Stop containers name: Stop containers
if: always() if: always()
@ -168,42 +183,46 @@ jobs:
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml logs docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml logs
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down
install-frontend-depedendencies: install-frontend-dependencies:
name: "Install Frontend Dependencies" name: "Install Frontend Dependencies"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- pre-commit - pre-commit
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- -
name: Use Node.js 20 name: Use Node.js 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'npm' cache: 'pnpm'
cache-dependency-path: 'src-ui/package-lock.json' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.npm ~/.pnpm-store
~/.cache ~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- -
name: Install dependencies name: Install dependencies
if: steps.cache-frontend-deps.outputs.cache-hit != 'true' if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
run: cd src-ui && npm ci run: cd src-ui && pnpm install
- -
name: Install Playwright name: Install Playwright
if: steps.cache-frontend-deps.outputs.cache-hit != 'true' if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
run: cd src-ui && npx playwright install --with-deps run: cd src-ui && pnpm playwright install --with-deps
tests-frontend: tests-frontend:
name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- install-frontend-depedendencies - install-frontend-dependencies
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -212,124 +231,88 @@ jobs:
shard-count: [4] shard-count: [4]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- -
name: Use Node.js 20 name: Use Node.js 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'npm' cache: 'pnpm'
cache-dependency-path: 'src-ui/package-lock.json' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.npm ~/.pnpm-store
~/.cache ~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular cli - name: Re-link Angular cli
run: cd src-ui && npm link @angular/cli run: cd src-ui && pnpm link @angular/cli
- -
name: Linting checks name: Linting checks
run: cd src-ui && npm run lint run: cd src-ui && pnpm run lint
- -
name: Run Jest unit tests name: Run Jest unit tests
run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
-
name: Upload Jest coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: jest-coverage-report-${{ matrix.shard-index }}
path: |
src-ui/coverage/coverage-final.json
src-ui/coverage/lcov.info
src-ui/coverage/clover.xml
retention-days: 7
if-no-files-found: warn
- -
name: Run Playwright e2e tests name: Run Playwright e2e tests
run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
- -
name: Upload Playwright test results name: Upload frontend test results to Codecov
uses: codecov/test-results-action@v1
if: always() if: always()
uses: actions/upload-artifact@v4
with: with:
name: playwright-report-${{ matrix.shard-index }} token: ${{ secrets.CODECOV_TOKEN }}
path: src-ui/playwright-report flags: frontend-node-${{ matrix.node-version }}
retention-days: 7 directory: src-ui/
tests-coverage-upload:
name: "Upload to Codecov"
runs-on: ubuntu-24.04
needs:
- tests-backend
- tests-frontend
steps:
-
uses: actions/checkout@v4
-
name: Download frontend jest coverage
uses: actions/download-artifact@v4
with:
path: src-ui/coverage/
pattern: jest-coverage-report-*
-
name: Download frontend playwright coverage
uses: actions/download-artifact@v4
with:
path: src-ui/coverage/
pattern: playwright-report-*
merge-multiple: true
- -
name: Upload frontend coverage to Codecov name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
flags: frontend flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/ directory: src-ui/coverage/
# dont include backend coverage files here
files: '!coverage.xml' frontend-bundle-analysis:
name: "Frontend Bundle Analysis"
runs-on: ubuntu-24.04
needs:
- tests-frontend
steps:
- uses: actions/checkout@v4
- -
name: Download backend coverage name: Install pnpm
uses: actions/download-artifact@v4 uses: pnpm/action-setup@v4
with: with:
name: backend-coverage-report version: 10
path: src/
-
name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}
# future expansion
flags: backend
directory: src/
- -
name: Use Node.js 20 name: Use Node.js 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'npm' cache: 'pnpm'
cache-dependency-path: 'src-ui/package-lock.json' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- -
name: Cache frontend dependencies name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.npm ~/.pnpm-store
~/.cache ~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
- -
name: Re-link Angular cli name: Re-link Angular cli
run: cd src-ui && npm link @angular/cli run: cd src-ui && pnpm link @angular/cli
- -
name: Build frontend and upload analysis name: Build frontend and upload analysis
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && ng build --configuration=production run: cd src-ui && pnpm run build --configuration=production
build-docker-image: build-docker-image:
name: Build Docker image for ${{ github.ref_name }} name: Build Docker image for ${{ github.ref_name }}
@ -472,16 +455,17 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv + tools name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- -
name: Install Python dependencies name: Install Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- -
name: Install system dependencies name: Install system dependencies
run: | run: |
@ -502,17 +486,21 @@ jobs:
- -
name: Generate requirements file name: Generate requirements file
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} requirements > requirements.txt uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- -
name: Compile messages name: Compile messages
run: | run: |
cd src/ cd src/
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py compilemessages uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py compilemessages
- -
name: Collect static files name: Collect static files
run: | run: |
cd src/ cd src/
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py collectstatic --no-input uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py collectstatic --no-input
- -
name: Move files name: Move files
run: | run: |
@ -528,13 +516,12 @@ jobs:
for file_name in .dockerignore \ for file_name in .dockerignore \
.env \ .env \
Dockerfile \ Dockerfile \
Pipfile \ pyproject.toml \
Pipfile.lock \ uv.lock \
requirements.txt \ requirements.txt \
LICENSE \ LICENSE \
README.md \ README.md \
paperless.conf.example \ paperless.conf.example
gunicorn.conf.py
do do
cp --verbose ${file_name} dist/paperless-ngx/ cp --verbose ${file_name} dist/paperless-ngx/
done done
@ -631,15 +618,17 @@ jobs:
ref: main ref: main
- -
name: Set up Python name: Set up Python
id: setup-python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv + tools name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- -
name: Append Changelog to docs name: Append Changelog to docs
id: append-Changelog id: append-Changelog
@ -655,7 +644,10 @@ jobs:
CURRENT_CHANGELOG=`tail --lines +2 changelog.md` CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
mv changelog-new.md changelog.md mv changelog-new.md changelog.md
pipenv run pre-commit run --files changelog.md || true uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
pre-commit run --files changelog.md || true
git config --global user.name "github-actions" git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"

View File

@ -33,7 +33,7 @@ jobs:
- -
name: Clean temporary images name: Clean temporary images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0 uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
@ -61,7 +61,7 @@ jobs:
- -
name: Clean untagged images name: Clean untagged images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.9.0 uses: stumpylog/image-cleaner-action/untagged@v0.10.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"

1
.gitignore vendored
View File

@ -44,6 +44,7 @@ nosetests.xml
coverage.xml coverage.xml
*,cover *,cover
.pytest_cache .pytest_cache
junit.xml
# Translations # Translations
*.mo *.mo

View File

@ -32,7 +32,7 @@ repos:
rev: v2.4.0 rev: v2.4.0
hooks: hooks:
- id: codespell - id: codespell
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)" exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
exclude_types: exclude_types:
- pofile - pofile
- json - json
@ -45,16 +45,19 @@ repos:
- javascript - javascript
- ts - ts
- markdown - markdown
exclude: "(^Pipfile\\.lock$)"
additional_dependencies: additional_dependencies:
- prettier@3.3.3 - prettier@3.3.3
- 'prettier-plugin-organize-imports@4.1.0' - 'prettier-plugin-organize-imports@4.1.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.6 rev: v0.9.9
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.5.1"
hooks:
- id: pyproject-fmt
# Dockerfile hooks # Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py - repo: https://github.com/AleksaC/hadolint-py
rev: v2.12.0.3 rev: v2.12.0.3

View File

@ -1 +0,0 @@
3.10.15

View File

@ -1,87 +0,0 @@
fix = true
line-length = 88
respect-gitignore = true
src = ["src"]
target-version = "py310"
output-format = "grouped"
show-fixes = true
# https://docs.astral.sh/ruff/settings/
# https://docs.astral.sh/ruff/rules/
[lint]
extend-select = [
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
"TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
]
ignore = ["DJ001", "SIM105", "RUF012"]
[lint.per-file-ignores]
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
"docker/wait-for-redis.py" = ["INP001", "T201"]
"src/documents/file_handling.py" = ["PTH"] # TODO Enable & remove
"src/documents/management/commands/document_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/management/commands/document_exporter.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/0012_auto_20160305_0040.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/0014_document_checksum.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/1003_mime_types.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/1012_fix_archive_files.py" = ["PTH"] # TODO Enable & remove
"src/documents/models.py" = ["SIM115", "PTH"] # TODO PTH Enable & remove
"src/documents/parsers.py" = ["PTH"] # TODO Enable & remove
"src/documents/signals/handlers.py" = ["PTH"] # TODO Enable & remove
"src/documents/tasks.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_api_app_config.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_classifier.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_file_handling.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management_exporter.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management_thumbnails.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_migration_archive_files.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_migration_document_pages_count.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_migration_mime_type.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_sanity_check.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_tasks.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_views.py" = ["PTH"] # TODO Enable & remove
"src/documents/views.py" = ["PTH"] # TODO Enable & remove
"src/paperless/checks.py" = ["PTH"] # TODO Enable & remove
"src/paperless/settings.py" = ["PTH"] # TODO Enable & remove
"src/paperless/tests/test_checks.py" = ["PTH"] # TODO Enable & remove
"src/paperless/urls.py" = ["PTH"] # TODO Enable & remove
"src/paperless/views.py" = ["PTH"] # TODO Enable & remove
"src/paperless_mail/mail.py" = ["PTH"] # TODO Enable & remove
"src/paperless_mail/preprocessor.py" = ["PTH"] # TODO Enable & remove
"src/paperless_tesseract/parsers.py" = ["PTH"] # TODO Enable & remove
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001", "PTH"] # TODO PTH Enable & remove
"src/paperless_tika/tests/test_live_tika.py" = ["PTH"] # TODO Enable & remove
"src/paperless_tika/tests/test_tika_parser.py" = ["PTH"] # TODO Enable & remove
# Testing
"*/tests/*.py" = ["E501", "SIM117"]
# Migrations
"*/migrations/*.py" = ["E501", "SIM", "T201"]
# Docker specific
"docker/rootfs/usr/local/bin/wait-for-redis.py" = ["INP001", "T201"]
[lint.isort]
force-single-line = true

View File

@ -5,5 +5,6 @@
/src-ui/ @paperless-ngx/frontend /src-ui/ @paperless-ngx/frontend
/src/ @paperless-ngx/backend /src/ @paperless-ngx/backend
Pipfile* @paperless-ngx/backend pyproject.toml @paperless-ngx/backend
uv.lock @paperless-ngx/backend
*.py @paperless-ngx/backend *.py @paperless-ngx/backend

View File

@ -4,15 +4,17 @@
# Stage: compile-frontend # Stage: compile-frontend
# Purpose: Compiles the frontend # Purpose: Compiles the frontend
# Notes: # Notes:
# - Does NPM stuff with Typescript and such # - Does PNPM stuff with Typescript and such
FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend
COPY ./src-ui /src/src-ui COPY ./src-ui /src/src-ui
WORKDIR /src/src-ui WORKDIR /src/src-ui
RUN set -eux \ RUN set -eux \
&& npm update npm -g \ && npm update -g pnpm \
&& npm ci && npm install -g corepack@latest \
&& corepack enable \
&& pnpm install
ARG PNGX_TAG_VERSION= ARG PNGX_TAG_VERSION=
# Add the tag to the environment file if its a tagged dev build # Add the tag to the environment file if its a tagged dev build
@ -26,28 +28,11 @@ esac
RUN set -eux \ RUN set -eux \
&& ./node_modules/.bin/ng build --configuration production && ./node_modules/.bin/ng build --configuration production
# Stage: pipenv-base
# Purpose: Generates a requirements.txt file for building
# Comments:
# - pipenv dependencies are not left in the final image
# - pipenv can't touch the final image somehow
FROM --platform=$BUILDPLATFORM docker.io/python:3.12-alpine AS pipenv-base
WORKDIR /usr/src/pipenv
COPY Pipfile* ./
RUN set -eux \
&& echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2024.4.1 \
&& echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt
# Stage: s6-overlay-base # Stage: s6-overlay-base
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM docker.io/python:3.12-slim-bookworm AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.6.5-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@ -123,9 +108,12 @@ ARG GS_VERSION=10.03.1
# Set Python environment variables # Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
# Ignore warning from Whitenoise # Ignore warning from Whitenoise about async iterators
PYTHONWARNINGS="ignore:::django.http.response:517" \ PYTHONWARNINGS="ignore:::django.http.response:517" \
PNGX_CONTAINERIZED=1 PNGX_CONTAINERIZED=1 \
# https://docs.astral.sh/uv/reference/settings/#link-mode
UV_LINK_MODE=copy \
UV_CACHE_DIR=/cache/uv/
# #
# Begin installation and configuration # Begin installation and configuration
@ -204,46 +192,29 @@ RUN set -eux \
&& rm --force --verbose *.deb \ && rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* && rm --recursive --force --verbose /var/lib/apt/lists/*
# Copy gunicorn config
# Changes very infrequently
WORKDIR /usr/src/paperless/
COPY --chown=1000:1000 gunicorn.conf.py /usr/src/paperless/gunicorn.conf.py
WORKDIR /usr/src/paperless/src/ WORKDIR /usr/src/paperless/src/
# Python dependencies # Python dependencies
# Change pretty frequently # Change pretty frequently
COPY --chown=1000:1000 --from=pipenv-base /usr/src/pipenv/requirements.txt ./ COPY --chown=1000:1000 ["pyproject.toml", "uv.lock", "/usr/src/paperless/src/"]
# Packages needed only for building a few quick Python # Packages needed only for building a few quick Python
# dependencies # dependencies
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
build-essential \ build-essential \
git \
# https://www.psycopg.org/docs/install.html#prerequisites
libpq-dev \
# https://github.com/PyMySQL/mysqlclient#linux # https://github.com/PyMySQL/mysqlclient#linux
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config" pkg-config"
ARG ZXING_VERSION=2.3.0
ARG PSYCOPG_VERSION=3.2.4
# hadolint ignore=DL3042 # hadolint ignore=DL3042
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ RUN --mount=type=cache,target=${UV_CACHE_DIR},id=python-cache \
set -eux \ set -eux \
&& echo "Installing build system packages" \ && echo "Installing build system packages" \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --upgrade wheel \
&& echo "Installing Python requirements" \ && echo "Installing Python requirements" \
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \ && uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \
https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_x86_64.whl \ && uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \
https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_x86_64.whl \
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
&& echo "Installing NLTK data" \ && echo "Installing NLTK data" \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \

102
Pipfile
View File

@ -1,102 +0,0 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
dateparser = "~=1.2"
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
django = "~=5.1.5"
django-allauth = {extras = ["mfa", "socialaccount"], version = "*"}
django-auditlog = "*"
django-celery-results = "*"
django-compression-middleware = "*"
django-cors-headers = "*"
django-extensions = "*"
django-filter = "~=25.1"
django-guardian = "*"
django-multiselectfield = "*"
django-soft-delete = "*"
djangorestframework = "~=3.15.2"
djangorestframework-guardian = "*"
drf-spectacular = "*"
drf-spectacular-sidecar = "*"
drf-writable-nested = "*"
bleach = "*"
celery = {extras = ["redis"], version = "*"}
channels = "~=4.2"
channels-redis = "*"
concurrent-log-handler = "*"
filelock = "*"
flower = "*"
gotenberg-client = "*"
gunicorn = "*"
httpx-oauth = "*"
imap-tools = "*"
inotifyrecursive = "~=0.3"
jinja2 = "~=3.1"
langdetect = "*"
mysqlclient = "*"
nltk = "*"
ocrmypdf = "~=16.9"
pathvalidate = "*"
pdf2image = "*"
psycopg = {version = "*", extras = ["c"]}
python-dateutil = "*"
python-dotenv = "*"
python-gnupg = "*"
python-ipware = "*"
python-magic = "*"
pyzbar = "*"
rapidfuzz = "*"
redis = {extras = ["hiredis"], version = "*"}
scikit-learn = "~=1.6"
setproctitle = "*"
tika-client = "*"
tqdm = "*"
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
uvicorn = {extras = ["standard"], version = "==0.25.0"}
watchdog = "~=6.0"
whitenoise = "~=6.9"
whoosh = "~=2.7"
zxing-cpp = "*"
[dev-packages]
# Linting
pre-commit = "*"
ruff = "*"
factory-boy = "*"
# Testing
pytest = "*"
pytest-cov = "*"
pytest-django = "*"
pytest-httpx = "*"
pytest-env = "*"
pytest-sugar = "*"
pytest-xdist = "*"
pytest-mock = "*"
pytest-rerunfailures = "*"
imagehash = "*"
daphne = "*"
# Documentation
mkdocs-material = "*"
mkdocs-glightbox = "*"
[typing-dev]
mypy = "*"
types-Pillow = "*"
django-filter-stubs = "*"
types-python-dateutil = "*"
djangorestframework-stubs = {extras= ["compatible-mypy"], version="*"}
celery-types = "*"
django-stubs = {extras= ["compatible-mypy"], version="*"}
types-dateparser = "*"
types-bleach = "*"
types-redis = "*"
types-tqdm = "*"
types-Markdown = "*"
types-Pygments = "*"
types-colorama = "*"
types-setuptools = "*"

4978
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.7 image: docker.io/gotenberg/gotenberg:8.17
hostname: gotenberg hostname: gotenberg
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host

View File

@ -77,7 +77,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.7 image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.

View File

@ -38,7 +38,7 @@ services:
- redisdata:/data - redisdata:/data
db: db:
image: docker.io/library/postgres:16 image: docker.io/library/postgres:17
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data

View File

@ -38,7 +38,7 @@ services:
- redisdata:/data - redisdata:/data
db: db:
image: docker.io/library/postgres:16 image: docker.io/library/postgres:17
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
@ -71,7 +71,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.7 image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not

View File

@ -34,7 +34,7 @@ services:
- redisdata:/data - redisdata:/data
db: db:
image: docker.io/library/postgres:16 image: docker.io/library/postgres:17
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data

View File

@ -59,7 +59,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.7 image: docker.io/gotenberg/gotenberg:8.17
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not

View File

@ -1,10 +1,18 @@
#!/command/with-contenv /usr/bin/bash #!/command/with-contenv /usr/bin/bash
# shellcheck shell=bash # shellcheck shell=bash
cd ${PAPERLESS_SRC_DIR}
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
exec python3 manage.py document_consumer echo "[svc-consumer] Consumer is disabled, exiting"
# https://skarnet.org/software/s6/s6-svc.html
s6-svc -Od .
else else
exec s6-setuidgid paperless python3 manage.py document_consumer cd ${PAPERLESS_SRC_DIR}
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec python3 manage.py document_consumer
else
exec s6-setuidgid paperless python3 manage.py document_consumer
fi
fi fi

View File

@ -3,8 +3,18 @@
cd ${PAPERLESS_SRC_DIR} cd ${PAPERLESS_SRC_DIR}
if [[ -n "${USER_IS_NON_ROOT}" ]]; then # Translate between things, preferring GRANIAN_
exec /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application export GRANIAN_HOST=${GRANIAN_HOST:-${PAPERLESS_BIND_ADDR:-"::"}}
else export GRANIAN_PORT=${GRANIAN_PORT:-${PAPERLESS_PORT:-8000}}
exec s6-setuidgid paperless /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application export GRANIAN_WORKERS=${GRANIAN_WORKERS:-${PAPERLESS_WEBSERVER_WORKERS:-1}}
# Only set GRANIAN_URL_PATH_PREFIX if PAPERLESS_FORCE_SCRIPT_NAME is set
if [[ -n "${PAPERLESS_FORCE_SCRIPT_NAME}" ]]; then
export GRANIAN_URL_PATH_PREFIX=${PAPERLESS_FORCE_SCRIPT_NAME}
fi
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec granian --interface asginl --ws "paperless.asgi:application"
else
exec s6-setuidgid paperless granian --interface asginl --ws "paperless.asgi:application"
fi fi

View File

@ -509,6 +509,12 @@ Invoice_{{ custom_fields|get_cf_value("Select Field") }}_{{ custom_fields|get_cf
This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`. This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`.
You can also use a custom `slugify` filter to slufigy text:
```jinja
{{ title | slugify }}
```
## Automatic recovery of invalid PDFs {#pdf-recovery} ## Automatic recovery of invalid PDFs {#pdf-recovery}
Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type

View File

@ -557,6 +557,20 @@ This is for use with self-signed certificates against local IMAP servers.
Settings this value has security implications for the security of your email. Settings this value has security implications for the security of your email.
Understand what it does and be sure you need to before setting. Understand what it does and be sure you need to before setting.
### Authentication & SSO {#authentication}
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
: Allow users to signup for a new Paperless-ngx account.
Defaults to False
#### [`PAPERLESS_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_ACCOUNT_DEFAULT_GROUPS}
: A list of group names that users will be added to when they sign up for a new account. Groups listed here must already exist.
Defaults to None
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS} #### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth. : This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
@ -580,12 +594,25 @@ system. See the corresponding
Defaults to True Defaults to True
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS} #### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=<bool>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS}
: Allow users to signup for a new Paperless-ngx account. : Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html).
: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.:
```json
{"openid_connect":{"SCOPE": ["openid","profile","email","groups"]...
```
Defaults to False Defaults to False
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) setting and this setting are used, the user will be added to both sets of groups.
Defaults to None
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL} #### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding : The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
@ -1030,6 +1057,11 @@ be used with caution!
## Document Consumption {#consume_config} ## Document Consumption {#consume_config}
#### [`PAPERLESS_CONSUMER_DISABLE=<bool>`](#PAPERLESS_CONSUMER_DISABLE) {#PAPERLESS_CONSUMER_DISABLE}
: Completely disable the directory-based consumer in docker. If you don't plan to consume documents
via the consumption directory, you can disable the consumer to save resources.
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES} #### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
: When the consumer detects a duplicate document, it will not touch : When the consumer detects a duplicate document, it will not touch
@ -1506,13 +1538,23 @@ increase RAM usage.
Defaults to 1. Defaults to 1.
!!! note
This option may also be set with `GRANIAN_WORKERS` and
this option may be removed in the future
#### [`PAPERLESS_BIND_ADDR=<ip address>`](#PAPERLESS_BIND_ADDR) {#PAPERLESS_BIND_ADDR} #### [`PAPERLESS_BIND_ADDR=<ip address>`](#PAPERLESS_BIND_ADDR) {#PAPERLESS_BIND_ADDR}
: The IP address the webserver will listen on inside the container. : The IP address the webserver will listen on inside the container.
There are special setups where you may need to configure this value There are special setups where you may need to configure this value
to restrict the Ip address or interface the webserver listens on. to restrict the Ip address or interface the webserver listens on.
Defaults to `[::]`, meaning all interfaces, including IPv6. Defaults to `::`, meaning all interfaces, including IPv6.
!!! note
This option may also be set with `GRANIAN_HOST` and
this option may be removed in the future
#### [`PAPERLESS_PORT=<port>`](#PAPERLESS_PORT) {#PAPERLESS_PORT} #### [`PAPERLESS_PORT=<port>`](#PAPERLESS_PORT) {#PAPERLESS_PORT}
@ -1527,6 +1569,11 @@ one pod).
Defaults to 8000. Defaults to 8000.
!!! note
This option may also be set with `GRANIAN_PORT` and
this option may be removed in the future
#### [`USERMAP_UID=<uid>`](#USERMAP_UID) {#USERMAP_UID} #### [`USERMAP_UID=<uid>`](#USERMAP_UID) {#USERMAP_UID}
: The ID of the paperless user in the container. Set this to your : The ID of the paperless user in the container. Set this to your

View File

@ -60,7 +60,7 @@ first-time setup.
Every command is executed directly from the root folder of the project unless specified otherwise. Every command is executed directly from the root folder of the project unless specified otherwise.
1. Install prerequisites + pipenv as mentioned in 1. Install prerequisites + [uv](https://github.com/astral-sh/uv) as mentioned in
[Bare metal route](setup.md#bare_metal). [Bare metal route](setup.md#bare_metal).
2. Copy `paperless.conf.example` to `paperless.conf` and enable debug 2. Copy `paperless.conf.example` to `paperless.conf` and enable debug
@ -75,17 +75,13 @@ first-time setup.
4. Install the Python dependencies: 4. Install the Python dependencies:
```bash ```bash
pipenv install --dev $ uv sync --group dev
``` ```
!!! note
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
5. Install pre-commit hooks: 5. Install pre-commit hooks:
```bash ```bash
pre-commit install $ uv run pre-commit install
``` ```
6. Apply migrations and create a superuser for your development instance: 6. Apply migrations and create a superuser for your development instance:
@ -93,8 +89,8 @@ first-time setup.
```bash ```bash
# src/ # src/
python3 manage.py migrate $ uv run manage.py migrate
python3 manage.py createsuperuser $ uv run manage.py createsuperuser
``` ```
7. You can now either ... 7. You can now either ...
@ -144,7 +140,7 @@ To build the front end once use this command:
```bash ```bash
# src-ui/ # src-ui/
$ npm install $ pnpm install
$ ng build --configuration production $ ng build --configuration production
``` ```
@ -164,10 +160,23 @@ $ ng build --configuration production
complicated IF cases. Append `# noqa: E501` to disable this check complicated IF cases. Append `# noqa: E501` to disable this check
for certain lines. for certain lines.
### Package Management
Paperless uses `uv` to manage packages and virtual environments for both development and production.
To accomplish some common tasks using `uv`, follow the shortcuts below:
To upgrade all locked packages to the latest allowed versions: `uv lock --upgrade`
To upgrade a single locked package: `uv lock --upgrade-package <package>`
To add a new package: `uv add <package>`
To add a new development package `uv add --dev <package>`
## Front end development ## Front end development
The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and
`npm`. `pnpm`.
!!! note !!! note
@ -176,7 +185,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
1. Install the Angular CLI. You might need sudo privileges to perform this command: 1. Install the Angular CLI. You might need sudo privileges to perform this command:
```bash ```bash
npm install -g @angular/cli pnpm install -g @angular/cli
``` ```
2. Make sure that it's on your path. 2. Make sure that it's on your path.
@ -184,7 +193,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
3. Install all necessary modules: 3. Install all necessary modules:
```bash ```bash
npm install pnpm install
``` ```
4. You can launch a development server by running: 4. You can launch a development server by running:
@ -198,7 +207,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
restart it. restart it.
By default, the development server is available on `http://localhost:4200/` and is configured to access the API at By default, the development server is available on `http://localhost:4200/` and is configured to access the API at
`http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts, CORS and X-Frame-Options are in place so that the front end behaves exactly as in production. `http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts and CORS are in place so that the front end behaves exactly as in production.
### Testing and code style ### Testing and code style
@ -332,27 +341,21 @@ LANGUAGES = [
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/). The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
If you want to build the documentation locally, this is how you do it: If you want to build the documentation locally, this is how you do it:
1. Have an active pipenv shell (`pipenv shell`) and install Python dependencies: 1. Build the documentation
```bash ```bash
pipenv install --dev $ uv run mkdocs build --config-file mkdocs.yml
```
2. Build the documentation
```bash
mkdocs build --config-file mkdocs.yml
``` ```
_alternatively..._ _alternatively..._
3. Serve the documentation. This will spin up a 2. Serve the documentation. This will spin up a
copy of the documentation at http://127.0.0.1:8000 copy of the documentation at http://127.0.0.1:8000
that will automatically refresh every time you change that will automatically refresh every time you change
something. something.
```bash ```bash
mkdocs serve $ uv run mkdocs serve
``` ```
## Building the Docker image ## Building the Docker image

View File

@ -133,6 +133,9 @@ Multiple options for ASGI servers exist:
implementation for ASGI. implementation for ASGI.
- `uvicorn` as a standalone server - `uvicorn` as a standalone server
You may also find the [Django documentation](https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/) on ASGI
useful to review.
## _What about the Redis licensing change and using one of the open source forks_? ## _What about the Redis licensing change and using one of the open source forks_?
Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream

View File

@ -380,6 +380,12 @@ are released, dependency support is confirmed, etc.
dependencies. This is an alternative to the above and may require adjusting dependencies. This is an alternative to the above and may require adjusting
the example scripts to utilize the virtual environment paths the example scripts to utilize the virtual environment paths
!!! tip
If you use modern Python tooling, such as `uv`, installation will not include
dependencies for Postgres or Mariadb. You can select those extras with `--extra <EXTRA>`
or all with `--all-extras`
9. Go to `/opt/paperless/src`, and execute the following commands: 9. Go to `/opt/paperless/src`, and execute the following commands:
```bash ```bash
@ -426,31 +432,20 @@ are released, dependency support is confirmed, etc.
!!! note !!! note
The `socket` script enables `gunicorn` to run on port 80 without The `socket` script enables `granian` to run on port 80 without
root privileges. For this you need to uncomment the root privileges. For this you need to uncomment the
`Require=paperless-webserver.socket` in the `webserver` script `Require=paperless-webserver.socket` in the `webserver` script
and configure `gunicorn` to listen on port 80 (see and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
`paperless/gunicorn.conf.py`).
You may need to adjust the path to the `gunicorn` executable. This
will be installed as part of the python dependencies, and is either
located in the `bin` folder of your virtual environment, or in
`~/.local/bin/` if no virtual environment is used.
These services rely on redis and optionally the database server, but These services rely on redis and optionally the database server, but
don't need to be started in any particular order. The example files don't need to be started in any particular order. The example files
depend on redis being started. If you use a database server, you depend on redis being started. If you use a database server, you
should add additional dependencies. should add additional dependencies.
!!! warning !!! note
The included scripts run a `gunicorn` standalone server, which is For instructions on using a reverse proxy,
fine for running paperless. It does support SSL, however, the [see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#).
documentation of GUnicorn states that you should use a proxy server
in front of gunicorn instead.
For instructions on how to use nginx for that,
[see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx).
!!! warning !!! warning
@ -714,6 +709,8 @@ the Pi and configuring some options in paperless can help improve
performance immensely: performance immensely:
- Stick with SQLite to save some resources. - Stick with SQLite to save some resources.
- If you do not need the filesystem-based consumer, consider disabling it
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will - Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
only OCR the first page of your documents. In most cases, this page only OCR the first page of your documents. In most cases, this page
contains enough information to be able to find it. contains enough information to be able to find it.

View File

@ -195,34 +195,6 @@ This might have multiple reasons.
is not, you need to compile the front end yourself or download the is not, you need to compile the front end yourself or download the
release archive instead of cloning the repository. release archive instead of cloning the repository.
2. Check the output of the web server. You might see errors like this:
```
[2021-01-25 10:08:04 +0000] [40] [ERROR] Socket error processing request.
Traceback (most recent call last):
File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 134, in handle
self.handle_request(listener, req, client, addr)
File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 190, in handle_request
util.reraise(*sys.exc_info())
File "/usr/local/lib/python3.7/site-packages/gunicorn/util.py", line 625, in reraise
raise value
File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 178, in handle_request
resp.write_file(respiter)
File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 396, in write_file
if not self.sendfile(respiter):
File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 386, in sendfile
sent += os.sendfile(sockno, fileno, offset + sent, count)
OSError: [Errno 22] Invalid argument
```
To fix this issue, add
```
SENDFILE=0
```
to your `docker-compose.env` file.
## Error while reading metadata ## Error while reading metadata
You might find messages like these in your log files: You might find messages like these in your log files:
@ -322,12 +294,12 @@ many documents at once often. Otherwise, try tweaking the
[`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to [`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to
unlock. This may have minor performance implications. unlock. This may have minor performance implications.
## gunicorn fails to start with "is not a valid port number" ## granian fails to start with "is not a valid port number"
You are likely running using Kubernetes, which automatically creates an You are likely running using Kubernetes, which automatically creates an
environment variable named `${serviceName}_PORT`. This is environment variable named `${serviceName}_PORT`. This is
the same environment variable which is used by Paperless to optionally the same environment variable which is used by Paperless to optionally
change the port gunicorn listens on. change the port granian listens on.
To fix this, set [`PAPERLESS_PORT`](configuration.md#PAPERLESS_PORT) again to your desired port, or the To fix this, set [`PAPERLESS_PORT`](configuration.md#PAPERLESS_PORT) again to your desired port, or the
default of 8000. default of 8000.

View File

@ -837,7 +837,7 @@ Paperless-ngx consists of the following components:
```shell-session ```shell-session
cd /path/to/paperless/src/ cd /path/to/paperless/src/
gunicorn -c ../gunicorn.conf.py paperless.wsgi granian --interface asginl --ws "paperless.asgi:application"
``` ```
or by any other means such as Apache `mod_wsgi`. or by any other means such as Apache `mod_wsgi`.

View File

@ -1,49 +0,0 @@
import os
# See https://docs.gunicorn.org/en/stable/settings.html for
# explanations of settings
bind = f"{os.getenv('PAPERLESS_BIND_ADDR', '[::]')}:{os.getenv('PAPERLESS_PORT', 8000)}"
workers = int(os.getenv("PAPERLESS_WEBSERVER_WORKERS", 1))
worker_class = "paperless.workers.ConfigurableWorker"
timeout = 120
preload_app = True
# https://docs.gunicorn.org/en/stable/faq.html#blocking-os-fchmod
worker_tmp_dir = "/dev/shm"
def pre_fork(server, worker):
pass
def pre_exec(server):
server.log.info("Forked child, re-executing.")
def when_ready(server):
server.log.info("Server is ready. Spawning workers")
def worker_int(worker):
worker.log.info("worker received INT or QUIT signal")
## get traceback info
import sys
import threading
import traceback
id2name = {th.ident: th.name for th in threading.enumerate()}
code = []
for threadId, stack in sys._current_frames().items():
code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
for filename, lineno, name, line in traceback.extract_stack(stack):
code.append(f'File: "{filename}", line {lineno}, in {name}')
if line:
code.append(f" {line.strip()}")
worker.log.debug("\n".join(code))
def worker_abort(worker):
worker.log.info("worker received SIGABRT signal")

355
pyproject.toml Normal file
View File

@ -0,0 +1,355 @@
[project]
name = "paperless-ngx"
version = "2.14.7"
description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
# TODO: Move certain things to groups and then utilize that further
# This will allow testing to not install a webserver, mysql, etc
dependencies = [
"bleach~=6.2.0",
"celery[redis]~=5.4.0",
"channels~=4.2",
"channels-redis~=4.2",
"concurrent-log-handler~=0.9.25",
"dateparser~=1.2",
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.1.6",
"django-allauth[socialaccount,mfa]~=65.4.0",
"django-auditlog~=3.0.0",
"django-celery-results~=2.5.1",
"django-compression-middleware~=0.5.0",
"django-cors-headers~=4.7.0",
"django-extensions~=3.2.3",
"django-filter~=25.1",
"django-guardian~=2.4.0",
"django-multiselectfield~=0.1.13",
"django-soft-delete~=1.0.18",
"djangorestframework~=3.15",
"djangorestframework-guardian~=0.3.0",
"drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2025.3.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.17.0",
"flower~=2.0.1",
"gotenberg-client~=0.9.0",
"httpx-oauth~=0.16",
"imap-tools~=1.10.0",
"inotifyrecursive~=0.3",
"jinja2~=3.1.5",
"langdetect~=1.0.9",
"nltk~=3.9.1",
"ocrmypdf~=16.10.0",
"pathvalidate~=3.2.3",
"pdf2image~=1.17.0",
"python-dateutil~=2.9.0",
"python-dotenv~=1.0.1",
"python-gnupg~=0.5.4",
"python-ipware~=3.0.0",
"python-magic~=0.4.27",
"pyzbar~=0.1.9",
"rapidfuzz~=3.12.1",
"redis[hiredis]~=5.2.1",
"scikit-learn~=1.6.1",
"setproctitle~=1.3.4",
"tika-client~=0.9.0",
"tqdm~=4.67.1",
"watchdog~=6.0",
"whitenoise~=6.9",
"whoosh~=2.7",
"zxing-cpp~=2.3.0",
]
optional-dependencies.mariadb = [
"mysqlclient~=2.2.7",
]
optional-dependencies.postgres = [
"psycopg[c]==3.2.5",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.2.5",
]
optional-dependencies.webserver = [
"granian~=2.0.1",
]
[dependency-groups]
dev = [
{ "include-group" = "docs" },
{ "include-group" = "testing" },
{ "include-group" = "lint" },
]
docs = [
"mkdocs-glightbox~=0.4.0",
"mkdocs-material~=9.6.4",
]
testing = [
"daphne",
"factory-boy~=3.3.1",
"imagehash",
"pytest~=8.3.3",
"pytest-cov~=6.0.0",
"pytest-django~=4.10.0",
"pytest-env",
"pytest-httpx",
"pytest-mock",
"pytest-rerunfailures",
"pytest-sugar",
"pytest-xdist",
]
lint = [
"pre-commit~=4.1.0",
"pre-commit-uv~=4.1.3",
"ruff~=0.9.9",
]
typing = [
"celery-types",
"django-filter-stubs",
"django-stubs[compatible-mypy]",
"djangorestframework-stubs[compatible-mypy]",
"mypy",
"types-bleach",
"types-colorama",
"types-dateparser",
"types-markdown",
"types-pygments",
"types-python-dateutil",
"types-redis",
"types-setuptools",
"types-tqdm",
]
[tool.ruff]
target-version = "py310"
line-length = 88
src = [
"src",
]
respect-gitignore = true
# https://docs.astral.sh/ruff/settings/
fix = true
show-fixes = true
output-format = "grouped"
# https://docs.astral.sh/ruff/rules/
lint.extend-select = [
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
]
lint.ignore = [
"DJ001",
"RUF012",
"SIM105",
]
# Migrations
lint.per-file-ignores."*/migrations/*.py" = [
"E501",
"SIM",
"T201",
]
# Testing
lint.per-file-ignores."*/tests/*.py" = [
"E501",
"SIM117",
]
lint.per-file-ignores.".github/scripts/*.py" = [
"E501",
"INP001",
"SIM117",
]
# Docker specific
lint.per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
"INP001",
"T201",
]
lint.per-file-ignores."docker/wait-for-redis.py" = [
"INP001",
"T201",
]
lint.per-file-ignores."src/documents/file_handling.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/management/commands/document_exporter.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/models.py" = [
"SIM115",
]
lint.per-file-ignores."src/documents/parsers.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/signals/handlers.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_file_handling.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_management.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_management_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_management_exporter.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_migration_archive_files.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_migration_document_pages_count.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_migration_mime_type.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_sanity_check.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/views.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless/checks.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless/settings.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless/views.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless_mail/mail.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
"PTH",
"RUF001",
] # TODO PTH Enable & remove
lint.isort.force-single-line = true
[tool.pytest.ini_options]
minversion = "8.0"
pythonpath = [
"src",
]
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_tesseract/tests/",
"src/paperless_tika/tests",
]
addopts = [
"--pythonwarnings=all",
"--cov",
"--cov-report=html",
"--cov-report=xml",
"--numprocesses=auto",
"--maxprocesses=16",
"--quiet",
"--durations=50",
"--junitxml=junit.xml",
"-o junit_family=legacy",
]
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings"
[tool.pytest_env]
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
[tool.coverage.run]
source = [
"src/",
]
omit = [
"*/tests/*",
"manage.py",
"paperless/wsgi.py",
"paperless/auth.py",
]
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.mypy]
plugins = [
"mypy_django_plugin.main",
"mypy_drf_plugin.main",
"numpy.typing.mypy_plugin",
]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
[tool.uv]
required-version = ">=0.5.14"
package = false
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.5/psycopg_c-3.2.5-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.5/psycopg_c-3.2.5-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
[tool.django-stubs]
django_settings_module = "paperless.settings"

View File

@ -9,7 +9,21 @@ Requires=redis.service
User=paperless User=paperless
Group=paperless Group=paperless
WorkingDirectory=/opt/paperless/src WorkingDirectory=/opt/paperless/src
ExecStart=/opt/paperless/.local/bin/gunicorn -c /opt/paperless/gunicorn.conf.py paperless.asgi:application
Environment=GRANIAN_HOST=::
Environment=GRANIAN_PORT=8000
Environment=GRANIAN_WORKERS=1
ExecStart=/bin/sh -c '\
# Host: GRANIAN_HOST -> PAPERLESS_BIND_ADDR -> default \
[ -n "$PAPERLESS_BIND_ADDR" ] && export GRANIAN_HOST=$PAPERLESS_BIND_ADDR; \
# Port: GRANIAN_PORT -> PAPERLESS_PORT -> default \
[ -n "$PAPERLESS_PORT" ] && export GRANIAN_PORT=$PAPERLESS_PORT; \
# Workers: GRANIAN_WORKERS -> PAPERLESS_WEBSERVER_WORKERS -> default \
[ -n "$PAPERLESS_WEBSERVER_WORKERS" ] && export GRANIAN_WORKERS=$PAPERLESS_WEBSERVER_WORKERS; \
# URL path prefix: only set if PAPERLESS_FORCE_SCRIPT_NAME exists \
[ -n "$PAPERLESS_FORCE_SCRIPT_NAME" ] && export GRANIAN_URL_PATH_PREFIX=$PAPERLESS_FORCE_SCRIPT_NAME; \
exec granian --interface asginl --ws "paperless.asgi:application"'
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

1
src-ui/.npmrc Normal file
View File

@ -0,0 +1 @@
shamefully-hoist=true

View File

@ -178,7 +178,8 @@
"schematicCollections": [ "schematicCollections": [
"@angular-eslint/schematics" "@angular-eslint/schematics"
], ],
"analytics": false "analytics": false,
"packageManager": "pnpm"
}, },
"schematics": { "schematics": {
"@angular-eslint/schematics:application": { "@angular-eslint/schematics:application": {

View File

@ -83,10 +83,17 @@ test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('button', { name: 'Dates' }).click() await page.getByRole('button', { name: 'Dates' }).click()
await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() await page.locator('.ng-arrow-wrapper').first().click()
await page.getByRole('option', { name: 'Within 3 months' }).click()
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() await page
await page.getByLabel('Datesselected').getByRole('button').first().click() .getByRole('menuitem', { name: 'Relative dates' })
.locator('span')
.first()
.click()
await page.getByRole('option', { name: 'Within 3 months' }).click()
await page.getByLabel('Dates selected').locator('button').first().click()
await page.getByLabel('Dates selected').locator('button').first().click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
await page.getByText('11', { exact: true }).click() await page.getByText('11', { exact: true }).click()

View File

@ -7,9 +7,20 @@ module.exports = {
'abstract-name-filter-service', 'abstract-name-filter-service',
'abstract-paperless-service', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`], transformIgnorePatterns: [
`<rootDir>/node_modules/.pnpm/(?!.*\\.mjs$|lodash-es)`,
],
moduleNameMapper: { moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1', '^src/(.*)': '<rootDir>/src/$1',
}, },
workerIdleMemoryLimit: '512MB', workerIdleMemoryLimit: '512MB',
reporters: [
'default',
[
'jest-junit',
{
classNameTemplate: '{filepath}/{classname}: {title}',
},
],
],
} }

File diff suppressed because it is too large Load Diff

19090
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@
"name": "paperless-ui", "name": "paperless-ui",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
@ -11,17 +12,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^19.1.2", "@angular/cdk": "^19.2.2",
"@angular/common": "~19.1.4", "@angular/common": "~19.2.1",
"@angular/compiler": "~19.1.4", "@angular/compiler": "~19.2.1",
"@angular/core": "~19.1.4", "@angular/core": "~19.2.1",
"@angular/forms": "~19.1.4", "@angular/forms": "~19.2.1",
"@angular/localize": "~19.1.4", "@angular/localize": "~19.2.1",
"@angular/platform-browser": "~19.1.4", "@angular/platform-browser": "~19.2.1",
"@angular/platform-browser-dynamic": "~19.1.4", "@angular/platform-browser-dynamic": "~19.2.1",
"@angular/router": "~19.1.4", "@angular/router": "~19.2.1",
"@ng-bootstrap/ng-bootstrap": "^18.0.0", "@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ng-select/ng-select": "^14.2.0", "@ng-select/ng-select": "^14.2.3",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
@ -29,46 +30,56 @@
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ng2-pdf-viewer": "^10.4.0", "ng2-pdf-viewer": "^10.4.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^9.0.0", "ngx-color": "^10.0.0",
"ngx-cookie-service": "^19.1.0", "ngx-cookie-service": "^19.1.2",
"ngx-device-detector": "^9.0.0", "ngx-device-detector": "^9.0.0",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^16.0.0", "ngx-ui-tour-ng-bootstrap": "^16.0.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"utif": "^3.1.0", "utif": "^3.1.0",
"uuid": "^11.0.5", "uuid": "^11.1.0",
"zone.js": "^0.15.0" "zone.js": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^19.0.0", "@angular-builders/custom-webpack": "^19.0.0",
"@angular-builders/jest": "^19.0.0", "@angular-builders/jest": "^19.0.0",
"@angular-devkit/build-angular": "^19.0.4", "@angular-devkit/build-angular": "^19.2.1",
"@angular-devkit/core": "^19.1.5", "@angular-devkit/core": "^19.2.1",
"@angular-devkit/schematics": "^19.1.5", "@angular-devkit/schematics": "^19.2.1",
"@angular-eslint/builder": "19.0.2", "@angular-eslint/builder": "19.2.1",
"@angular-eslint/eslint-plugin": "19.0.2", "@angular-eslint/eslint-plugin": "19.2.1",
"@angular-eslint/eslint-plugin-template": "19.0.2", "@angular-eslint/eslint-plugin-template": "19.2.1",
"@angular-eslint/schematics": "19.0.2", "@angular-eslint/schematics": "19.2.1",
"@angular-eslint/template-parser": "19.0.2", "@angular-eslint/template-parser": "19.2.1",
"@angular/cli": "~19.1.5", "@angular/cli": "~19.2.1",
"@angular/compiler-cli": "~19.1.4", "@angular/compiler-cli": "~19.2.1",
"@codecov/webpack-plugin": "^1.8.0", "@codecov/webpack-plugin": "^1.9.0",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.0", "@types/node": "^22.13.9",
"@typescript-eslint/eslint-plugin": "^8.22.0", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.22.0", "@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.0.0", "@typescript-eslint/utils": "^8.26.1",
"eslint": "^9.19.0", "eslint": "^9.22.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^14.4.2", "jest-junit": "^16.0.0",
"jest-preset-angular": "^14.5.3",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"canvas",
"esbuild",
"lmdb",
"msgpackr-extract"
]
},
"typings": "./src/typings.d.ts" "typings": "./src/typings.d.ts"
} }

View File

@ -21,7 +21,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
port, port,
command: 'npm run start', command: 'pnpm run start',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 2 * 60 * 1000, timeout: 2 * 60 * 1000,
}, },

12447
src-ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -36,7 +36,13 @@ export const routes: Routes = [
component: AppFrameComponent, component: AppFrameComponent,
canDeactivate: [DirtyDocGuard], canDeactivate: [DirtyDocGuard],
children: [ children: [
{ path: 'dashboard', component: DashboardComponent }, {
path: 'dashboard',
component: DashboardComponent,
data: {
componentName: 'AppFrameComponent',
},
},
{ {
path: 'documents', path: 'documents',
component: DocumentListComponent, component: DocumentListComponent,
@ -47,6 +53,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Document, type: PermissionType.Document,
}, },
componentName: 'DocumentListComponent',
}, },
}, },
{ {
@ -59,6 +66,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.SavedView, type: PermissionType.SavedView,
}, },
componentName: 'DocumentListComponent',
}, },
}, },
{ {
@ -70,6 +78,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Document, type: PermissionType.Document,
}, },
componentName: 'DocumentDetailComponent',
}, },
}, },
{ {
@ -81,6 +90,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Document, type: PermissionType.Document,
}, },
componentName: 'DocumentDetailComponent',
}, },
}, },
{ {
@ -92,6 +102,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Document, type: PermissionType.Document,
}, },
componentName: 'DocumentAsnComponent',
}, },
}, },
{ {
@ -103,6 +114,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Tag, type: PermissionType.Tag,
}, },
componentName: 'TagListComponent',
}, },
}, },
{ {
@ -114,6 +126,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.DocumentType, type: PermissionType.DocumentType,
}, },
componentName: 'DocumentTypeListComponent',
}, },
}, },
{ {
@ -125,6 +138,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Correspondent, type: PermissionType.Correspondent,
}, },
componentName: 'CorrespondentListComponent',
}, },
}, },
{ {
@ -136,6 +150,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.StoragePath, type: PermissionType.StoragePath,
}, },
componentName: 'StoragePathListComponent',
}, },
}, },
{ {
@ -144,6 +159,7 @@ export const routes: Routes = [
canActivate: [PermissionsGuard], canActivate: [PermissionsGuard],
data: { data: {
requireAdmin: true, requireAdmin: true,
componentName: 'LogsComponent',
}, },
}, },
{ {
@ -155,6 +171,7 @@ export const routes: Routes = [
action: PermissionAction.Delete, action: PermissionAction.Delete,
type: PermissionType.Document, type: PermissionType.Document,
}, },
componentName: 'TrashComponent',
}, },
}, },
// redirect old paths // redirect old paths
@ -180,6 +197,7 @@ export const routes: Routes = [
action: PermissionAction.Change, action: PermissionAction.Change,
type: PermissionType.UISettings, type: PermissionType.UISettings,
}, },
componentName: 'SettingsComponent',
}, },
}, },
{ {
@ -192,6 +210,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.UISettings, type: PermissionType.UISettings,
}, },
componentName: 'SettingsComponent',
}, },
}, },
{ {
@ -203,6 +222,7 @@ export const routes: Routes = [
action: PermissionAction.Change, action: PermissionAction.Change,
type: PermissionType.AppConfig, type: PermissionType.AppConfig,
}, },
componentName: 'ConfigComponent',
}, },
}, },
{ {
@ -214,6 +234,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.PaperlessTask, type: PermissionType.PaperlessTask,
}, },
componentName: 'TasksComponent',
}, },
}, },
{ {
@ -225,6 +246,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.CustomField, type: PermissionType.CustomField,
}, },
componentName: 'CustomFieldsComponent',
}, },
}, },
{ {
@ -236,6 +258,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.Workflow, type: PermissionType.Workflow,
}, },
componentName: 'WorkflowsComponent',
}, },
}, },
{ {
@ -247,6 +270,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.MailAccount, type: PermissionType.MailAccount,
}, },
componentName: 'MailComponent',
}, },
}, },
{ {
@ -258,6 +282,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.User, type: PermissionType.User,
}, },
componentName: 'UsersAndGroupsComponent',
}, },
}, },
{ {
@ -269,6 +294,7 @@ export const routes: Routes = [
action: PermissionAction.View, action: PermissionAction.View,
type: PermissionType.SavedView, type: PermissionType.SavedView,
}, },
componentName: 'SavedViewsComponent',
}, },
}, },
], ],

View File

@ -118,7 +118,7 @@
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<span i18n>Sidebar</span> <span i18n>Sidebar</span>
</div> </div>
@ -129,7 +129,7 @@
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<span i18n>Dark mode</span> <span i18n>Dark mode</span>
</div> </div>
@ -165,7 +165,7 @@
<p i18n> <p i18n>
Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually.
</p> </p>
<p> <p class="mb-0">
<em i18n>No tracking data is collected by the app in any way.</em> <em i18n>No tracking data is collected by the app in any way.</em>
</p> </p>
</ng-template> </ng-template>
@ -173,7 +173,7 @@
</div> </div>
<h5 class="mt-3" i18n>Saved Views</h5> <h5 class="mt-3" i18n>Saved Views</h5>
<div class="row mb-3"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check> <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
</div> </div>
@ -183,15 +183,15 @@
<div class="col-xl-6 ps-xl-5"> <div class="col-xl-6 ps-xl-5">
<h5 class="mt-3 mt-md-0" i18n>Document editing</h5> <h5 class="mt-3 mt-md-0" i18n>Document editing</h5>
<div class="row mb-3"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check> <pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row">
<div class="col-2"> <div class="col-md-3 col-form-label pt-0">
<span i18n>Default zoom:</span> <span i18n>Default zoom</span>
</div> </div>
<div class="col"> <div class="col">
<select class="form-select" formControlName="pdfViewerDefaultZoom"> <select class="form-select" formControlName="pdfViewerDefaultZoom">
@ -202,7 +202,7 @@
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check> <pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
</div> </div>
@ -214,10 +214,22 @@
</div> </div>
</div> </div>
<h5 class="mt-3" i18n>Notes</h5> <h5 class="mt-3" i18n>Global search</h5>
<div class="row mb-3"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Full search links to</span>
</div>
<div class="col mb-3">
<select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select>
</div> </div>
</div> </div>
@ -229,26 +241,10 @@
</div> </div>
</div> </div>
<h5 class="mt-3" i18n>Global search</h5> <h5 class="mt-3" i18n>Notes</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check> <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col">
<div class="row">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Full search links to</span>
</div>
<div class="col">
<select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
@ -267,8 +263,8 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<p i18n> <p i18n>
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
</p> </p>
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
@ -307,7 +303,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<span i18n>Default Edit Permissions</span> <span i18n>Default Edit Permissions</span>
</div> </div>
@ -346,7 +342,7 @@
<h5 i18n>Document processing</h5> <h5 i18n>Document processing</h5>
<div class="row mb-3"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check> <pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check> <pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>

View File

@ -303,12 +303,17 @@ describe('SettingsComponent', () => {
redis_error: redis_error:
'Error 61 connecting to localhost:6379. Connection refused.', 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR, celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK, index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(), index_last_modified: new Date().toISOString(),
index_error: null, index_error: null,
classifier_status: SystemStatusItemStatus.OK, classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(), classifier_last_trained: new Date().toISOString(),
classifier_error: null, classifier_error: null,
sanity_check_status: SystemStatusItemStatus.ERROR,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: 'Error running sanity check.',
}, },
} }
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
@ -320,6 +325,8 @@ describe('SettingsComponent', () => {
component['systemStatus'].database.status = SystemStatusItemStatus.OK component['systemStatus'].database.status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.sanity_check_status =
SystemStatusItemStatus.OK
expect(component.systemStatusHasErrors).toBeFalsy() expect(component.systemStatusHasErrors).toBeFalsy()
}) })

View File

@ -164,7 +164,10 @@ export class SettingsComponent
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR this.systemStatus.tasks.classifier_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.sanity_check_status ===
SystemStatusItemStatus.ERROR
) )
} }

View File

@ -19,6 +19,7 @@ import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { import {
PaperlessTask, PaperlessTask,
PaperlessTaskName,
PaperlessTaskStatus, PaperlessTaskStatus,
PaperlessTaskType, PaperlessTaskType,
} from 'src/app/data/paperless-task' } from 'src/app/data/paperless-task'
@ -39,7 +40,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'test.pdf', task_file_name: 'test.pdf',
date_created: new Date('2023-03-01T10:26:03.093116Z'), date_created: new Date('2023-03-01T10:26:03.093116Z'),
date_done: new Date('2023-03-01T10:26:07.223048Z'), date_done: new Date('2023-03-01T10:26:07.223048Z'),
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Failed, status: PaperlessTaskStatus.Failed,
result: 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)', result: 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
acknowledged: false, acknowledged: false,
@ -51,7 +53,8 @@ const tasks: PaperlessTask[] = [
task_file_name: '191092.pdf', task_file_name: '191092.pdf',
date_created: new Date('2023-03-01T09:26:03.093116Z'), date_created: new Date('2023-03-01T09:26:03.093116Z'),
date_done: new Date('2023-03-01T09:26:07.223048Z'), date_done: new Date('2023-03-01T09:26:07.223048Z'),
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Failed, status: PaperlessTaskStatus.Failed,
result: result:
'191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)', '191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
@ -64,7 +67,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'Scan Jun 6, 2023 at 3.19 PM.pdf', task_file_name: 'Scan Jun 6, 2023 at 3.19 PM.pdf',
date_created: new Date('2023-06-06T15:22:05.722323-07:00'), date_created: new Date('2023-06-06T15:22:05.722323-07:00'),
date_done: new Date('2023-06-06T15:22:14.564305-07:00'), date_done: new Date('2023-06-06T15:22:14.564305-07:00'),
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Pending, status: PaperlessTaskStatus.Pending,
result: null, result: null,
acknowledged: false, acknowledged: false,
@ -76,7 +80,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'paperless-mail-l4dkg8ir', task_file_name: 'paperless-mail-l4dkg8ir',
date_created: new Date('2023-06-04T11:24:32.898089-07:00'), date_created: new Date('2023-06-04T11:24:32.898089-07:00'),
date_done: new Date('2023-06-04T11:24:44.678605-07:00'), date_done: new Date('2023-06-04T11:24:44.678605-07:00'),
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete, status: PaperlessTaskStatus.Complete,
result: 'Success. New document id 422 created', result: 'Success. New document id 422 created',
acknowledged: false, acknowledged: false,
@ -88,7 +93,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'onlinePaymentSummary.pdf', task_file_name: 'onlinePaymentSummary.pdf',
date_created: new Date('2023-06-01T13:49:51.631305-07:00'), date_created: new Date('2023-06-01T13:49:51.631305-07:00'),
date_done: new Date('2023-06-01T13:49:54.190220-07:00'), date_done: new Date('2023-06-01T13:49:54.190220-07:00'),
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete, status: PaperlessTaskStatus.Complete,
result: 'Success. New document id 421 created', result: 'Success. New document id 421 created',
acknowledged: false, acknowledged: false,
@ -100,7 +106,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'paperless-mail-_rrpmqk6', task_file_name: 'paperless-mail-_rrpmqk6',
date_created: new Date('2023-06-07T02:54:35.694916Z'), date_created: new Date('2023-06-07T02:54:35.694916Z'),
date_done: null, date_done: null,
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Started, status: PaperlessTaskStatus.Started,
result: null, result: null,
acknowledged: false, acknowledged: false,
@ -155,7 +162,9 @@ describe('TasksComponent', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.detectChanges() fixture.detectChanges()
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}tasks/`) .expectOne(
`${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
.flush(tasks) .flush(tasks)
}) })

View File

@ -15,7 +15,7 @@
</svg> </svg>
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled"> <div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
@if (customAppTitle?.length) { @if (customAppTitle?.length) {
<div class="d-flex flex-column align-items-start"> <div class="d-flex flex-column align-items-start custom-title">
<span class="title">{{customAppTitle}}</span> <span class="title">{{customAppTitle}}</span>
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span> <span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
</div> </div>

View File

@ -244,7 +244,7 @@ main {
} }
} }
@media screen and (max-width: 768px) { @media screen and (min-width: 366px) and (max-width: 768px) {
.navbar-toggler { .navbar-toggler {
// compensate for 2 buttons on the right // compensate for 2 buttons on the right
margin-right: 45px; margin-right: 45px;
@ -257,6 +257,13 @@ main {
} }
} }
@media screen and (max-width: 345px) {
.custom-title {
max-width: 110px;
overflow: hidden;
}
}
:host ::ng-deep .dropdown.show .dropdown-toggle, :host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover { :host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7; opacity: 0.7;

View File

@ -21,7 +21,7 @@
} }
<div class="scroll-list"> <div class="scroll-list">
@for (toast of toasts; track toast.id) { @for (toast of toasts; track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast> <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
} }
</div> </div>
</div> </div>

View File

@ -28,10 +28,16 @@
</select> </select>
</div> </div>
<div class="form-check form-switch mt-4"> <div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback">
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments"> <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label> <label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
</div> </div>
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> @if (!archiveFallback) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">

View File

@ -29,6 +29,7 @@ export class MergeConfirmDialogComponent
implements OnInit implements OnInit
{ {
public documentIDs: number[] = [] public documentIDs: number[] = []
public archiveFallback: boolean = false
public deleteOriginals: boolean = false public deleteOriginals: boolean = false
private _documents: Document[] = [] private _documents: Document[] = []
get documents(): Document[] { get documents(): Document[] {

View File

@ -84,7 +84,7 @@ export class SplitConfirmDialogComponent
addSplit() { addSplit() {
if (this.page === this.totalPages) return if (this.page === this.totalPages) return
this.pages.add(this.page) this.pages.add(this.page)
this.pages = new Set(Array.from(this.pages).sort()) this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
this.confirmButtonEnabled = this.pages.size > 0 this.confirmButtonEnabled = this.pages.size > 0
} }

View File

@ -34,7 +34,7 @@ import {
CustomFieldQueryElement, CustomFieldQueryElement,
CustomFieldQueryExpression, CustomFieldQueryExpression,
} from 'src/app/utils/custom-field-query-element' } from 'src/app/utils/custom-field-query-element'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { DocumentLinkComponent } from '../input/document-link/document-link.component' import { DocumentLinkComponent } from '../input/document-link/document-link.component'
@ -183,7 +183,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
public CustomFieldDataType = CustomFieldDataType public CustomFieldDataType = CustomFieldDataType
public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
public popperOptions = popperOptionsReenablePreventOverflow public popperOptions = pngxPopperOptions
@Input() @Input()
title: string title: string

View File

@ -1,161 +1,158 @@
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions"> <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs> <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="row d-flex"> <h6 class="dropdown-header border-bottom" i18n>Created</h6>
<div class="col border-end"> <div class="list-group list-group-flush">
<div class="list-group list-group-flush"> <div class="list-group-item d-flex p-2 select-item" role="menuitem">
<h6 class="dropdown-header border-bottom" i18n>Created</h6> <div class="selected-icon">
@for (rd of relativeDates; track rd) { @if (createdRelativeDate) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
<div class="selected-icon"> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
@if (createdRelativeDate === rd.id) { <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
<i-bs width="1em" height="1em" name="check"></i-bs> </a>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
} }
<div class="list-group-item d-flex p-2" role="menuitem"> </div>
<div class="input-group input-group-sm small ps-1 pe-2">
<div class="selected-icon"> <ng-select class="w-100" name="createdRelativeDate"
@if (createdDateFrom) { [items]="relativeDates" [(ngModel)]="createdRelativeDate"
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> bindValue="id"
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> bindLabel="name"
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> clearable="false"
</a> placeholder="Relative dates"
} i18n-placeholder
</div> (change)="onSetCreatedRelativeDate($event)">
<div class="input-group input-group-sm small ps-1 pe-2"> <ng-template ng-option-tmp let-item="item">
<span class="input-group-text w-25 small text-muted" i18n>From</span> <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" </ng-template>
maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate"> </ng-select>
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (createdDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>
</div> </div>
</div> </div>
<div class="col"> <div class="list-group-item d-flex p-2" role="menuitem">
<h6 class="dropdown-header border-bottom" i18n>Added</h6> <div class="selected-icon">
<div class="list-group list-group-flush"> @if (createdDateFrom) {
@for (rd of relativeDates; track rd) { <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()">
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)"> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<div class="selected-icon"> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
@if (addedRelativeDate === rd.id) { </a>
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
} }
<div class="list-group-item d-flex p-2" role="menuitem"> </div>
<div class="input-group input-group-sm small ps-1 pe-2">
<div class="selected-icon"> <span class="input-group-text w-25 small text-muted" i18n>From</span>
@if (addedDateFrom) { <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</a> </button>
} <ng-template #createdFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button>
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> </ng-template>
<span class="input-group-text w-25 small text-muted" i18n>From</span> </div>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" </div>
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate"> <div class="list-group-item d-flex p-2" role="menuitem">
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button"> <div class="selected-icon">
<i-bs width="1em" height="1em" name="calendar"></i-bs> @if (createdDateTo) {
</button> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()">
<ng-template #addedFromFooterTemplate> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<div class="btn-group-xs border-top p-2 d-flex"> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
<button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button> </a>
<button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button> }
</div> </div>
</ng-template> <div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button>
</div> </div>
</ng-template>
</div>
</div> </div>
<div class="list-group-item d-flex p-2" role="menuitem"> </div>
<h6 class="dropdown-header border-bottom" i18n>Added</h6>
<div class="selected-icon"> <div class="list-group list-group-flush">
@if (addedDateTo) { <div class="list-group-item d-flex p-2 select-item" role="menuitem">
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> <div class="selected-icon">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> @if (addedRelativeDate) {
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()">
</a> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
} <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<ng-select class="w-100" name="addedRelativeDate"
[items]="relativeDates" [(ngModel)]="addedRelativeDate"
bindValue="id"
bindLabel="name"
clearable="false"
placeholder="Relative dates"
i18n-placeholder
(change)="onSetAddedRelativeDate($event)">
<ng-template ng-option-tmp let-item="item">
<div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
</ng-template>
</ng-select>
</div>
</div>
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (addedDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>From</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #addedFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button>
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> </ng-template>
<span class="input-group-text w-25 small text-muted" i18n>To</span> </div>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" </div>
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate"> <div class="list-group-item d-flex p-2" role="menuitem">
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button"> <div class="selected-icon">
<i-bs width="1em" height="1em" name="calendar"></i-bs> @if (addedDateTo) {
</button> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()">
<ng-template #addedToFooterTemplate> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<div class="btn-group-xs border-top p-2 d-flex"> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
<button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button> </a>
<button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button> }
</div> </div>
</ng-template> <div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #addedToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button>
</div> </div>
</ng-template>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,16 +1,7 @@
.date-dropdown { .date-dropdown {
--bs-dropdown-min-width: 22rem;
white-space: nowrap; white-space: nowrap;
@media(min-width: 768px) {
--bs-dropdown-min-width: 40rem;
}
@media screen and (max-width: 767px) {
.border-end {
border: none !important;
}
}
.btn-link { .btn-link {
line-height: 1; line-height: 1;
} }
@ -21,6 +12,10 @@
min-height: 1em; min-height: 1em;
} }
.select-item .selected-icon {
line-height: 2em;
}
.input-group-sm { .input-group-sm {
.form-control { .form-control {
font-size: 0.875rem; font-size: 0.875rem;

View File

@ -82,10 +82,12 @@ describe('DatesDropdownComponent', () => {
it('should support relative dates', fakeAsync(() => { it('should support relative dates', fakeAsync(() => {
let result: DateSelection let result: DateSelection
component.datesSet.subscribe((date) => (result = date)) component.datesSet.subscribe((date) => (result = date))
component.setCreatedRelativeDate(null) component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK) component.onSetCreatedRelativeDate({
component.setAddedRelativeDate(null) id: RelativeDate.WITHIN_1_WEEK,
component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK) } as any)
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
tick(500) tick(500)
expect(result).toEqual({ expect(result).toEqual({
createdFrom: null, createdFrom: null,
@ -147,8 +149,19 @@ describe('DatesDropdownComponent', () => {
expect(component.addedDateTo).toBeNull() expect(component.addedDateTo).toBeNull()
}) })
it('should support clearRelativeDate', () => {
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
component.clearCreatedRelativeDate()
expect(component.createdRelativeDate).toBeNull()
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK
component.clearAddedRelativeDate()
expect(component.addedRelativeDate).toBeNull()
})
it('should limit keyboard events', () => { it('should limit keyboard events', () => {
const input: HTMLInputElement = fixture.nativeElement.querySelector('input') const input: HTMLInputElement =
fixture.nativeElement.querySelector('input.form-control')
let event: KeyboardEvent = new KeyboardEvent('keypress', { let event: KeyboardEvent = new KeyboardEvent('keypress', {
key: '9', key: '9',
}) })
@ -163,4 +176,19 @@ describe('DatesDropdownComponent', () => {
input.dispatchEvent(event) input.dispatchEvent(event)
expect(eventSpy).toHaveBeenCalled() expect(eventSpy).toHaveBeenCalled()
}) })
it('should support debounce', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.onChangeDebounce()
tick(500)
expect(result).toEqual({
createdFrom: null,
createdTo: null,
createdRelativeDateID: null,
addedFrom: null,
addedTo: null,
addedRelativeDateID: null,
})
}))
}) })

View File

@ -13,13 +13,14 @@ import {
NgbDatepickerModule, NgbDatepickerModule,
NgbDropdownModule, NgbDropdownModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { debounceTime } from 'rxjs/operators' import { debounceTime } from 'rxjs/operators'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export interface DateSelection { export interface DateSelection {
@ -32,10 +33,14 @@ export interface DateSelection {
} }
export enum RelativeDate { export enum RelativeDate {
WITHIN_1_WEEK = 0, WITHIN_1_WEEK = 1,
WITHIN_1_MONTH = 1, WITHIN_1_MONTH = 2,
WITHIN_3_MONTHS = 2, WITHIN_3_MONTHS = 3,
WITHIN_1_YEAR = 3, WITHIN_1_YEAR = 4,
THIS_YEAR = 5,
THIS_MONTH = 6,
TODAY = 7,
YESTERDAY = 8,
} }
@Component({ @Component({
@ -49,13 +54,14 @@ export enum RelativeDate {
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDatepickerModule, NgbDatepickerModule,
NgbDropdownModule, NgbDropdownModule,
NgSelectModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgClass, NgClass,
], ],
}) })
export class DatesDropdownComponent implements OnInit, OnDestroy { export class DatesDropdownComponent implements OnInit, OnDestroy {
public popperOptions = popperOptionsReenablePreventOverflow public popperOptions = pngxPopperOptions
constructor(settings: SettingsService) { constructor(settings: SettingsService) {
this.datePlaceHolder = settings.getLocalizedDateInputFormat() this.datePlaceHolder = settings.getLocalizedDateInputFormat()
@ -82,44 +88,64 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
name: $localize`Within 1 year`, name: $localize`Within 1 year`,
date: new Date().setFullYear(new Date().getFullYear() - 1), date: new Date().setFullYear(new Date().getFullYear() - 1),
}, },
{
id: RelativeDate.THIS_YEAR,
name: $localize`This year`,
date: new Date('1/1/' + new Date().getFullYear()),
},
{
id: RelativeDate.THIS_MONTH,
name: $localize`This month`,
date: new Date().setDate(1),
},
{
id: RelativeDate.TODAY,
name: $localize`Today`,
date: new Date().setHours(0, 0, 0, 0),
},
{
id: RelativeDate.YESTERDAY,
name: $localize`Yesterday`,
date: new Date().setDate(new Date().getDate() - 1),
},
] ]
datePlaceHolder: string datePlaceHolder: string
// created // created
@Input() @Input()
createdDateTo: string createdDateTo: string = null
@Output() @Output()
createdDateToChange = new EventEmitter<string>() createdDateToChange = new EventEmitter<string>()
@Input() @Input()
createdDateFrom: string createdDateFrom: string = null
@Output() @Output()
createdDateFromChange = new EventEmitter<string>() createdDateFromChange = new EventEmitter<string>()
@Input() @Input()
createdRelativeDate: RelativeDate createdRelativeDate: RelativeDate = null
@Output() @Output()
createdRelativeDateChange = new EventEmitter<number>() createdRelativeDateChange = new EventEmitter<number>()
// added // added
@Input() @Input()
addedDateTo: string addedDateTo: string = null
@Output() @Output()
addedDateToChange = new EventEmitter<string>() addedDateToChange = new EventEmitter<string>()
@Input() @Input()
addedDateFrom: string addedDateFrom: string = null
@Output() @Output()
addedDateFromChange = new EventEmitter<string>() addedDateFromChange = new EventEmitter<string>()
@Input() @Input()
addedRelativeDate: RelativeDate addedRelativeDate: RelativeDate = null
@Output() @Output()
addedRelativeDateChange = new EventEmitter<number>() addedRelativeDateChange = new EventEmitter<number>()
@ -133,6 +159,9 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
@Input() @Input()
disabled: boolean = false disabled: boolean = false
@Input()
placement: string = 'bottom-start'
public readonly today: string = new Date().toISOString().split('T')[0] public readonly today: string = new Date().toISOString().split('T')[0]
get isActive(): boolean { get isActive(): boolean {
@ -172,17 +201,17 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.onChange() this.onChange()
} }
setCreatedRelativeDate(rd: RelativeDate) { onSetCreatedRelativeDate(rd: { id: number; name: string; date: number }) {
// createdRelativeDate is set by ngModel
this.createdDateTo = null this.createdDateTo = null
this.createdDateFrom = null this.createdDateFrom = null
this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
this.onChange() this.onChange()
} }
setAddedRelativeDate(rd: RelativeDate) { onSetAddedRelativeDate(rd: { id: number; name: string; date: number }) {
// addedRelativeDate is set by ngModel
this.addedDateTo = null this.addedDateTo = null
this.addedDateFrom = null this.addedDateFrom = null
this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
this.onChange() this.onChange()
} }
@ -224,6 +253,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.onChange() this.onChange()
} }
clearCreatedRelativeDate() {
this.createdRelativeDate = null
this.onChange()
}
clearAddedTo() { clearAddedTo() {
this.addedDateTo = null this.addedDateTo = null
this.onChange() this.onChange()
@ -234,6 +268,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.onChange() this.onChange()
} }
clearAddedRelativeDate() {
this.addedRelativeDate = null
this.onChange()
}
// prevent chars other than numbers and separators // prevent chars other than numbers and separators
onKeyPress(event: KeyboardEvent) { onKeyPress(event: KeyboardEvent) {
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {

View File

@ -189,6 +189,7 @@
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select> <pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select> <pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select> <pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
<pngx-input-custom-fields-values formControlName="assign_custom_fields_values" [selectedFields]="formGroup.get('assign_custom_fields').value" (removeSelectedField)="removeSelectedCustomField($event, formGroup)"></pngx-input-custom-fields-values>
</div> </div>
<div class="col"> <div class="col">
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select> <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>

View File

@ -2,7 +2,12 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of } from 'rxjs'
@ -369,4 +374,19 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.objectForm.get('actions').value[0].email).toBeNull() expect(component.objectForm.get('actions').value[0].email).toBeNull()
expect(component.objectForm.get('actions').value[0].webhook).toBeNull() expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
}) })
it('should remove selected custom field from the form group', () => {
const formGroup = new FormGroup({
assign_custom_fields: new FormControl([1, 2, 3]),
})
component.removeSelectedCustomField(2, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([1, 3])
component.removeSelectedCustomField(1, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([3])
component.removeSelectedCustomField(3, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([])
})
}) })

View File

@ -47,6 +47,7 @@ import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
import { EntriesComponent } from '../../input/entries/entries.component' import { EntriesComponent } from '../../input/entries/entries.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
@ -151,6 +152,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
SelectComponent, SelectComponent,
TextAreaComponent, TextAreaComponent,
TagsComponent, TagsComponent,
CustomFieldsValuesComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsUserComponent, PermissionsUserComponent,
ConfirmButtonComponent, ConfirmButtonComponent,
@ -439,6 +441,9 @@ export class WorkflowEditDialogComponent
assign_change_users: new FormControl(action.assign_change_users), assign_change_users: new FormControl(action.assign_change_users),
assign_change_groups: new FormControl(action.assign_change_groups), assign_change_groups: new FormControl(action.assign_change_groups),
assign_custom_fields: new FormControl(action.assign_custom_fields), assign_custom_fields: new FormControl(action.assign_custom_fields),
assign_custom_fields_values: new FormControl(
action.assign_custom_fields_values
),
remove_tags: new FormControl(action.remove_tags), remove_tags: new FormControl(action.remove_tags),
remove_all_tags: new FormControl(action.remove_all_tags), remove_all_tags: new FormControl(action.remove_all_tags),
remove_document_types: new FormControl(action.remove_document_types), remove_document_types: new FormControl(action.remove_document_types),
@ -565,6 +570,7 @@ export class WorkflowEditDialogComponent
assign_change_users: [], assign_change_users: [],
assign_change_groups: [], assign_change_groups: [],
assign_custom_fields: [], assign_custom_fields: [],
assign_custom_fields_values: {},
remove_tags: [], remove_tags: [],
remove_all_tags: false, remove_all_tags: false,
remove_document_types: [], remove_document_types: [],
@ -643,4 +649,12 @@ export class WorkflowEditDialogComponent
}) })
super.save() super.save()
} }
public removeSelectedCustomField(fieldId: number, group: FormGroup) {
group
.get('assign_custom_fields')
.setValue(
group.get('assign_custom_fields').value.filter((id) => id !== fieldId)
)
}
} }

View File

@ -0,0 +1,32 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="mb-1">
<label for="email" class="form-label" i18n>Email address(es)</label>
<input type="email" class="form-control" id="email" [(ngModel)]="emailAddress">
</div>
<div class="mb-1">
<label for="email" class="form-label" i18n>Subject</label>
<input type="email" class="form-control" id="subject" [(ngModel)]="emailSubject">
</div>
<div>
<label for="message" class="form-label" i18n>Message</label>
<textarea class="form-control" id="message" rows="3" [(ngModel)]="emailMessage"></textarea>
</div>
</div>
<div class="modal-footer">
<div class="input-group">
<div class="input-group-text flex-grow-1">
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
</div>
<button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
@if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
<ng-container i18n>Send email</ng-container>
</button>
</div>
</div>

View File

@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
import { EmailDocumentDialogComponent } from './email-document-dialog.component'
describe('EmailDocumentDialogComponent', () => {
let component: EmailDocumentDialogComponent
let fixture: ComponentFixture<EmailDocumentDialogComponent>
let documentService: DocumentService
let permissionsService: PermissionsService
let toastService: ToastService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
EmailDocumentDialogComponent,
IfPermissionsDirective,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
NgbActiveModal,
],
}).compileComponents()
fixture = TestBed.createComponent(EmailDocumentDialogComponent)
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should set hasArchiveVersion and useArchiveVersion', () => {
expect(component.hasArchiveVersion).toBeTruthy()
component.hasArchiveVersion = false
expect(component.hasArchiveVersion).toBeFalsy()
expect(component.useArchiveVersion).toBeFalsy()
})
it('should support sending document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
component.emailMessage = 'World'
jest
.spyOn(documentService, 'emailDocument')
.mockReturnValue(throwError(() => new Error('Unable to email document')))
component.emailDocument()
expect(toastErrorSpy).toHaveBeenCalled()
jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true))
component.emailDocument()
expect(toastSuccessSpy).toHaveBeenCalled()
})
it('should close the dialog', () => {
const activeModal = TestBed.inject(NgbActiveModal)
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,78 @@
import { Component, Input } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
selector: 'pngx-email-document-dialog',
templateUrl: './email-document-dialog.component.html',
styleUrl: './email-document-dialog.component.scss',
imports: [FormsModule, NgxBootstrapIconsModule],
})
export class EmailDocumentDialogComponent extends LoadingComponentWithPermissions {
@Input()
title = $localize`Email Document`
@Input()
documentId: number
private _hasArchiveVersion: boolean = true
@Input()
set hasArchiveVersion(value: boolean) {
this._hasArchiveVersion = value
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this._hasArchiveVersion
}
public useArchiveVersion: boolean = true
public emailAddress: string = ''
public emailSubject: string = ''
public emailMessage: string = ''
constructor(
private activeModal: NgbActiveModal,
private documentService: DocumentService,
private toastService: ToastService
) {
super()
this.loading = false
}
public emailDocument() {
this.loading = true
this.documentService
.emailDocument(
this.documentId,
this.emailAddress,
this.emailSubject,
this.emailMessage,
this.useArchiveVersion
)
.subscribe({
next: () => {
this.loading = false
this.emailAddress = ''
this.emailSubject = ''
this.emailMessage = ''
this.close()
this.toastService.showInfo($localize`Email sent`)
},
error: (e) => {
this.loading = false
this.toastService.showError($localize`Error emailing document`, e)
},
})
}
public close() {
this.activeModal.close()
}
}

View File

@ -7,6 +7,7 @@ import {
tick, tick,
} from '@angular/core/testing' } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { import {
DEFAULT_MATCHING_ALGORITHM, DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL, MATCH_ALL,
@ -44,6 +45,11 @@ const nullItem = {
name: 'Not assigned', name: 'Not assigned',
} }
const negativeNullItem = {
id: NEGATIVE_NULL_FILTER_VALUE,
name: 'Not assigned',
}
let selectionModel: FilterableDropdownSelectionModel let selectionModel: FilterableDropdownSelectionModel
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
@ -64,6 +70,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
hotkeyService = TestBed.inject(HotKeyService) hotkeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(FilterableDropdownComponent) fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance component = fixture.componentInstance
component.selectionModel = new FilterableDropdownSelectionModel()
selectionModel = new FilterableDropdownSelectionModel() selectionModel = new FilterableDropdownSelectionModel()
}) })
@ -74,7 +81,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support reset', () => { it('should support reset', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
expect(selectionModel.getSelectedItems()).toHaveLength(1) expect(selectionModel.getSelectedItems()).toHaveLength(1)
@ -96,7 +103,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should emit change when items selected', () => { it('should emit change when items selected', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -110,11 +117,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.set(items[0].id, ToggleableItemState.NotSelected) selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
expect(newModel.getSelectedItems()).toEqual([]) expect(newModel.getSelectedItems()).toEqual([])
expect(component.items).toEqual([nullItem, ...items]) expect(component.selectionModel.items).toEqual([nullItem, ...items])
}) })
it('should emit change when items excluded', () => { it('should emit change when items excluded', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -124,7 +131,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should emit change when items excluded', () => { it('should emit change when items excluded', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -139,8 +146,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should exclude items when excluded and not editing', () => { it('should exclude items when excluded and not editing', () => {
component.items = items component.selectionModel.items = items
component.manyToOne = true component.selectionModel.manyToOne = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
component.excludeClicked(items[0].id) component.excludeClicked(items[0].id)
@ -149,8 +156,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should toggle when items excluded and editing', () => { it('should toggle when items excluded and editing', () => {
component.items = items component.selectionModel.items = items
component.manyToOne = true component.selectionModel.manyToOne = true
component.editing = true component.editing = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.NotSelected) selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
@ -160,8 +167,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should hide count for item if adding will increase size of set', () => { it('should hide count for item if adding will increase size of set', () => {
component.items = items component.selectionModel.items = items
component.manyToOne = true component.selectionModel.manyToOne = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
expect(component.hideCount(items[0])).toBeFalsy() expect(component.hideCount(items[0])).toBeFalsy()
selectionModel.logicalOperator = LogicalOperator.Or selectionModel.logicalOperator = LogicalOperator.Or
@ -170,7 +177,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
it('should enforce single select when editing', () => { it('should enforce single select when editing', () => {
component.editing = true component.editing = true
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -182,11 +189,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support manyToOne selecting', () => { it('should support manyToOne selecting', () => {
component.items = items component.selectionModel.items = items
selectionModel.manyToOne = false selectionModel.manyToOne = false
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.manyToOne = true component.selectionModel.manyToOne = true
expect(component.manyToOne).toBeTruthy() expect(component.selectionModel.manyToOne).toBeTruthy()
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -197,12 +204,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should dynamically enable / disable modifier toggle', () => { it('should dynamically enable / disable modifier toggle', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
expect(component.modifierToggleEnabled).toBeTruthy() expect(component.modifierToggleEnabled).toBeTruthy()
selectionModel.toggle(null) component.selectionModel.manyToOne = true
expect(component.modifierToggleEnabled).toBeFalsy()
component.manyToOne = true
expect(component.modifierToggleEnabled).toBeFalsy() expect(component.modifierToggleEnabled).toBeFalsy()
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
selectionModel.toggle(items[1].id) selectionModel.toggle(items[1].id)
@ -210,7 +215,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should apply changes and close when apply button clicked', () => { it('should apply changes and close when apply button clicked', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
@ -232,7 +237,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should apply on close if enabled', () => { it('should apply on close if enabled', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.applyOnClose = true component.applyOnClose = true
@ -250,7 +255,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => { it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -277,7 +282,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => { it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
expect(component.selectionModel.getSelectedItems()).toEqual([]) expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement fixture.nativeElement
@ -297,7 +302,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => { it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
let applyResult: ChangedItems let applyResult: ChangedItems
@ -319,7 +324,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should support arrow keyboard navigation', fakeAsync(() => { it('should support arrow keyboard navigation', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -364,7 +369,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => { it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -400,7 +405,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should support arrow keyboard navigation after click', fakeAsync(() => { it('should support arrow keyboard navigation after click', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -425,9 +430,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should toggle logical operator', fakeAsync(() => { it('should toggle logical operator', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.manyToOne = true component.selectionModel.manyToOne = true
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected) selectionModel.set(items[1].id, ToggleableItemState.Selected)
component.selectionModel = selectionModel component.selectionModel = selectionModel
@ -454,7 +459,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should toggle intersection include / exclude', fakeAsync(() => { it('should toggle intersection include / exclude', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected) selectionModel.set(items[1].id, ToggleableItemState.Selected)
@ -483,22 +488,53 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(changedResult.getExcludedItems()).toEqual(items) expect(changedResult.getExcludedItems()).toEqual(items)
})) }))
it('selection model should sort items by state', () => { it('should update null item selection on toggleIntersection', () => {
component.items = items.concat([{ id: null, name: 'Null B' }]) component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.selectionModel.intersection = Intersection.Include
component.selectionModel.set(null, ToggleableItemState.Selected)
component.selectionModel.intersection = Intersection.Exclude
component.selectionModel.toggleIntersection()
expect(component.selectionModel.getExcludedItems()).toEqual([
negativeNullItem,
])
component.selectionModel.intersection = Intersection.Include
component.selectionModel.toggleIntersection()
expect(component.selectionModel.getSelectedItems()).toEqual([nullItem])
})
it('selection model should sort items by state', () => {
component.selectionModel = selectionModel
component.selectionModel.items = items.concat([{ id: 3, name: 'Item3' }])
selectionModel.toggle(items[1].id) selectionModel.toggle(items[1].id)
selectionModel.apply() selectionModel.apply()
expect(selectionModel.items.length).toEqual(4)
expect(selectionModel.items).toEqual([ expect(selectionModel.items).toEqual([
nullItem, nullItem,
{ id: null, name: 'Null B' },
items[1], items[1],
{ id: 3, name: 'Item3' },
items[0], items[0],
]) ])
selectionModel.intersection = Intersection.Exclude
selectionModel.toggleIntersection()
selectionModel.apply()
expect(selectionModel.items).toEqual([
negativeNullItem,
items[1],
{ id: 3, name: 'Item3' },
items[0],
])
// coverage
selectionModel.items = selectionModel.items.reverse()
selectionModel.apply()
}) })
it('selection model should sort items by state and document counts = 0, if set', () => { it('selection model should sort items by state and document counts = 0, if set', () => {
const tagA = { id: 4, name: 'Tag A' } const tagA = { id: 4, name: 'Tag A' }
component.items = items.concat([tagA]) component.selectionModel.items = items.concat([tagA])
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.documentCounts = [ component.documentCounts = [
{ id: 1, document_count: 0 }, // Tag1 { id: 1, document_count: 0 }, // Tag1
@ -529,7 +565,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should set support create, keep open model and call createRef method', fakeAsync(() => { it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.selectionModel = selectionModel component.selectionModel = selectionModel
fixture.nativeElement fixture.nativeElement
@ -549,7 +585,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => { it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.createRef = jest.fn() component.createRef = jest.fn()
@ -569,7 +605,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
const id = 1 const id = 1
const state = ToggleableItemState.Selected const state = ToggleableItemState.Selected
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.manyToOne = true component.selectionModel.manyToOne = true
component.selectionModel.singleSelect = true component.selectionModel.singleSelect = true
component.selectionModel.intersection = Intersection.Include component.selectionModel.intersection = Intersection.Include
component.selectionModel['temporarySelectionStates'].set(id, state) component.selectionModel['temporarySelectionStates'].set(id, state)
@ -596,7 +632,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support shortcut keys', () => { it('should support shortcut keys', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.shortcutKey = 't' component.shortcutKey = 't'
fixture.detectChanges() fixture.detectChanges()
@ -606,7 +642,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support an extra button and not apply changes when clicked', () => { it('should support an extra button and not apply changes when clicked', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.extraButtonTitle = 'Extra' component.extraButtonTitle = 'Extra'
component.selectionModel = selectionModel component.selectionModel = selectionModel

View File

@ -12,12 +12,13 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, filter, takeUntil } from 'rxjs' import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { FilterPipe } from 'src/app/pipes/filter.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { SelectionDataItem } from 'src/app/services/rest/document.service' import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { import {
@ -61,15 +62,56 @@ export class FilterableDropdownSelectionModel {
} }
set items(items: MatchingModel[]) { set items(items: MatchingModel[]) {
this._items = items if (items) {
this.sortItems() this._items = Array.from(items)
this.sortItems()
this.setNullItem()
}
}
private setNullItem() {
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
if (this._items[0]?.id === null) {
this._items.shift()
}
return
}
const item = {
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id:
this.manyToOne || this.intersection === Intersection.Include
? null
: NEGATIVE_NULL_FILTER_VALUE,
}
if (
this._items[0]?.id === null ||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
) {
this._items[0] = item
} else if (this._items) {
this._items.unshift(item)
}
}
constructor(manyToOne: boolean = false) {
this.manyToOne = manyToOne
} }
private sortItems() { private sortItems() {
this._items.sort((a, b) => { this._items.sort((a, b) => {
if (a.id == null && b.id != null) { if (
(a.id == null && b.id != null) ||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
b.id != NEGATIVE_NULL_FILTER_VALUE)
) {
return -1 return -1
} else if (a.id != null && b.id == null) { } else if (
(a.id != null && b.id == null) ||
(a.id != NEGATIVE_NULL_FILTER_VALUE &&
b.id == NEGATIVE_NULL_FILTER_VALUE)
) {
return 1 return 1
} else if ( } else if (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
@ -230,6 +272,7 @@ export class FilterableDropdownSelectionModel {
set logicalOperator(operator: LogicalOperator) { set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator = operator this.temporaryLogicalOperator = operator
this.setNullItem()
} }
toggleOperator() { toggleOperator() {
@ -242,6 +285,7 @@ export class FilterableDropdownSelectionModel {
set intersection(intersection: Intersection) { set intersection(intersection: Intersection) {
this.temporaryIntersection = intersection this.temporaryIntersection = intersection
this.setNullItem()
} }
toggleIntersection() { toggleIntersection() {
@ -250,9 +294,20 @@ export class FilterableDropdownSelectionModel {
this.intersection == Intersection.Include this.intersection == Intersection.Include
? ToggleableItemState.Selected ? ToggleableItemState.Selected
: ToggleableItemState.Excluded : ToggleableItemState.Excluded
this.temporarySelectionStates.forEach((state, key) => { this.temporarySelectionStates.forEach((state, key) => {
this.temporarySelectionStates.set(key, newState) if (key === null && this.intersection === Intersection.Exclude) {
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
} else if (
key === NEGATIVE_NULL_FILTER_VALUE &&
this.intersection === Intersection.Include
) {
this.temporarySelectionStates.set(null, newState)
} else {
this.temporarySelectionStates.set(key, newState)
}
}) })
this.changed.next(this) this.changed.next(this)
} }
@ -274,6 +329,7 @@ export class FilterableDropdownSelectionModel {
this.temporarySelectionStates.clear() this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
this.temporaryIntersection = this._intersection = Intersection.Include this.temporaryIntersection = this._intersection = Intersection.Include
this.setNullItem()
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
@ -305,8 +361,10 @@ export class FilterableDropdownSelectionModel {
isNoneSelected() { isNoneSelected() {
return ( return (
this.selectionSize() == 1 && (this.selectionSize() == 1 &&
this.get(null) == ToggleableItemState.Selected this.get(null) == ToggleableItemState.Selected) ||
(this.intersection == Intersection.Exclude &&
this.get(NEGATIVE_NULL_FILTER_VALUE) == ToggleableItemState.Excluded)
) )
} }
@ -380,29 +438,17 @@ export class FilterableDropdownComponent
@ViewChild('dropdown') dropdown: NgbDropdown @ViewChild('dropdown') dropdown: NgbDropdown
@ViewChild('buttonItems') buttonItems: ElementRef @ViewChild('buttonItems') buttonItems: ElementRef
public popperOptions = popperOptionsReenablePreventOverflow public popperOptions = pngxPopperOptions
filterText: string filterText: string
@Input() _selectionModel: FilterableDropdownSelectionModel
set items(items: MatchingModel[]) {
if (items) {
this._selectionModel.items = Array.from(items)
this._selectionModel.items.unshift({
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id: null,
})
}
}
get items(): MatchingModel[] { get items(): MatchingModel[] {
return this._selectionModel.items return this._selectionModel.items
} }
_selectionModel: FilterableDropdownSelectionModel = @Input({ required: true })
new FilterableDropdownSelectionModel()
@Input()
set selectionModel(model: FilterableDropdownSelectionModel) { set selectionModel(model: FilterableDropdownSelectionModel) {
if (this.selectionModel) { if (this.selectionModel) {
this.selectionModel.changed.complete() this.selectionModel.changed.complete()
@ -423,11 +469,6 @@ export class FilterableDropdownComponent
@Output() @Output()
selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>() selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
@Input()
set manyToOne(manyToOne: boolean) {
this.selectionModel.manyToOne = manyToOne
}
get manyToOne() { get manyToOne() {
return this.selectionModel.manyToOne return this.selectionModel.manyToOne
} }
@ -484,7 +525,7 @@ export class FilterableDropdownComponent
return this.manyToOne return this.manyToOne
? this.selectionModel.selectionSize() > 1 && ? this.selectionModel.selectionSize() > 1 &&
this.selectionModel.getExcludedItems().length == 0 this.selectionModel.getExcludedItems().length == 0
: !this.selectionModel.isNoneSelected() : true
} }
get name(): string { get name(): string {

View File

@ -0,0 +1,77 @@
<div class="list-group mt-3 selected-fields">
@for (fieldId of selectedFields; track fieldId) {
<div class="list-group-item
d-flex
justify-content-between
align-items-center">
@switch (getCustomField(fieldId)?.data_type) {
@case (CustomFieldDataType.String) {
<pngx-input-text [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-text>
}
@case (CustomFieldDataType.Date) {
<pngx-input-date [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-date>
}
@case (CustomFieldDataType.Integer) {
<pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"
[showAdd]="false"></pngx-input-number>
}
@case (CustomFieldDataType.Float) {
<pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"
[showAdd]="false"
[step]=".1"></pngx-input-number>
}
@case (CustomFieldDataType.Monetary) {
<pngx-input-monetary [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[defaultCurrency]="getCustomField(fieldId)?.extra_data?.default_currency"
class="flex-grow-1"
[horizontal]="true"></pngx-input-monetary>
}
@case (CustomFieldDataType.Boolean) {
<pngx-input-check [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-check>
}
@case (CustomFieldDataType.Url) {
<pngx-input-url [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-url>
}
@case (CustomFieldDataType.DocumentLink) {
<pngx-input-document-link [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-document-link>
}
@case (CustomFieldDataType.Select) {
<pngx-input-select [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[items]="getCustomField(fieldId)?.extra_data.select_options"
class="flex-grow-1"
bindLabel="label"
[allowNull]="true"
[horizontal]="true"></pngx-input-select>
}
}
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
<i-bs name="trash"></i-bs>
</button>
</div>
}
</div>

View File

@ -0,0 +1,3 @@
:host ::ng-deep .list-group-item .mb-3 {
margin-bottom: 0 !important;
}

View File

@ -0,0 +1,69 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { of } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomFieldsValuesComponent } from './custom-fields-values.component'
describe('CustomFieldsValuesComponent', () => {
let component: CustomFieldsValuesComponent
let fixture: ComponentFixture<CustomFieldsValuesComponent>
let customFieldsService: CustomFieldsService
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [FormsModule, ReactiveFormsModule, CustomFieldsValuesComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(CustomFieldsValuesComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
customFieldsService = TestBed.inject(CustomFieldsService)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
all: [1],
count: 1,
results: [
{
id: 1,
name: 'Field 1',
data_type: CustomFieldDataType.String,
} as CustomField,
],
})
)
fixture.detectChanges()
})
beforeEach(() => {
fixture = TestBed.createComponent(CustomFieldsValuesComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should set selectedFields and map values correctly', () => {
component.value = { 1: 'value1' }
component.selectedFields = [1, 2]
expect(component.selectedFields).toEqual([1, 2])
expect(component.value).toEqual({ 1: 'value1', 2: null })
})
it('should return the correct custom field by id', () => {
const field = component.getCustomField(1)
expect(field).toEqual({
id: 1,
name: 'Field 1',
data_type: CustomFieldDataType.String,
} as CustomField)
})
})

View File

@ -0,0 +1,90 @@
import {
Component,
EventEmitter,
forwardRef,
Input,
Output,
} from '@angular/core'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { RouterModule } from '@angular/router'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { AbstractInputComponent } from '../abstract-input'
import { CheckComponent } from '../check/check.component'
import { DateComponent } from '../date/date.component'
import { DocumentLinkComponent } from '../document-link/document-link.component'
import { MonetaryComponent } from '../monetary/monetary.component'
import { NumberComponent } from '../number/number.component'
import { SelectComponent } from '../select/select.component'
import { TextComponent } from '../text/text.component'
import { UrlComponent } from '../url/url.component'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomFieldsValuesComponent),
multi: true,
},
],
selector: 'pngx-input-custom-fields-values',
templateUrl: './custom-fields-values.component.html',
styleUrl: './custom-fields-values.component.scss',
imports: [
TextComponent,
DateComponent,
NumberComponent,
DocumentLinkComponent,
UrlComponent,
SelectComponent,
MonetaryComponent,
CheckComponent,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
NgxBootstrapIconsModule,
],
})
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {
public CustomFieldDataType = CustomFieldDataType
constructor(customFieldsService: CustomFieldsService) {
super()
customFieldsService.listAll().subscribe((items) => {
this.fields = items.results
})
}
private fields: CustomField[]
private _selectedFields: number[]
@Input()
set selectedFields(newFields: number[]) {
this._selectedFields = newFields
// map the selected fields to an object with field_id as key and value as value
this.value = newFields.reduce((acc, fieldId) => {
acc[fieldId] = this.value?.[fieldId] || null
return acc
}, {})
this.onChange(this.value)
}
get selectedFields(): number[] {
return this._selectedFields
}
@Output()
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
public getCustomField(id: number): CustomField {
return this.fields.find((field) => field.id === id)
}
}

View File

@ -1,14 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
</button>
</div>
<div class="modal-body">
<pngx-input-select [items]="objects" [title]="message" [(ngModel)]="selected"></pngx-input-select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)" i18n>Select</button>
</div>

View File

@ -1,36 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { SelectComponent } from '../input/select/select.component'
import { SelectDialogComponent } from './select-dialog.component'
describe('SelectDialogComponent', () => {
let component: SelectDialogComponent
let fixture: ComponentFixture<SelectDialogComponent>
let modal: NgbActiveModal
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [NgbActiveModal],
imports: [
NgSelectModule,
FormsModule,
ReactiveFormsModule,
SelectDialogComponent,
SelectComponent,
],
}).compileComponents()
modal = TestBed.inject(NgbActiveModal)
fixture = TestBed.createComponent(SelectDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should close modal on cancel', () => {
const closeSpy = jest.spyOn(modal, 'close')
component.cancelClicked()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@ -1,33 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { SelectComponent } from '../input/select/select.component'
@Component({
selector: 'pngx-select-dialog',
templateUrl: './select-dialog.component.html',
styleUrls: ['./select-dialog.component.scss'],
imports: [SelectComponent, FormsModule, ReactiveFormsModule],
})
export class SelectDialogComponent {
constructor(public activeModal: NgbActiveModal) {}
@Output()
public selectClicked = new EventEmitter()
@Input()
title = $localize`Select`
@Input()
message = $localize`Please select an object`
@Input()
objects: ObjectWithId[] = []
selected: number
cancelClicked() {
this.activeModal.close()
}
}

View File

@ -0,0 +1,68 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body p-0">
<ul class="list-group list-group-flush">
@if (!shareLinks || shareLinks.length === 0) {
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
No existing links
</li>
}
@for (link of shareLinks; track link) {
<li class="list-group-item">
<div class="input-group w-100">
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
@if (link.expiration) {
<span class="input-group-text">
{{ getDaysRemaining(link) }}
</span>
}
<button type="button" class="btn btn-outline-primary" (click)="copy(link)">
@if (copied !== link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
}
@if (copied === link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
@if (canShare(link)) {
<button type="button" class="btn btn-outline-primary" (click)="share(link)">
<i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
</button>
}
<button type="button" class="btn btn-outline-danger" (click)="delete(link)">
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
</li>
}
</ul>
</div>
<div class="modal-footer">
<div class="input-group w-100">
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
</div>
</div>
<div class="input-group w-100 mt-2">
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
<select class="form-select fs-6" [(ngModel)]="expirationDays">
@for (option of EXPIRATION_OPTIONS; track option) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
<button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
@if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
@if (!loading) {
<i-bs name="plus"></i-bs>
}
<ng-container i18n>Create</ng-container>
</button>
</div>
</div>

View File

@ -0,0 +1,3 @@
.copied-badge {
right: 15em;
}

View File

@ -11,17 +11,18 @@ import {
tick, tick,
} from '@angular/core/testing' } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link' import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { ShareLinkService } from 'src/app/services/rest/share-link.service' import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ShareLinksDropdownComponent } from './share-links-dropdown.component' import { ShareLinksDialogComponent } from './share-links-dialog.component'
describe('ShareLinksDropdownComponent', () => { describe('ShareLinksDialogComponent', () => {
let component: ShareLinksDropdownComponent let component: ShareLinksDialogComponent
let fixture: ComponentFixture<ShareLinksDropdownComponent> let fixture: ComponentFixture<ShareLinksDialogComponent>
let shareLinkService: ShareLinkService let shareLinkService: ShareLinkService
let toastService: ToastService let toastService: ToastService
let httpController: HttpTestingController let httpController: HttpTestingController
@ -30,16 +31,17 @@ describe('ShareLinksDropdownComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
ShareLinksDropdownComponent, ShareLinksDialogComponent,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
], ],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
NgbActiveModal,
], ],
}) })
fixture = TestBed.createComponent(ShareLinksDropdownComponent) fixture = TestBed.createComponent(ShareLinksDialogComponent)
shareLinkService = TestBed.inject(ShareLinkService) shareLinkService = TestBed.inject(ShareLinkService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
httpController = TestBed.inject(HttpTestingController) httpController = TestBed.inject(HttpTestingController)
@ -232,4 +234,11 @@ describe('ShareLinksDropdownComponent', () => {
] ]
).toBeTruthy() ).toBeTruthy()
}) })
it('should support close', () => {
const activeModal = TestBed.inject(NgbActiveModal)
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
}) })

View File

@ -1,7 +1,7 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, OnInit } from '@angular/core' import { Component, Input, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs' import { first } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link' import { FileVersion, ShareLink } from 'src/app/data/share-link'
@ -10,17 +10,12 @@ import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@Component({ @Component({
selector: 'pngx-share-links-dropdown', selector: 'pngx-share-links-dialog',
templateUrl: './share-links-dropdown.component.html', templateUrl: './share-links-dialog.component.html',
styleUrls: ['./share-links-dropdown.component.scss'], styleUrls: ['./share-links-dialog.component.scss'],
imports: [ imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule],
FormsModule,
ReactiveFormsModule,
NgbDropdownModule,
NgxBootstrapIconsModule,
],
}) })
export class ShareLinksDropdownComponent implements OnInit { export class ShareLinksDialogComponent implements OnInit {
EXPIRATION_OPTIONS = [ EXPIRATION_OPTIONS = [
{ label: $localize`1 day`, value: 1 }, { label: $localize`1 day`, value: 1 },
{ label: $localize`7 days`, value: 7 }, { label: $localize`7 days`, value: 7 },
@ -41,9 +36,6 @@ export class ShareLinksDropdownComponent implements OnInit {
} }
} }
@Input()
disabled: boolean = false
private _hasArchiveVersion: boolean = true private _hasArchiveVersion: boolean = true
@Input() @Input()
@ -67,6 +59,7 @@ export class ShareLinksDropdownComponent implements OnInit {
useArchiveVersion: boolean = true useArchiveVersion: boolean = true
constructor( constructor(
private activeModal: NgbActiveModal,
private shareLinkService: ShareLinkService, private shareLinkService: ShareLinkService,
private toastService: ToastService, private toastService: ToastService,
private clipboard: Clipboard private clipboard: Clipboard
@ -169,4 +162,8 @@ export class ShareLinksDropdownComponent implements OnInit {
}, },
}) })
} }
close() {
this.activeModal.close()
}
} }

View File

@ -1,70 +0,0 @@
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="link"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Share Links</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown">
<ul class="list-group list-group-flush">
@if (!shareLinks || shareLinks.length === 0) {
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
No existing links
</li>
}
@for (link of shareLinks; track link) {
<li class="list-group-item">
<div class="input-group input-group-sm w-100">
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
@if (link.expiration) {
<span class="input-group-text">
{{ getDaysRemaining(link) }}
</span>
}
<button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)">
@if (copied !== link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
}
@if (copied === link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
@if (canShare(link)) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)">
<i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
</button>
}
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)">
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
</li>
}
<li class="list-group-item pt-3 pb-2">
<div class="input-group input-group-sm w-100">
<div class="form-check form-switch ms-auto small">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
</div>
</div>
<div class="input-group input-group-sm w-100 mt-2">
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
<select class="form-select form-select-sm" [(ngModel)]="expirationDays">
@for (option of EXPIRATION_OPTIONS; track option) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
<button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
@if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
@if (!loading) {
<i-bs name="plus"></i-bs>
}
<ng-container i18n>Create</ng-container>
</button>
</div>
</li>
</ul>
</div>
</div>

View File

@ -1,14 +0,0 @@
.share-links-dropdown {
min-width: 350px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -175px !important;
}
}
}
.copied-badge {
right: 7.5em;
}

View File

@ -1,5 +1,5 @@
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5> <h6 class="modal-title" id="modal-basic-title" i18n>System Status</h6>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -11,11 +11,11 @@
</div> </div>
</div> </div>
} @else { } @else {
<div class="row row-cols-1 row-cols-md-3 g-3"> <div class="row row-cols-1 row-cols-md-4 g-3">
<div class="col"> <div class="col">
<div class="card bg-light h-100"> <div class="card bg-light h-100">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0" i18n>Environment</h5> <h6 class="card-title mb-0" i18n>Environment</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="card-text"> <dl class="card-text">
@ -38,37 +38,96 @@
<div class="col"> <div class="col">
<div class="card bg-light h-100"> <div class="card bg-light h-100">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0" i18n>Database</h5> <h6 class="card-title mb-0" i18n>Database</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="card-text"> <dl class="card-text">
<dt i18n>Type</dt> <dt i18n>Type</dt>
<dd>{{status.database.type}}</dd> <dd>{{status.database.type}}</dd>
<dt i18n>Status</dt> <dt i18n>Status</dt>
<dd class="d-flex align-items-center"> <dd>
{{status.database.status}} <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave">
@if (status.database.status === 'OK') { {{status.database.status}}
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.database.url}}" triggers="mouseenter:mouseleave"></i-bs> @if (status.database.status === 'OK') {
} @else { <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.database.url}}: {{status.database.error}}" triggers="mouseenter:mouseleave"></i-bs> } @else {
} <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
<ng-template #databaseStatus>
@if (status.database.status === 'OK') {
{{status.database.url}}
} @else {
{{status.database.url}}: {{status.database.error}}
}
</ng-template>
</dd> </dd>
<dt i18n>Migration Status</dt> <dt i18n>Migration Status</dt>
<dd class="d-flex align-items-center"> <dd>
@if (status.database.migration_status.unapplied_migrations.length === 0) { <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave">
<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> @if (status.database.migration_status.unapplied_migrations.length === 0) {
} @else { <ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
<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> } @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"></i-bs>
<ng-template #migrationStatus> }
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span> <ng-template #migrationStatus>
@if (status.database.migration_status.unapplied_migrations.length > 0) { <h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
<h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6> @if (status.database.migration_status.unapplied_migrations.length > 0) {
<ul> <h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
@for (migration of status.database.migration_status.unapplied_migrations; track migration) { <ul>
<li class="font-monospace small">{{migration}}</li> @for (migration of status.database.migration_status.unapplied_migrations; track migration) {
} <li class="font-monospace small">{{migration}}</li>
</ul> }
</ul>
}
</ng-template>
</button>
</dd>
</dl>
</div>
</div>
</div>
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h6 class="card-title mb-0" i18n>Tasks Queue</h6>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Redis Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
<ng-template #redisStatus>
@if (status.tasks.redis_status === 'OK') {
{{status.tasks.redis_url}}
} @else {
{{status.tasks.redis_url}}: {{status.tasks.redis_error}}
}
</ng-template>
</dd>
<dt i18n>Celery Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
<ng-template #celeryStatus>
@if (status.tasks.celery_status === 'OK') {
{{status.tasks.celery_url}}
} @else {
{{status.tasks.celery_error}}
} }
</ng-template> </ng-template>
</dd> </dd>
@ -80,63 +139,109 @@
<div class="col"> <div class="col">
<div class="card bg-light h-100"> <div class="card bg-light h-100">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0" i18n>Tasks</h5> <h6 class="card-title mb-0" i18n>Health</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="card-text"> <dl class="card-text">
<dt i18n>Redis Status</dt>
<dd class="d-flex align-items-center">
{{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}: {{status.tasks.redis_error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<dt i18n>Celery Status</dt>
<dd class="d-flex align-items-center">
{{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</dd>
<dt i18n>Search Index</dt> <dt i18n>Search Index</dt>
<dd class="d-flex align-items-center"> <dd class="d-flex align-items-center">
{{status.tasks.index_status}} <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
@if (status.tasks.index_status === 'OK') { {{status.tasks.index_status}}
@if (isStale(status.tasks.index_last_modified)) { @if (status.tasks.index_status === 'OK') {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs> @if (isStale(status.tasks.index_last_modified)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else { } @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs> <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@if (isRunning(PaperlessTaskName.IndexOptimize)) {
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
} @else {
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)">
<i-bs name="play-fill"></i-bs>&nbsp;
<ng-container i18n>Run Task</ng-container>
</button>
} }
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
} }
</dd> </dd>
<ng-template #indexStatus> <ng-template #indexStatus>
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span> @if (status.tasks.index_status === 'OK') {
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_error}}</span>
}
</ng-template> </ng-template>
<dt i18n>Classifier</dt> <dt i18n>Classifier</dt>
<dd class="d-flex align-items-center"> <dd class="d-flex align-items-center">
{{status.tasks.classifier_status}} <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave">
@if (status.tasks.classifier_status === 'OK') { {{status.tasks.classifier_status}}
@if (isStale(status.tasks.classifier_last_trained)) { @if (status.tasks.classifier_status === 'OK') {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs> @if (isStale(status.tasks.classifier_last_trained)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else { } @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1" <i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR" [class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING" [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
ngbPopover="{{status.tasks.classifier_error}}" }
triggers="mouseenter:mouseleave"></i-bs> </button>
@if (currentUserIsSuperUser) {
@if (isRunning(PaperlessTaskName.TrainClassifier)) {
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
} @else {
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)">
<i-bs name="play-fill"></i-bs>&nbsp;
<ng-container i18n>Run Task</ng-container>
</button>
}
} }
</dd> </dd>
<ng-template #classifierStatus> <ng-template #classifierStatus>
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span> @if (status.tasks.classifier_status === 'OK') {
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_error}}</span>
}
</ng-template>
<dt i18n>Sanity Checker</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.sanity_check_status}}
@if (status.tasks.sanity_check_status === 'OK') {
@if (isStale(status.tasks.sanity_check_last_run)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@if (isRunning(PaperlessTaskName.SanityCheck)) {
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
} @else {
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)">
<i-bs name="play-fill"></i-bs>&nbsp;
<ng-container i18n>Run Task</ng-container>
</button>
}
}
</dd>
<ng-template #sanityCheckerStatus>
@if (status.tasks.sanity_check_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_last_run | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
}
</ng-template> </ng-template>
</dl> </dl>
</div> </div>
@ -146,7 +251,7 @@
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-sm btn-outline-secondary" (click)="copy()"> <button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
@if (!copied) { @if (!copied) {
<i-bs name="clipboard-fill"></i-bs>&nbsp; <i-bs name="clipboard-fill"></i-bs>&nbsp;
} }

View File

@ -0,0 +1,3 @@
.btn.small {
font-size: 0.75rem;
}

View File

@ -9,11 +9,16 @@ import {
} from '@angular/core/testing' } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import { import {
InstallType, InstallType,
SystemStatus, SystemStatus,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { SystemStatusDialogComponent } from './system-status-dialog.component' import { SystemStatusDialogComponent } from './system-status-dialog.component'
const status: SystemStatus = { const status: SystemStatus = {
@ -36,12 +41,17 @@ const status: SystemStatus = {
redis_status: SystemStatusItemStatus.ERROR, redis_status: SystemStatusItemStatus.ERROR,
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.', redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR, celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK, index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(), index_last_modified: new Date().toISOString(),
index_error: null, index_error: null,
classifier_status: SystemStatusItemStatus.OK, classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(), classifier_last_trained: new Date().toISOString(),
classifier_error: null, classifier_error: null,
sanity_check_status: SystemStatusItemStatus.OK,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: null,
}, },
} }
@ -49,6 +59,9 @@ describe('SystemStatusDialogComponent', () => {
let component: SystemStatusDialogComponent let component: SystemStatusDialogComponent
let fixture: ComponentFixture<SystemStatusDialogComponent> let fixture: ComponentFixture<SystemStatusDialogComponent>
let clipboard: Clipboard let clipboard: Clipboard
let tasksService: TasksService
let systemStatusService: SystemStatusService
let toastService: ToastService
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@ -67,6 +80,9 @@ describe('SystemStatusDialogComponent', () => {
component = fixture.componentInstance component = fixture.componentInstance
component.status = status component.status = status
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
tasksService = TestBed.inject(TasksService)
systemStatusService = TestBed.inject(SystemStatusService)
toastService = TestBed.inject(ToastService)
fixture.detectChanges() fixture.detectChanges()
}) })
@ -93,4 +109,37 @@ describe('SystemStatusDialogComponent', () => {
expect(component.isStale(date.toISOString())).toBeTruthy() expect(component.isStale(date.toISOString())).toBeTruthy()
expect(component.isStale(date.toISOString(), 26)).toBeFalsy() expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
}) })
it('should check if task is running', () => {
component.runTask(PaperlessTaskName.IndexOptimize)
expect(component.isRunning(PaperlessTaskName.IndexOptimize)).toBeTruthy()
expect(component.isRunning(PaperlessTaskName.SanityCheck)).toBeFalsy()
})
it('should support running tasks, refresh status and show toasts', () => {
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const getStatusSpy = jest.spyOn(systemStatusService, 'get')
const runSpy = jest.spyOn(tasksService, 'run')
// fail first
runSpy.mockReturnValue(throwError(() => new Error('error')))
component.runTask(PaperlessTaskName.IndexOptimize)
expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
expect(toastErrorSpy).toHaveBeenCalledWith(
`Failed to start task ${PaperlessTaskName.IndexOptimize}, see the logs for more details`,
expect.any(Error)
)
// succeed
runSpy.mockReturnValue(of({}))
getStatusSpy.mockReturnValue(of(status))
component.runTask(PaperlessTaskName.IndexOptimize)
expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
expect(getStatusSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith(
`Task ${PaperlessTaskName.IndexOptimize} started`
)
})
}) })

View File

@ -7,12 +7,17 @@ import {
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import { import {
SystemStatus, SystemStatus,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe' import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { PermissionsService } from 'src/app/services/permissions.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'pngx-system-status-dialog', selector: 'pngx-system-status-dialog',
@ -30,13 +35,24 @@ import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
}) })
export class SystemStatusDialogComponent { export class SystemStatusDialogComponent {
public SystemStatusItemStatus = SystemStatusItemStatus public SystemStatusItemStatus = SystemStatusItemStatus
public PaperlessTaskName = PaperlessTaskName
public status: SystemStatus public status: SystemStatus
public copied: boolean = false public copied: boolean = false
private runningTasks: Set<PaperlessTaskName> = new Set()
get currentUserIsSuperUser(): boolean {
return this.permissionsService.isSuperUser()
}
constructor( constructor(
public activeModal: NgbActiveModal, public activeModal: NgbActiveModal,
private clipboard: Clipboard private clipboard: Clipboard,
private systemStatusService: SystemStatusService,
private tasksService: TasksService,
private toastService: ToastService,
private permissionsService: PermissionsService
) {} ) {}
public close() { public close() {
@ -56,4 +72,30 @@ export class SystemStatusDialogComponent {
const now = new Date() const now = new Date()
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000 return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
} }
public isRunning(taskName: PaperlessTaskName): boolean {
return this.runningTasks.has(taskName)
}
public runTask(taskName: PaperlessTaskName) {
this.runningTasks.add(taskName)
this.toastService.showInfo(`Task ${taskName} started`)
this.tasksService.run(taskName).subscribe({
next: () => {
this.runningTasks.delete(taskName)
this.systemStatusService.get().subscribe({
next: (status) => {
this.status = status
},
})
},
error: (err) => {
this.runningTasks.delete(taskName)
this.toastService.showError(
`Failed to start task ${taskName}, see the logs for more details`,
err
)
},
})
}
} }

View File

@ -33,7 +33,7 @@
} }
<div class="row"> <div class="row">
<div class="col offset-sm-3"> <div class="col offset-sm-3">
<button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)"> <button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)">
@if (!copied) { @if (!copied) {
<i-bs name="clipboard"></i-bs>&nbsp; <i-bs name="clipboard"></i-bs>&nbsp;
} }
@ -48,9 +48,9 @@
</details> </details>
} }
@if (toast.action) { @if (toast.action) {
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p> <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
} }
</div> </div>
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button> <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
</div> </div>
</ngb-toast> </ngb-toast>

View File

@ -27,7 +27,7 @@ export class ToastComponent {
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>() @Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() close: EventEmitter<Toast> = new EventEmitter<Toast>() @Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
public copied: boolean = false public copied: boolean = false

View File

@ -1,3 +1,3 @@
@for (toast of toasts; track toast.id) { @for (toast of toasts; track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast> <pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
} }

View File

@ -34,6 +34,17 @@
} }
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
@if (!settingsService.offerTour() && savedViewService.allViews.length === 0) {
<div class="col">
<div class="card shadow-sm bg-light opacity-50">
<div class="card-body">
<div class="text-center">
<p class="mb-0 fst-italic"><i-bs name="info-circle" class="me-2"></i-bs><ng-container i18n>Hint: saved views can be created from the <a routerLink="/documents">documents list</a></ng-container></p>
</div>
</div>
</div>
</div>
}
@for (v of dashboardViews; track v.id) { @for (v of dashboardViews; track v.id) {
<div class="col"> <div class="col">
<pngx-saved-view-widget <pngx-saved-view-widget
@ -49,12 +60,8 @@
</div> </div>
<div class="col-12 col-lg-4 col-xl-3 col-sidebar"> <div class="col-12 col-lg-4 col-xl-3 col-sidebar">
<div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0"> <div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0">
<div class="col"> <pngx-upload-file-widget></pngx-upload-file-widget>
<pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget> <pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
</div>
<div class="col">
<pngx-upload-file-widget></pngx-upload-file-widget>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -105,6 +105,7 @@ describe('DashboardComponent', () => {
results: saved_views, results: saved_views,
}), }),
dashboardViews: saved_views.filter((v) => v.show_on_dashboard), dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
allViews: saved_views,
}, },
}, },
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),

View File

@ -6,6 +6,8 @@ import {
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { RouterModule } from '@angular/router'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@ -35,6 +37,8 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
IfPermissionsDirective, IfPermissionsDirective,
DragDropModule, DragDropModule,
TourNgBootstrapModule, TourNgBootstrapModule,
NgxBootstrapIconsModule,
RouterModule,
], ],
}) })
export class DashboardComponent extends ComponentWithPermissions { export class DashboardComponent extends ComponentWithPermissions {

View File

@ -55,7 +55,7 @@
} }
@case (DisplayField.TAGS) { @case (DisplayField.TAGS) {
@for (tagID of doc.tags; track tagID) { @for (tagID of doc.tags; track tagID) {
<pngx-tag [tagID]="tagID" class="ms-1" (click)="clickTag(tagID, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag> <pngx-tag [tagID]="tagID" class="ms-1 fs-6" (click)="clickTag(tagID, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
} }
} }
@case (DisplayField.DOCUMENT_TYPE) { @case (DisplayField.DOCUMENT_TYPE) {

View File

@ -1,12 +1,15 @@
<pngx-widget-frame title="Upload new documents" i18n-title *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }"> <pngx-widget-frame *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" [cardless]="true">
<div content tourAnchor="tour.upload-widget"> <div content tourAnchor="tour.upload-widget">
<form class="justify-content-center d-flex flex-column align-items-center py-3 px-2"> <form class="justify-content-center d-flex flex-column align-items-center">
<span class="text-muted" i18n>Drop documents anywhere or</span> <button type="button" class="btn btn-outline-dark bg-light shadow-sm w-100 h-100 pt-3 pb-3" (click)="fileUpload.click()">
<button type="button" class="btn btn-sm btn-outline-primary mt-3" (click)="fileUpload.click()" i18n>Browse files</button> <i-bs class="text-primary" name="plus-circle"></i-bs>&nbsp;
<span class="text-primary" i18n>Upload documents</span>
<div class="text-muted smaller fst-italic" i18n>or drop files anywhere</div>
</button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload> <input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form> </form>
@if (getStatus().length > 0) { @if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40" [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 consumer-status-list" [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 overflow-y-scroll"> <div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
<div class="card shadow-sm consumer-status-card"> <div class="card shadow-sm consumer-status-card">
<div class="card-body"> <div class="card-body">
@ -30,24 +33,6 @@
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container> <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div> </div>
} }
@if (getStatusHidden().length) {
<div class="alerts-hidden">
@if (!alertsExpanded) {
<p class="mt-3 mb-0 text-center">
<span i18n="This is shown as a summary line when there are more than 5 document in the processing pipeline.">{getStatusHidden().length, plural, =1 {One more document} other {{{getStatusHidden().length}} more documents}}</span>
&nbsp;&bull;&nbsp;
<a [routerLink]="[]" (click)="alertsExpanded = !alertsExpanded" aria-controls="hiddenAlerts" [attr.aria-expanded]="alertsExpanded" i18n>Show all</a>
</p>
}
<div #hiddenAlerts="ngbCollapse" [ngbCollapse]="!alertsExpanded" (ngbCollapseChange)="alertsExpanded = $event">
@for (status of getStatusHidden(); track status) {
<div>
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div>
}
</div>
</div>
}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,13 @@
form { :host ::ng-deep i-bs svg {
position: relative; margin-top: -3px;
}
.btn-outline-dark {
--bs-btn-border-color: var(--bs-border-color-translucent);
}
.smaller {
font-size: 0.75rem;
} }
.alert-heading { .alert-heading {
@ -40,6 +48,10 @@ form {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important; background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
} }
.max-vh100-40 { .consumer-status-list {
max-height: calc(100vh - 40px); max-height: calc(100vh - 312px); // e.g. below the upload button, mobile
@media screen and (min-width: 768px) {
max-height: calc(100vh - 208px); // e.g. below the upload button
}
} }

View File

@ -8,7 +8,6 @@ import {
} from '@angular/core/testing' } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
@ -116,20 +115,6 @@ describe('UploadFileWidgetComponent', () => {
expect(component.getStatusColor(successStatus)).toEqual('success') expect(component.getStatusColor(successStatus)).toEqual('success')
}) })
it('should enforce a maximum number of alerts', () => {
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
// 5 total, 1 hidden
expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
6
)
expect(
fixture.debugElement
.query(By.directive(NgbCollapse))
.queryAll(By.directive(NgbAlert))
).toHaveLength(1)
})
it('should allow dismissing an alert', () => { it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus()) component.dismiss(new FileStatus())
@ -138,7 +123,6 @@ describe('UploadFileWidgetComponent', () => {
it('should allow dismissing completed alerts', fakeAsync(() => { it('should allow dismissing completed alerts', fakeAsync(() => {
mockConsumerStatuses(websocketStatusService) mockConsumerStatuses(websocketStatusService)
component.alertsExpanded = true
fixture.detectChanges() fixture.detectChanges()
jest jest
.spyOn(component, 'getStatusCompleted') .spyOn(component, 'getStatusCompleted')

View File

@ -4,7 +4,6 @@ import { RouterModule } from '@angular/router'
import { import {
NgbAlert, NgbAlert,
NgbAlertModule, NgbAlertModule,
NgbCollapseModule,
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@ -21,8 +20,6 @@ import {
} from 'src/app/services/websocket-status.service' } from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
const MAX_ALERTS = 5
@Component({ @Component({
selector: 'pngx-upload-file-widget', selector: 'pngx-upload-file-widget',
templateUrl: './upload-file-widget.component.html', templateUrl: './upload-file-widget.component.html',
@ -34,15 +31,12 @@ const MAX_ALERTS = 5
NgTemplateOutlet, NgTemplateOutlet,
RouterModule, RouterModule,
NgbAlertModule, NgbAlertModule,
NgbCollapseModule,
NgbProgressbarModule, NgbProgressbarModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
TourNgBootstrapModule, TourNgBootstrapModule,
], ],
}) })
export class UploadFileWidgetComponent extends ComponentWithPermissions { export class UploadFileWidgetComponent extends ComponentWithPermissions {
alertsExpanded = false
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert> @ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
constructor( constructor(
@ -54,7 +48,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
} }
getStatus() { getStatus() {
return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS) return this.websocketStatusService.getConsumerStatus()
} }
getStatusSummary() { getStatusSummary() {
@ -77,13 +71,6 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
) )
} }
getStatusHidden() {
if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS)
return []
else
return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS)
}
getStatusUploading() { getStatusUploading() {
return this.websocketStatusService.getConsumerStatus( return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING FileStatusPhase.UPLOADING

View File

@ -1,23 +1,32 @@
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent"> @if (!cardless) {
<div class="card-header"> <div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
<div class="d-flex justify-content-between align-items-center"> <div class="card-header">
<div class="d-flex"> <div class="d-flex justify-content-between align-items-center">
@if (draggable) { <div class="d-flex">
<div class="ms-n2 me-1" cdkDragHandle> @if (draggable) {
<i-bs name="grip-vertical"></i-bs> <div class="ms-n2 me-1" cdkDragHandle>
</div> <i-bs name="grip-vertical"></i-bs>
</div>
}
<h6 class="card-title mb-0">{{title}}</h6>
</div>
@if (loading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
} }
<h6 class="card-title mb-0">{{title}}</h6> <ng-content select="[header-buttons]"></ng-content>
</div> </div>
@if (loading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
}
<ng-content select="[header-buttons]"></ng-content>
</div> </div>
<div class="card-body text-dark">
<ng-container [ngTemplateOutlet]="content"></ng-container>
</div>
</div>
} @else {
<div class="fade" [class.show]="show">
<ng-container [ngTemplateOutlet]="content"></ng-container>
</div>
}
</div> <ng-template #content>
<div class="card-body text-dark"> <ng-content select="[content]"></ng-content>
<ng-content select="[content]"></ng-content> </ng-template>
</div>
</div>

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