Compare commits

..

47 Commits

Author SHA1 Message Date
shamoon cdfb67f71b some edit dialogs to signals 2026-07-07 18:35:53 -07:00
shamoon c3a2794a01 permissions dialogs etc 2026-07-07 17:00:59 -07:00
shamoon 4dd84d7367 trash 2026-07-07 17:00:44 -07:00
shamoon 2f3e409a2e signals for logs and workflow components 2026-07-07 17:00:21 -07:00
shamoon b1e81615b0 signals for settings display fields 2026-07-07 16:59:46 -07:00
shamoon 1233a33b66 signals for open docuemtns service, saved views 2026-07-07 16:58:56 -07:00
shamoon f2bbd8bb65 Add signal handling to app frame, global search 2026-07-07 16:58:09 -07:00
shamoon 5a93d46a0c Convert management component, custom fields to signals 2026-07-07 16:56:23 -07:00
shamoon cca4d3dbb7 Fix doc detail view, touches settings too 2026-07-07 07:56:33 -07:00
shamoon 2b48ebaa59 Fix sidebar via saved view + tasks service signals 2026-07-06 20:24:51 -07:00
shamoon da23edc9eb Fix tasks / users-groups and mail 2026-07-06 20:19:13 -07:00
shamoon 872465ee8a Add back the nice fade-in 2026-07-06 20:13:41 -07:00
shamoon 3755070e1f Update some global components with loading signals 2026-07-06 20:09:58 -07:00
shamoon 0c5cfb2254 remove deprecated pnpm stuff from package.json 2026-07-05 18:03:32 -07:00
shamoon f3edc1ac36 Ok first real chunk of zoneless pngx
document list
dashboard / widget frame
stats
2026-07-04 09:19:37 -07:00
shamoon 2caa6ff43f Use zoneless 2026-07-04 09:19:07 -07:00
shamoon 56cc2146cd Remove zone import from polyfill 2026-07-04 09:18:59 -07:00
shamoon 040eacc189 Mechanical stuff for angular 22 2026-07-04 08:28:24 -07:00
dependabot[bot] 31d28d87db Chore(deps): Bump the actions group across 1 directory with 14 updates (#13079)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 22:21:19 +00:00
GitHub Actions 8ad321b743 Auto translate strings 2026-07-02 22:03:59 +00:00
dependabot[bot] ea1102b7f2 Chore(deps): Bump pdfjs-dist from 5.7.284 to 6.0.227 in /src-ui (#13080)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 22:02:20 +00:00
dependabot[bot] 627875b1cf Chore(deps-dev): Bump @playwright/test from 1.60.0 to 1.61.1 in /src-ui (#13078)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 08:25:57 -07:00
dependabot[bot] 7ffaba6aac Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates (#13076)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 15:23:09 +00:00
dependabot[bot] fe81f3ee98 Chore(deps): Bump uuid from 14.0.0 to 14.0.1 in /src-ui (#13077)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 15:07:48 +00:00
dependabot[bot] ed9372c808 Chore(deps-dev): Bump jest-preset-angular from 16.1.5 to 17.0.0 in /src-ui in the frontend-jest-dependencies group (#13075)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 14:50:59 +00:00
dependabot[bot] b804da0140 Chore(deps-dev): Bump @types/node from 25.9.1 to 26.0.0 in /src-ui (#13081)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 07:30:21 -07:00
NgoQuocViet2001 67972a0740 Fix (beta): fix sanity checker affected document counts (#13052)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-06-27 15:55:38 -07:00
shamoon a8bfa25efd Fix (beta): preserve Unicode in localization prompt (#13055) 2026-06-27 05:53:36 -07:00
shamoon 00baacb26c Update SECURITY.md to clarify design choice 2026-06-23 23:37:55 -07:00
GitHub Actions 63c19e7f75 Auto translate strings 2026-06-23 14:34:24 +00:00
shamoon bf70e597ee Merge branch 'beta' into dev 2026-06-23 07:32:33 -07:00
shamoon 78824665aa Add tomli to pyproject-fmt hook 2026-06-23 07:32:03 -07:00
shamoon e75946847e Fix: include last-modified in doc etag (#13044) 2026-06-22 18:18:57 -07:00
Trenton H 7bf2a9ff82 Fix (beta): Stream chunks during compaction to prevent oom on smaller installs (#13014) 2026-06-19 15:40:16 +00:00
Trenton H bb5d7438b1 Documentation (beta): Updates documentation for new v3 features (#13033)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-06-18 16:20:31 -07:00
Trenton H a009ea1f04 Chore(beta): Update suggested broker to Valkey + Redis agnostic documentation (#13032)
Co-authored-by: upmcplanetracker <219436948+upmcplanetracker@users.noreply.github.com>
2026-06-18 19:54:15 +00:00
shamoon 0cdf718d9f Fix (beta): truncate embedding queries for small chunk size (#13028) 2026-06-18 16:15:40 +00:00
GitHub Actions 20a220ef6c Auto translate strings 2026-06-16 00:04:24 +00:00
dependabot[bot] e83996135a Chore(deps): Bump the npm_and_yarn group across 1 directory with 3 updates (#13016)
Bumps the npm_and_yarn group with 3 updates in the /src-ui directory: [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common), [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) and [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core).


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

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

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

---
updated-dependencies:
- dependency-name: "@angular/common"
  dependency-version: 21.2.17
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@angular/compiler"
  dependency-version: 21.2.17
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@angular/core"
  dependency-version: 21.2.17
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 17:02:51 -07:00
dependabot[bot] b0227dd080 Chore(deps): Bump the uv group across 1 directory with 2 updates (#12995)
Bumps the uv group with 2 updates in the / directory: [torch](https://github.com/pytorch/pytorch) and [tornado](https://github.com/tornadoweb/tornado).


Updates `torch` from 2.11.0 to 2.12.0
- [Release notes](https://github.com/pytorch/pytorch/releases)
- [Changelog](https://github.com/pytorch/pytorch/blob/main/RELEASE.md)
- [Commits](https://github.com/pytorch/pytorch/compare/v2.11.0...v2.12.0)

Updates `tornado` from 6.5.5 to 6.5.6
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.5...v6.5.6)

---
updated-dependencies:
- dependency-name: torch
  dependency-version: 2.12.0
  dependency-type: direct:production
- dependency-name: tornado
  dependency-version: 6.5.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 09:14:17 -07:00
dependabot[bot] 40d927a9ff Chore(deps): Bump the utilities-patch group across 1 directory with 4 updates (#12931)
* Chore(deps): Bump the utilities-patch group across 1 directory with 4 updates

Bumps the utilities-patch group with 4 updates in the / directory: [llama-index-core](https://github.com/run-llama/llama_index), [psycopg-pool](https://github.com/psycopg/psycopg), [zensical](https://github.com/zensical/zensical) and [ruff](https://github.com/astral-sh/ruff).


Updates `llama-index-core` from 0.14.21 to 0.14.22
- [Release notes](https://github.com/run-llama/llama_index/releases)
- [Changelog](https://github.com/run-llama/llama_index/blob/main/CHANGELOG.md)
- [Commits](https://github.com/run-llama/llama_index/compare/v0.14.21...v0.14.22)

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

Updates `zensical` from 0.0.36 to 0.0.43
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.36...v0.0.43)

Updates `ruff` from 0.15.12 to 0.15.15
- [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.15.12...0.15.15)

---
updated-dependencies:
- dependency-name: llama-index-core
  dependency-version: 0.14.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: psycopg-pool
  dependency-version: 3.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: ruff
  dependency-version: 0.15.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
- dependency-name: zensical
  dependency-version: 0.0.43
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: utilities-patch
...

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

* Syncs hook versions and runs them

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-06-15 08:55:18 -07:00
dependabot[bot] 82aefe5870 Chore(deps-dev): Bump types-markdown (#12927)
Bumps [types-markdown](https://github.com/python/typeshed) from 3.10.2.20260211 to 3.10.2.20260518.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-markdown
  dependency-version: 3.10.2.20260518
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 21:08:27 +00:00
dependabot[bot] 1bef142fd6 Chore(deps): Bump aiohttp in the uv group across 1 directory (#12930)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 20:56:53 +00:00
dependabot[bot] f56f29111c Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 2 updates (#12923)
Bumps the pre-commit-dependencies group with 2 updates in the / directory: [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) and [https://github.com/tox-dev/pyproject-fmt](https://github.com/tox-dev/pyproject-fmt).


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

Updates `https://github.com/tox-dev/pyproject-fmt` from v2.21.1 to 2.21.2
- [Release notes](https://github.com/tox-dev/pyproject-fmt/releases)
- [Commits](https://github.com/tox-dev/pyproject-fmt/compare/v2.21.1...v2.21.2)

---
updated-dependencies:
- dependency-name: https://github.com/astral-sh/ruff-pre-commit
  dependency-version: 0.15.14
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/tox-dev/pyproject-fmt
  dependency-version: 2.21.2
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 14:55:55 -07:00
dependabot[bot] e40e9eb0f9 docker(deps): Bump astral-sh/uv (#12920)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.11.6-python3.12-trixie-slim to 0.11.19-python3.12-trixie-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.11.6...0.11.19)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.11.18-python3.12-trixie-slim
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 08:28:09 -07:00
dependabot[bot] 0ec6610475 Chore(deps): Bump the actions group across 1 directory with 12 updates (#12909)
Bumps the actions group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `6.0.0` | `6.1.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.0.3` | `6.0.8` |
| [j178/prek-action](https://github.com/j178/prek-action) | `2.0.2` | `2.0.4` |
| [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) | `7.2.0` | `7.3.1` |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.3` | `0.5.6` |
| [github/codeql-action](https://github.com/github/codeql-action) | `4.35.2` | `4.36.0` |
| [actions/labeler](https://github.com/actions/labeler) | `6.0.1` | `6.1.0` |
| [actions/stale](https://github.com/actions/stale) | `10.2.0` | `10.3.0` |



Updates `codecov/codecov-action` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2...e79a6962e0d4c0c17b229090214935d2e33f8354)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/metadata-action` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/030e881283bb7a6894de51c315a6bfe6a94e05cf...80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

Updates `pnpm/action-setup` from 6.0.3 to 6.0.8
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/903f9c1a6ebcba6cf41d87230be49611ac97822e...0e279bb959325dab635dd2c09392533439d90093)

Updates `j178/prek-action` from 2.0.2 to 2.0.4
- [Release notes](https://github.com/j178/prek-action/releases)
- [Commits](https://github.com/j178/prek-action/compare/cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3...bdca6f102f98e2b4c7029491a53dfd366469e33d)

Updates `release-drafter/release-drafter` from 7.2.0 to 7.3.1
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/5de93583980a40bd78603b6dfdcda5b4df377b32...693d20e7c1ce1a81d3a41962f85914253b518449)

Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.6
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/b1d7e1fb5de872772f31590499237e7cce841e8e...5f14fd08f7cf1cb1609c1e344975f152c7ee938d)

Updates `github/codeql-action` from 4.35.2 to 4.36.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...7211b7c8077ea37d8641b6271f6a365a22a5fbfa)

Updates `actions/labeler` from 6.0.1 to 6.1.0
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/634933edcd8ababfe52f92936142cc22ac488b1b...f27b608878404679385c85cfa523b85ccb86e213)

Updates `actions/stale` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action
  dependency-version: 4.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: j178/prek-action
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 07:42:42 -07:00
GitHub Actions 05bf334d37 Auto translate strings 2026-06-03 22:15:23 +00:00
113 changed files with 5065 additions and 4899 deletions
+10 -10
View File
@@ -24,7 +24,7 @@ jobs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -87,7 +87,7 @@ jobs:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Start containers
@@ -96,11 +96,11 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -141,13 +141,13 @@ jobs:
pytest
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: backend-python-${{ matrix.python-version }}
files: junit.xml
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: backend-python-${{ matrix.python-version }}
files: coverage.xml
@@ -169,16 +169,16 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -202,7 +202,7 @@ jobs:
check \
src/
- name: Cache Mypy
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes
+10 -10
View File
@@ -41,7 +41,7 @@ jobs:
ref-name: ${{ steps.ref.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Determine ref name
@@ -106,9 +106,9 @@ jobs:
echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -121,7 +121,7 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
@@ -132,7 +132,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest
id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
@@ -182,29 +182,29 @@ jobs:
echo "Downloaded digests:"
ls -la /tmp/digests/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
+4 -4
View File
@@ -21,7 +21,7 @@ jobs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -69,16 +69,16 @@ jobs:
steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+19 -19
View File
@@ -21,7 +21,7 @@ jobs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -77,11 +77,11 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
- name: Use Node.js 24
@@ -92,7 +92,7 @@ jobs:
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.pnpm-store
@@ -109,11 +109,11 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
- name: Use Node.js 24
@@ -123,7 +123,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.pnpm-store
@@ -148,11 +148,11 @@ jobs:
shard-count: [4]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
- name: Use Node.js 24
@@ -162,7 +162,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.pnpm-store
@@ -174,13 +174,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/
@@ -191,7 +191,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
container: mcr.microsoft.com/playwright:v1.60.0-noble
container: mcr.microsoft.com/playwright:v1.61.1-noble
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
@@ -203,11 +203,11 @@ jobs:
shard-count: [2]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
- name: Use Node.js 24
@@ -217,7 +217,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.pnpm-store
@@ -239,12 +239,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 2
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
- name: Use Node.js 24
@@ -254,7 +254,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.pnpm-store
+3 -3
View File
@@ -17,12 +17,12 @@ jobs:
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
- name: Run prek
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
+10 -10
View File
@@ -20,7 +20,7 @@ jobs:
statuses: read
steps:
- name: Wait for Docker build
uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
with:
ref: ${{ github.sha }}
check-name: 'Merge and Push Manifest'
@@ -34,12 +34,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# ---- Frontend Build ----
- name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
- name: Use Node.js 24
@@ -54,11 +54,11 @@ jobs:
# ---- Backend Setup ----
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
@@ -170,7 +170,7 @@ jobs:
fi
- name: Create release and changelog
id: create-release
uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }}
@@ -181,7 +181,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive
uses: shogo82148/actions-upload-release-asset@ee2ae851dc5d938b90075b3ef12c540abfd1ee72 # v1.10.1
uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -201,17 +201,17 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
persist-credentials: true # for pushing changelog branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
semgrep:
name: Semgrep CE
runs-on: ubuntu-24.04
@@ -38,13 +38,13 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
if: always()
with:
sarif_file: results.sarif
+3 -3
View File
@@ -34,12 +34,12 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
environment: translation-sync
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false
- name: crowdin action
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_translations: false
download_translations: true
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps:
- name: Label PR with release-drafter
uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-stale: 7
days-before-close: 14
+5 -5
View File
@@ -14,7 +14,7 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with:
@@ -23,13 +23,13 @@ jobs:
persist-credentials: true # for pushing translation branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -43,7 +43,7 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
- name: Use Node.js 24
@@ -54,7 +54,7 @@ jobs:
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.pnpm-store
+4 -3
View File
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.8.3'
rev: 'v3.8.4'
hooks:
- id: prettier
types_or:
@@ -50,14 +50,15 @@ repos:
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
rev: v0.15.17
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.21.1"
rev: "v2.24.1"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
# Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.14.0
+1 -1
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.6-python3.12-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
+1
View File
@@ -63,6 +63,7 @@ The following are not generally considered vulnerabilities unless accompanied by
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
- missing limits or hardening settings presented without concrete impact
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
## Transparency
@@ -30,7 +30,7 @@
# documentation.
services:
broker:
image: docker.io/library/redis:8
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
+1 -1
View File
@@ -26,7 +26,7 @@
# documentation.
services:
broker:
image: docker.io/library/redis:8
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
+1 -1
View File
@@ -27,7 +27,7 @@
# documentation.
services:
broker:
image: docker.io/library/redis:8
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
@@ -30,7 +30,7 @@
# documentation.
services:
broker:
image: docker.io/library/redis:8
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
+1 -1
View File
@@ -26,7 +26,7 @@
# documentation.
services:
broker:
image: docker.io/library/redis:8
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
@@ -30,7 +30,7 @@
# documentation.
services:
broker:
image: docker.io/library/redis:8
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
+1 -1
View File
@@ -23,7 +23,7 @@
# documentation.
services:
broker:
image: docker.io/library/redis:8
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
+32
View File
@@ -65,6 +65,11 @@ copies you created in the steps above.
Please review the [migration instructions](migration-v3.md) before upgrading Paperless-ngx to v3.0, it includes some breaking changes that require manual intervention before upgrading.
!!! note
Upgrading to v3 clears the existing task history; previously completed, failed, or
acknowledged tasks will no longer appear in the task list afterward. No action is required.
### Docker Route {#docker-updating}
If a new release of paperless-ngx is available, upgrading depends on how
@@ -500,6 +505,33 @@ task scheduler.
python3 manage.py document_index reindex --if-needed
```
### Managing the LLM (AI) index {#llm-index}
When the [AI features](advanced_usage.md#ai-features) are enabled with an embedding
backend, Paperless-ngx maintains a vector index of your documents used for
Retrieval-Augmented Generation (RAG), similar-document retrieval, and document chat. The
index is updated automatically on the schedule set by
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON), but you
can manage it manually:
```
document_llmindex {rebuild,update,compact}
```
Specify `rebuild` to build the index from scratch from all documents in the database. Use
this the first time you enable the feature, or after changing the embedding backend or
model.
Specify `update` to incrementally index new and changed documents. This is what the
scheduled task runs.
Specify `compact` to reclaim space and optimize the on-disk vector store.
!!! note
These commands have no effect unless AI is enabled and an embedding backend is
configured.
### Clearing the database read cache
If the database read cache is enabled, **you must run this command** after making any changes to the database outside the application context.
+83 -2
View File
@@ -97,6 +97,85 @@ when using this feature:
of these correspondents to ANY new document, if both are set to
automatic matching.
## AI features {#ai-features}
Paperless-ngx includes a set of optional features backed by a large language model
(LLM): AI-assisted suggestions, similar-document retrieval, and a document chat. They
are **off by default** and never replace the built-in, non-LLM
[matching and suggestions](#matching).
!!! warning
Enabling these features sends document content (and metadata) to the LLM backend you
configure. If that backend is a remote/hosted provider, your documents leave your
server and may incur usage charges. Consider the privacy implications before enabling,
and prefer a local backend (Ollama, or a self-hosted OpenAI-compatible gateway) if that
matters to you.
All AI settings can be supplied as `PAPERLESS_AI_*` environment variables (see
[configuration](configuration.md#ai)) or set in the admin under
**Settings → Application Configuration**; the database value takes precedence over the
environment.
### Enabling the AI features
At a minimum you need to enable AI and choose an LLM backend:
- [`PAPERLESS_AI_ENABLED`](configuration.md#PAPERLESS_AI_ENABLED) — master switch.
- [`PAPERLESS_AI_LLM_BACKEND`](configuration.md#PAPERLESS_AI_LLM_BACKEND) — `ollama`
(runs locally) or `openai-like` (OpenAI itself or any OpenAI-compatible API).
- [`PAPERLESS_AI_LLM_MODEL`](configuration.md#PAPERLESS_AI_LLM_MODEL), and for
`openai-like` usually [`PAPERLESS_AI_LLM_API_KEY`](configuration.md#PAPERLESS_AI_LLM_API_KEY)
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
### AI-assisted suggestions
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
storage path and dates by sending the document to the LLM. This is **opt-in per request**
and surfaces through the "Suggest" control on the document detail page, alongside the
classic classifier-based suggestions — it does not disable them. Suggestion output
language can be steered with
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
(otherwise it follows the user's UI language).
### The LLM index (RAG) and similar documents
Setting an embedding backend turns on the **LLM index**, a vector index of your documents
that enables Retrieval-Augmented Generation (RAG). When enabled, suggestions are grounded
in similar existing documents, and the document chat can retrieve relevant context.
Enable it by setting
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
built when AI is enabled **and** an embedding backend is set.
The index is updated automatically on a schedule controlled by
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
default), and can be rebuilt or compacted manually — see
[Managing the LLM index](administration.md#llm-index).
!!! note
Local embeddings via `huggingface` download the embedding model on first use into the
Paperless data directory. The first run therefore needs network access and some disk
space.
### Document chat
When the LLM index is enabled, the chat control in the top app toolbar answers questions
about your documents. It operates over a single document or across multiple documents
depending on the current view, and its answers include links to the source documents it
drew from.
### AI Security notes
- Document content is passed to the LLM as **untrusted data**.
- By default Paperless-ngx allows AI endpoints that resolve to private/loopback addresses
(for local backends). Set
[`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS`](configuration.md#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS)
to `false` to block them.
## Hooking into the consumption process {#consume-hooks}
Sometimes you may want to do something arbitrary whenever a document is
@@ -846,7 +925,7 @@ Paperless is able to utilize barcodes for automatically performing some tasks. B
At this time, the library utilized for detection of barcodes supports the following types:
- AN-13/UPC-A
- EAN-13/UPC-A
- UPC-E
- EAN-8
- Code 128
@@ -855,7 +934,9 @@ At this time, the library utilized for detection of barcodes supports the follow
- Codabar
- Interleaved 2 of 5
- QR Code
- SQ Code
- Data Matrix
- Aztec
- PDF417
For usage in Paperless, the type of barcode does not matter, only the contents of it.
+7
View File
@@ -227,6 +227,7 @@ Version-aware endpoints:
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
## Permissions
@@ -445,3 +446,9 @@ Initial API version.
large lists of object IDs for operations affecting many objects.
- The legacy `title_content` document search parameter is deprecated and will be removed in a future version.
Clients should use `text` for simple title-and-content search and `title_search` for title-only search.
- The task tracking system was redesigned. The tasks list (`/api/tasks/`) is now paginated, and the
task object exposes `task_type` (formerly `task_name`) and `trigger_source` (formerly `type`). New
read-only endpoints `/api/tasks/summary/`, `/api/tasks/status_counts/`, and `/api/tasks/active/`
provide aggregate views, and `POST /api/tasks/run/` lets privileged users dispatch supported tasks.
API v9 continues to serve the unpaginated list with the legacy field names until support for v9 is
dropped.
+21 -17
View File
@@ -22,7 +22,11 @@ or applicable default will be utilized instead.
## Required services
### Redis Broker
### Message Broker
Paperless-ngx uses a Redis-compatible message broker. Any broker that
speaks the Redis protocol works here, including [Valkey](https://valkey.io/)
(the default in the bundled Docker Compose files) and Redis itself.
#### [`PAPERLESS_REDIS=<url>`](#PAPERLESS_REDIS) {#PAPERLESS_REDIS}
@@ -30,21 +34,21 @@ or applicable default will be utilized instead.
fetching, index optimization and for training the automatic document
matcher.
- If your Redis server needs login credentials PAPERLESS_REDIS =
- If your broker needs login credentials PAPERLESS_REDIS =
`redis://<username>:<password>@<host>:<port>`
- With the requirepass option PAPERLESS_REDIS =
`redis://:<password>@<host>:<port>`
- To include the redis database index PAPERLESS_REDIS =
- To include the database index PAPERLESS_REDIS =
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
[More information on securing your Redis
Instance](https://redis.io/docs/latest/operate/oss_and_stack/management/security).
[More information on securing your broker
instance](https://valkey.io/topics/security/).
Defaults to `redis://localhost:6379`.
#### [`PAPERLESS_REDIS_PREFIX=<prefix>`](#PAPERLESS_REDIS_PREFIX) {#PAPERLESS_REDIS_PREFIX}
: Prefix to be used in Redis for keys and channels. Useful for sharing one Redis server among multiple Paperless instances.
: Prefix to be used in the broker for keys and channels. Useful for sharing one broker among multiple Paperless instances.
Defaults to no prefix.
@@ -58,14 +62,14 @@ and the relevant connection variables.
#### [`PAPERLESS_DBENGINE=<engine>`](#PAPERLESS_DBENGINE) {#PAPERLESS_DBENGINE}
: Specifies the database engine to use. Accepted values are `sqlite`, `postgresql`,
and `mariadb`.
Defaults to `sqlite` if not set.
and `mariadb`. PostgreSQL and MariaDB users must set this explicitly.
PostgreSQL and MariaDB both require [`PAPERLESS_DBHOST`](#PAPERLESS_DBHOST) to be
set. SQLite does not use any other connection variables; the database file is always
located at `<PAPERLESS_DATA_DIR>/db.sqlite3`.
Defaults to `sqlite`.
!!! warning
Using MariaDB comes with some caveats.
See [MySQL Caveats](advanced_usage.md#mysql-caveats).
@@ -238,7 +242,7 @@ dictionaries; for example, `pool.max_size=20` sets
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
: Caches the database read query results into Redis. This can significantly improve application response times by caching database queries, at the cost of slightly increased memory usage.
: Caches the database read query results into the broker. This can significantly improve application response times by caching database queries, at the cost of slightly increased memory usage.
Defaults to `false`.
@@ -258,18 +262,18 @@ dictionaries; for example, `pool.max_size=20` sets
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
For more details, refer to the [Redis eviction policy documentation](https://redis.io/docs/latest/develop/reference/eviction/), and see the `PAPERLESS_READ_CACHE_REDIS_URL` setting to specify a separate Redis broker.
In case of an out-of-memory (OOM) situation, the broker may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
If your system has limited RAM, consider configuring a dedicated broker instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
For more details, refer to the [Redis eviction policy documentation](https://redis.io/docs/latest/develop/reference/eviction/), and see the `PAPERLESS_READ_CACHE_REDIS_URL` setting to specify a separate broker.
#### [`PAPERLESS_READ_CACHE_REDIS_URL=<url>`](#PAPERLESS_READ_CACHE_REDIS_URL) {#PAPERLESS_READ_CACHE_REDIS_URL}
: Defines the Redis instance used for the read cache.
: Defines the broker instance used for the read cache.
Defaults to `None`.
!!! Note
If this value is not set, the same Redis instance used for scheduled tasks will be used for caching as well.
If this value is not set, the same broker instance used for scheduled tasks will be used for caching as well.
## Optional Services
@@ -888,7 +892,7 @@ modes are available:
The default is `auto`.
For the `skip`, `redo`, and `force` modes, read more about OCR
For the `redo` and `force` modes, read more about OCR
behaviour in the [OCRmyPDF
documentation](https://ocrmypdf.readthedocs.io/en/latest/advanced.html#when-ocr-is-skipped).
@@ -2127,7 +2131,7 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
Defaults to true, which allows internal endpoints.
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
AI is enabled and the LLM embedding backend is set.
+13 -12
View File
@@ -94,16 +94,16 @@ first-time setup.
```
7. You can now either ...
- install Redis or
- install a Redis-compatible broker (e.g. Valkey or Redis) or
- use the included `scripts/start_services.sh` to use Docker to fire
up a Redis instance (and some other services such as Tika,
up a broker instance (and some other services such as Tika,
Gotenberg and a database server) or
- spin up a bare Redis container
- spin up a bare broker container
```bash
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
docker run -d -p 6379:6379 --restart unless-stopped docker.io/valkey/valkey:9-alpine
```
8. Continue with either back-end or front-end development or both :-).
@@ -132,7 +132,7 @@ uv run manage.py runserver & \
```
You might need the front end to test your back end code.
This assumes that you have AngularJS installed on your system.
This assumes that you have Angular installed on your system.
Go to the [Front end development](#front-end-development) section for further details.
To build the front end once use this command:
@@ -174,7 +174,7 @@ To add a new development package `uv add --dev <package>`
## Front end development
The front end is built using AngularJS. In order to get started, you need Node.js (version 24+) and
The front end is built using Angular. In order to get started, you need Node.js (version 24+) and
`pnpm`.
!!! note
@@ -248,12 +248,12 @@ that authentication is working.
## Localization
Paperless-ngx is available in many different languages. Since Paperless-ngx
consists both of a Django application and an AngularJS front end, both
consists both of a Django application and an Angular front end, both
these parts have to be translated separately.
### Front end localization
- The AngularJS front end does localization according to the [Angular
- The Angular front end does localization according to the [Angular
documentation](https://angular.io/guide/i18n).
- The source language of the project is "en_US".
- The source strings end up in the file `src-ui/messages.xlf`.
@@ -495,7 +495,7 @@ class MyCustomParser:
self._tempdir = Path(
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
)
self._text: str | None = None
self._text: str = ""
self._archive_path: Path | None = None
def __enter__(self) -> Self:
@@ -553,7 +553,8 @@ def parse(
**Result accessors**
```python
def get_text(self) -> str | None:
def get_text(self) -> str:
# Return the extracted text, or an empty string if none was found.
return self._text
def get_date(self) -> "datetime.datetime | None":
@@ -684,7 +685,7 @@ class XmlDocumentParser:
def __init__(self, logging_group: object = None) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
self._text: str | None = None
self._text: str = ""
def __enter__(self) -> Self:
return self
@@ -702,7 +703,7 @@ class XmlDocumentParser:
except ET.ParseError as e:
raise ParseError(f"XML parse error: {e}") from e
def get_text(self) -> str | None:
def get_text(self) -> str:
return self._text
def get_date(self):
+29 -6
View File
@@ -70,7 +70,16 @@ elsewhere. Here are a couple notes about that.
Paperless-ngx determines the type of a file by inspecting its content
rather than its file extensions. However, files processed via the
consumption directory will be rejected if they have a file extension that
not supported by any of the available parsers.
is not supported by any of the available parsers.
## _Are duplicate documents rejected?_
**A:** Not by default. As of v3, a file whose contents match an existing document is still
consumed, and the duplicate is flagged in the UI — open the document and check the
**Duplicates** tab to review documents that share the same content. If you prefer the old
behavior of rejecting duplicates during consumption, set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
to `true`.
## _Will paperless-ngx run on Raspberry Pi?_
@@ -118,10 +127,24 @@ able to run paperless, you're a bit on your own. If you can't run the
docker image, the documentation has instructions for bare metal
installs.
## _What about the Redis licensing change and using one of the open source forks_?
## _Does Paperless-ngx use AI, and is my data private?_
Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream
libraries, so using one of these to replace Redis is not officially supported.
**A:** Paperless-ngx includes optional AI features — LLM-based suggestions, document chat,
and similar-document retrieval — that are **disabled by default**. They only run when you
enable them and configure an LLM backend. The built-in tag/correspondent suggestions use a
local, non-LLM machine-learning model and do not send your data anywhere. If you enable the
LLM features, document content is sent to whichever backend you configure — this can be a
fully local backend (e.g. Ollama) or a remote provider. See
[AI features](advanced_usage.md#ai-features) for details.
However, they do claim to be compatible with the Redis protocol and will likely work, but we will
not be updating from using Redis as the broker officially just yet.
## _Which message broker should I use_?
Paperless-ngx talks to a Redis-compatible message broker, so any broker that
implements the Redis protocol will work. The bundled Docker Compose files
default to [Valkey](https://valkey.io/), the open-source fork created after
Redis' licensing change, but Redis itself and other wire-compatible brokers
(such as Microsoft's Garnet) are equally fine.
Existing installs can switch broker implementations in place: point
[`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) at the new instance and
reuse the same data volume.
+2 -1
View File
@@ -35,9 +35,10 @@ physical documents into a searchable online archive so you can keep, well, _less
- _New!_ Supports remote OCR with Azure AI (opt-in).
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
- **New**: Paperless-ngx can optionally leverage AI (Large Language Models or LLMs) for document suggestions, chatting with your documents, and similar-document retrieval. These features are opt-in and disabled by default.
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
- Keep multiple **versions** of a document's file under a single entry, sharing one set of metadata.
- **Beautiful, modern web application** that features:
- Customizable dashboard with statistics.
- Filtering by tags, correspondents, types, and more.
+19 -12
View File
@@ -178,7 +178,7 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
- `fonts-liberation` for generating thumbnails for plain text
files
- `imagemagick` >= 6 for PDF conversion
- `gnupg` for handling encrypted documents
- `gnupg` for decrypting GPG-encrypted email
- `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection
- `mariadb-client` for MariaDB compile time
@@ -226,7 +226,8 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
build-essential python3-setuptools python3-wheel
```
2. Install `redis` >= 6.0 and configure it to start automatically.
2. Install a Redis-compatible broker (a current release of Valkey or
Redis) and configure it to start automatically.
3. Optional: Install `postgresql` and configure a database, user, and
password for Paperless-ngx. If you do not wish to use PostgreSQL,
@@ -268,10 +269,10 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
Edit the included `paperless.conf` and adjust the settings to your
needs. Required settings for getting Paperless-ngx running are:
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your broker, such as
`redis://localhost:6379`.
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
`mariadb`, or `sqlite`
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) should be one of `postgresql`,
`mariadb`, or `sqlite`. PostgreSQL and MariaDB users must set this explicitly.
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
PostgreSQL server is running. Do not configure this to use
SQLite instead. Also configure port, database name, user and
@@ -297,7 +298,7 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
!!! warning
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
Ensure your broker instance [is secured](https://valkey.io/topics/security/).
7. Create the following directories if they do not already exist:
- `/opt/paperless/media`
@@ -389,9 +390,9 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
`Require=paperless-webserver.socket` in the `webserver` script
and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
These services rely on Redis and optionally the database server, but
These services rely on the broker and optionally the database server, but
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 the broker being started. If you use a database server, you
should add additional dependencies.
!!! note
@@ -449,6 +450,12 @@ development documentation.
You can migrate to Paperless-ngx from Paperless-ng or from the original
Paperless project.
!!! note
Upgrading an existing Paperless-ngx installation from v2 to v3 has its own
breaking changes and required steps. See the [v3 migration guide](migration-v3.md)
before upgrading.
<h3 id="migration_ng">Migrating from Paperless-ng</h3>
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng, and
@@ -494,7 +501,7 @@ installation. Keep these points in mind:
for other services, you might as well use it for Paperless as well.
- The task scheduler of Paperless, which is used to execute periodic
tasks such as email checking and maintenance, requires a
[Redis](https://redis.io/) message broker instance. The
Redis-compatible message broker instance (such as Valkey or Redis). The
Docker Compose route takes care of that.
- The layout of the folder structure for your documents and data
remains the same, so you can plug your old Docker volumes into
@@ -582,16 +589,16 @@ commands as well.
1. Stop and remove the Paperless container.
2. If using an external database, stop that container.
3. Update Redis configuration.
3. Update broker configuration.
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4.
1. Otherwise, add a new Redis service in `docker-compose.yml`,
1. Otherwise, add a new broker service in `docker-compose.yml`,
following [the example compose
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
the new Redis container.
the new broker container.
4. Update user mapping.
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
+2 -33
View File
@@ -10,9 +10,9 @@ Check for the following issues:
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
using docker.
- Ensure that redis is up and running. Paperless does its task
- Ensure that the broker is up and running. Paperless does its task
processing asynchronously, and for documents to arrive at the task
processor, it needs redis to run.
processor, it needs the broker to run.
- Ensure that the task processor is running. Docker does this
automatically. Manually invoke the task processor by executing
@@ -149,37 +149,6 @@ operating system, if these are different from `1000`. See [Docker setup](setup.m
Also ensure that you are able to read and write to the consumption
directory on the host.
## OSError: \[Errno 19\] No such device when consuming files
If you experience errors such as:
```shell-session
File "/usr/local/lib/python3.7/site-packages/whoosh/codec/base.py", line 570, in open_compound_file
return CompoundStorage(dbfile, use_mmap=storage.supports_mmap)
File "/usr/local/lib/python3.7/site-packages/whoosh/filedb/compound.py", line 75, in __init__
self._source = mmap.mmap(fileno, 0, access=mmap.ACCESS_READ)
OSError: [Errno 19] No such device
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/usr/local/lib/python3.7/site-packages/django_q/cluster.py", line 436, in worker
res = f(*task["args"], **task["kwargs"])
File "/usr/src/paperless/src/documents/tasks.py", line 73, in consume_file
override_tag_ids=override_tag_ids)
File "/usr/src/paperless/src/documents/consumer.py", line 271, in try_consume_file
raise ConsumerError(e)
```
Paperless uses a search index to provide better and faster full text
searching. This search index is stored inside the `data` folder. The
search index uses memory-mapped files (mmap). The above error indicates
that paperless was unable to create and open these files.
This happens when you're trying to store the data directory on certain
file systems (mostly network shares) that don't support memory-mapped
files.
## Web-UI stuck at "Loading\..."
This might have multiple reasons.
+21 -2
View File
@@ -292,6 +292,23 @@ Once setup, navigating to the email settings page in Paperless-ngx will allow yo
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
for details.
### Duplicate documents
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
contents exactly match an existing document (same checksum), the new copy is still
consumed and a warning is logged. The task entry for the upload also flags that a
duplicate was detected and links to the existing document(s).
To review duplicates, open a document and switch to the **Duplicates** tab on the
document detail page. It lists other documents that share the same content, including any
that are in the trash (shown with a badge), and links to each so you can decide which to
keep.
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
a "document already exists" message.
## Document Suggestions
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
@@ -306,7 +323,9 @@ Paperless-ngx includes several features that use AI to enhance the document mana
so consider the privacy implications of using these features, especially if using a remote
model or API provider instead of the default local model.
The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering. This uses the FAISS vector store.
The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering.
See [AI features](advanced_usage.md#ai-features) for how to enable and configure these features, including choosing an LLM backend and setting up the LLM index for RAG.
### AI-Enhanced Suggestions
@@ -1097,7 +1116,7 @@ Paperless-ngx consists of the following components:
errors (i.e., wrong email credentials, errors during consuming a
specific file, etc).
- A [redis](https://redis.io/) message broker: This is a really
- A message broker (such as Valkey or Redis): This is a really
lightweight service that is responsible for getting the tasks from
the webserver and the consumer to the task scheduler. These run in a
different process (maybe even on different machines!), and
+26 -26
View File
@@ -50,7 +50,7 @@ dependencies = [
"imap-tools~=1.13.0",
"jinja2~=3.1.5",
"langdetect~=1.0.9",
"llama-index-core>=0.14.21",
"llama-index-core>=0.14.22",
"llama-index-embeddings-huggingface>=0.6.1",
"llama-index-embeddings-ollama>=0.9",
"llama-index-embeddings-openai-like>=0.2.2",
@@ -75,7 +75,7 @@ dependencies = [
"sqlite-vec==0.1.9",
"tantivy~=0.26.0",
"tika-client~=0.11.0",
"torch~=2.11.0",
"torch~=2.12.0",
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"zxing-cpp~=3.0.0",
@@ -88,7 +88,7 @@ postgres = [
"psycopg[c,pool]==3.3",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3",
"psycopg-pool==3.3",
"psycopg-pool==3.3.1",
]
webserver = [
"granian[uvloop]~=2.7.0",
@@ -101,11 +101,11 @@ dev = [
{ include-group = "testing" },
]
docs = [
"zensical>=0.0.36",
"zensical>=0.0.43",
]
lint = [
"prek~=0.3.10",
"ruff~=0.15.12",
"ruff~=0.15.15",
]
testing = [
"daphne",
@@ -245,50 +245,38 @@ per-file-ignores."src/documents/models.py" = [
isort.force-single-line = true
[tool.codespell]
write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = """\
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
/mail/*,src/documents/tests/samples/*,*.po,*.json\
"""
write-changes = true
[tool.pyproject-fmt]
table_format = "long"
[tool.mypy]
mypy_path = "src"
disallow_any_generics = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
plugins = [
"mypy_django_plugin.main",
"mypy_drf_plugin.main",
]
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.pyrefly]
search-path = [ "src" ]
baseline = ".pyrefly-baseline.json"
python-platform = "linux"
search-path = [ "src" ]
[tool.django-stubs]
django_settings_module = "paperless.settings"
[tool.pytest]
minversion = "9.0"
pythonpath = [ "src" ]
strict_config = true
strict_markers = true
strict_parametrization_ids = true
strict_xfail = true
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_ai/tests",
]
addopts = [
"--pythonwarnings=all",
"--cov",
@@ -303,7 +291,6 @@ addopts = [
"-o",
"junit_family=legacy",
]
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings"
markers = [
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
@@ -316,6 +303,19 @@ markers = [
"search: Tests for the Tantivy search backend",
"api: Tests for REST API endpoints",
]
minversion = "9.0"
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
pythonpath = [ "src" ]
strict_config = true
strict_markers = true
strict_parametrization_ids = true
strict_xfail = true
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_ai/tests",
]
[tool.pytest_env]
PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production"
+3 -1
View File
@@ -155,7 +155,9 @@
"builder": "@angular-builders/jest:run",
"options": {
"tsConfig": "tsconfig.spec.json",
"zoneless": false
"zoneless": false,
"watch": false,
"coverage": true
}
},
"lint": {
+24 -5
View File
@@ -3,6 +3,27 @@ const angularTemplatePlugin = require('@angular-eslint/eslint-plugin-template')
const angularTemplateParser = require('@angular-eslint/template-parser')
const tsParser = require('@typescript-eslint/parser')
const angularTsRecommendedRules = {
'@angular-eslint/contextual-lifecycle': 'error',
'@angular-eslint/no-empty-lifecycle-method': 'error',
'@angular-eslint/no-input-rename': 'error',
'@angular-eslint/no-inputs-metadata-property': 'error',
'@angular-eslint/no-output-native': 'error',
'@angular-eslint/no-output-on-prefix': 'error',
'@angular-eslint/no-output-rename': 'error',
'@angular-eslint/no-outputs-metadata-property': 'error',
'@angular-eslint/prefer-inject': 'error',
'@angular-eslint/prefer-standalone': 'error',
'@angular-eslint/use-pipe-transform-interface': 'error',
'@angular-eslint/use-lifecycle-interface': 'warn',
}
const angularTemplateRecommendedRules = {
'@angular-eslint/template/banana-in-box': 'error',
'@angular-eslint/template/eqeqeq': 'error',
'@angular-eslint/template/no-negated-async': 'error',
'@angular-eslint/template/prefer-control-flow': 'error',
}
module.exports = [
{
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
@@ -12,8 +33,6 @@ module.exports = [
languageOptions: {
parser: tsParser,
parserOptions: {
project: ['tsconfig.json'],
createDefaultProgram: true,
ecmaVersion: 2020,
sourceType: 'module',
},
@@ -22,9 +41,9 @@ module.exports = [
'@angular-eslint': angularEslintPlugin,
'@angular-eslint/template': angularTemplatePlugin,
},
processor: '@angular-eslint/template/extract-inline-html',
processor: angularTemplatePlugin.processors['extract-inline-html'],
rules: {
...angularEslintPlugin.configs.recommended.rules,
...angularTsRecommendedRules,
'@angular-eslint/directive-selector': [
'error',
{
@@ -52,7 +71,7 @@ module.exports = [
'@angular-eslint/template': angularTemplatePlugin,
},
rules: {
...angularTemplatePlugin.configs.recommended.rules,
...angularTemplateRecommendedRules,
},
},
]
+242 -188
View File
File diff suppressed because it is too large Load Diff
+38 -47
View File
@@ -6,22 +6,22 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test --no-watch --coverage",
"test": "ng test",
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@angular/cdk": "^21.2.12",
"@angular/common": "~21.2.14",
"@angular/compiler": "~21.2.14",
"@angular/core": "~21.2.14",
"@angular/forms": "~21.2.14",
"@angular/localize": "~21.2.14",
"@angular/platform-browser": "~21.2.14",
"@angular/platform-browser-dynamic": "~21.2.14",
"@angular/router": "~21.2.14",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.8.2",
"@angular/cdk": "^22.0.3",
"@angular/common": "~22.0.5",
"@angular/compiler": "~22.0.5",
"@angular/core": "~22.0.5",
"@angular/forms": "~22.0.5",
"@angular/localize": "~22.0.5",
"@angular/platform-browser": "~22.0.5",
"@angular/platform-browser-dynamic": "~22.0.5",
"@angular/router": "~22.0.5",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.2.0",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -29,56 +29,47 @@
"mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0",
"ngx-cookie-service": "^21.3.1",
"ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
"ngx-cookie-service": "^22.0.0",
"ngx-device-detector": "^12.0.0",
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
"normalize-diacritics": "^5.0.0",
"pdfjs-dist": "^5.7.284",
"pdfjs-dist": "^6.0.227",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^14.0.0",
"uuid": "^14.0.1",
"zone.js": "^0.16.2"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.2.12",
"@angular-devkit/schematics": "^21.2.12",
"@angular-eslint/builder": "21.4.0",
"@angular-eslint/eslint-plugin": "21.4.0",
"@angular-eslint/eslint-plugin-template": "21.4.0",
"@angular-eslint/schematics": "21.4.0",
"@angular-eslint/template-parser": "21.4.0",
"@angular/build": "^21.2.12",
"@angular/cli": "~21.2.12",
"@angular/compiler-cli": "~21.2.14",
"@angular-builders/custom-webpack": "^22.0.1",
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.0.5",
"@angular-devkit/schematics": "^22.0.5",
"@angular-eslint/builder": "22.0.0",
"@angular-eslint/eslint-plugin": "22.0.0",
"@angular-eslint/eslint-plugin-template": "22.0.0",
"@angular-eslint/schematics": "22.0.0",
"@angular-eslint/template-parser": "22.0.0",
"@angular/build": "^22.0.5",
"@angular/cli": "~22.0.5",
"@angular/compiler-cli": "~22.0.5",
"@codecov/webpack-plugin": "^2.0.1",
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.61.1",
"@types/jest": "^30.0.0",
"@types/node": "^25.9.1",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"@typescript-eslint/utils": "^8.60.0",
"eslint": "^10.4.0",
"@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.62.0",
"@typescript-eslint/utils": "^8.62.0",
"eslint": "^10.5.0",
"jest": "30.4.2",
"jest-environment-jsdom": "^30.4.1",
"jest-junit": "^17.0.0",
"jest-preset-angular": "^16.1.5",
"jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1",
"typescript": "^5.9.3",
"typescript": "^6.0.3",
"webpack": "^5.107.2"
},
"packageManager": "pnpm@10.17.1",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"canvas",
"esbuild",
"lmdb",
"msgpackr-extract"
]
}
"packageManager": "pnpm@10.17.1"
}
+2327 -2873
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
packages:
- "."
allowBuilds:
"@parcel/watcher": true
canvas: true
esbuild: true
lmdb: true
msgpackr-extract: true
normalize-diacritics: false
unrs-resolver: false
onlyBuiltDependencies:
- "@parcel/watcher"
- canvas
- esbuild
- lmdb
- msgpackr-extract
ignoredBuiltDependencies:
- normalize-diacritics
- unrs-resolver
@@ -7,6 +7,7 @@ import {
OnInit,
ViewChild,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
@@ -34,22 +35,70 @@ export class LogsComponent
private logService = inject(LogService)
private changedetectorRef = inject(ChangeDetectorRef)
public logs: Array<{ message: string; level: number }> = []
private logsSignal = signal<Array<{ message: string; level: number }>>([])
public logFiles: string[] = []
private logFilesSignal = signal<string[]>([])
public activeLog: string
private activeLogSignal = signal<string>(undefined)
public autoRefreshEnabled: boolean = true
private autoRefreshEnabledSignal = signal(true)
public limit: number = 5000
private limitSignal = signal(5000)
public showJumpToBottom = false
private showJumpToBottomSignal = signal(false)
private readonly limitChange$ = new Subject<number>()
@ViewChild('logContainer') logContainer: ElementRef<HTMLElement>
public get logs(): Array<{ message: string; level: number }> {
return this.logsSignal()
}
public set logs(logs: Array<{ message: string; level: number }>) {
this.logsSignal.set(logs)
}
public get logFiles(): string[] {
return this.logFilesSignal()
}
public set logFiles(logFiles: string[]) {
this.logFilesSignal.set(logFiles)
}
public get activeLog(): string {
return this.activeLogSignal()
}
public set activeLog(activeLog: string) {
this.activeLogSignal.set(activeLog)
}
public get autoRefreshEnabled(): boolean {
return this.autoRefreshEnabledSignal()
}
public set autoRefreshEnabled(autoRefreshEnabled: boolean) {
this.autoRefreshEnabledSignal.set(autoRefreshEnabled)
}
public get limit(): number {
return this.limitSignal()
}
public set limit(limit: number) {
this.limitSignal.set(limit)
}
public get showJumpToBottom(): boolean {
return this.showJumpToBottomSignal()
}
public set showJumpToBottom(showJumpToBottom: boolean) {
this.showJumpToBottomSignal.set(showJumpToBottom)
}
ngOnInit(): void {
this.limitChange$
.pipe(debounceTime(300), takeUntil(this.unsubscribeNotifier))
@@ -1,5 +1,5 @@
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
import {
@@ -166,18 +166,42 @@ export class TasksComponent
public autoRefreshEnabled: boolean = true
public readonly pageSize = 25
public page: number = 1
public totalTasks: number = 0
public sectionCounts: Record<TaskSection, number> = {
private totalTasksSignal = signal(0)
private sectionCountsSignal = signal<Record<TaskSection, number>>({
[TaskSection.All]: 0,
[TaskSection.NeedsAttention]: 0,
[TaskSection.InProgress]: 0,
[TaskSection.Completed]: 0,
}
public pagedTasks: PaperlessTask[] = []
})
private pagedTasksSignal = signal<PaperlessTask[]>([])
public selectedSection: TaskSection = TaskSection.All
public selectedTaskType: PaperlessTaskType | null = null
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
public get totalTasks(): number {
return this.totalTasksSignal()
}
public set totalTasks(value: number) {
this.totalTasksSignal.set(value)
}
public get sectionCounts(): Record<TaskSection, number> {
return this.sectionCountsSignal()
}
public set sectionCounts(value: Record<TaskSection, number>) {
this.sectionCountsSignal.set(value)
}
public get pagedTasks(): PaperlessTask[] {
return this.pagedTasksSignal()
}
public set pagedTasks(value: PaperlessTask[]) {
this.pagedTasksSignal.set(value)
}
private _filterText: string = ''
get filterText() {
return this._filterText
@@ -480,6 +504,13 @@ export class TasksComponent
return this.sectionCounts[section]
}
private setSectionCount(section: TaskSection, count: number) {
this.sectionCountsSignal.update((counts) => ({
...counts,
[section]: count,
}))
}
sectionShowsResults(section: TaskSection): boolean {
return section !== TaskSection.InProgress
}
@@ -664,10 +695,12 @@ export class TasksComponent
.statusCounts(this.getParamsForSection(TaskSection.All))
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((counts) => {
this.sectionCounts[TaskSection.All] = counts.all
this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
this.sectionCounts[TaskSection.InProgress] = counts.in_progress
this.sectionCounts[TaskSection.Completed] = counts.completed
this.sectionCounts = {
[TaskSection.All]: counts.all,
[TaskSection.NeedsAttention]: counts.needs_attention,
[TaskSection.InProgress]: counts.in_progress,
[TaskSection.Completed]: counts.completed,
}
})
}
@@ -735,9 +768,9 @@ export class TasksComponent
next: (result) => {
this.pagedTasks = result.results
this.totalTasks = result.count
this.sectionCounts[TaskSection.All] = result.count
this.setSectionCount(TaskSection.All, result.count)
if (this.selectedSection !== TaskSection.All) {
this.sectionCounts[this.selectedSection] = result.count
this.setSectionCount(this.selectedSection, result.count)
}
this.loading = false
if (
@@ -1,4 +1,4 @@
import { Component, OnDestroy, inject } from '@angular/core'
import { Component, OnDestroy, inject, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import {
@@ -7,7 +7,7 @@ import {
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, takeUntil, tap } from 'rxjs'
import { takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
@@ -42,11 +42,51 @@ export class TrashComponent
private settingsService = inject(SettingsService)
private router = inject(Router)
public documentsInTrash: Document[] = []
public selectedDocuments: Set<number> = new Set()
public allToggled: boolean = false
public page: number = 1
public totalDocuments: number
private documentsInTrashSignal = signal<Document[]>([])
private selectedDocumentsSignal = signal<Set<number>>(new Set())
private allToggledSignal = signal(false)
private pageSignal = signal(1)
private totalDocumentsSignal = signal<number>(undefined)
public get documentsInTrash(): Document[] {
return this.documentsInTrashSignal()
}
public set documentsInTrash(documentsInTrash: Document[]) {
this.documentsInTrashSignal.set(documentsInTrash)
}
public get selectedDocuments(): Set<number> {
return this.selectedDocumentsSignal()
}
public set selectedDocuments(selectedDocuments: Set<number>) {
this.selectedDocumentsSignal.set(selectedDocuments)
}
public get allToggled(): boolean {
return this.allToggledSignal()
}
public set allToggled(allToggled: boolean) {
this.allToggledSignal.set(allToggled)
}
public get page(): number {
return this.pageSignal()
}
public set page(page: number) {
this.pageSignal.set(page)
}
public get totalDocuments(): number {
return this.totalDocumentsSignal()
}
public set totalDocuments(totalDocuments: number) {
this.totalDocumentsSignal.set(totalDocuments)
}
constructor() {
super()
@@ -61,10 +101,9 @@ export class TrashComponent
tap((r) => {
this.documentsInTrash = r.results
this.totalDocuments = r.count
this.selectedDocuments.clear()
this.selectedDocuments = new Set()
this.loading = false
}),
delay(100)
})
)
.subscribe(() => {
this.show = true
@@ -186,17 +225,20 @@ export class TrashComponent
}
toggleSelected(object: Document) {
this.selectedDocuments.has(object.id)
? this.selectedDocuments.delete(object.id)
: this.selectedDocuments.add(object.id)
const selectedDocuments = new Set(this.selectedDocuments)
selectedDocuments.has(object.id)
? selectedDocuments.delete(object.id)
: selectedDocuments.add(object.id)
this.selectedDocuments = selectedDocuments
}
clearSelection() {
this.allToggled = false
this.selectedDocuments.clear()
this.selectedDocuments = new Set()
}
getDaysRemaining(document: Document): number {
this.settingsService.trackChanges()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24))
@@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, first, takeUntil } from 'rxjs'
@@ -43,8 +43,24 @@ export class UsersAndGroupsComponent
permissionsService = inject(PermissionsService)
private settings = inject(SettingsService)
users: User[]
groups: Group[]
private usersSignal = signal<User[]>(null)
private groupsSignal = signal<Group[]>(null)
get users(): User[] {
return this.usersSignal()
}
set users(value: User[]) {
this.usersSignal.set(value)
}
get groups(): Group[] {
return this.groupsSignal()
}
set groups(value: Group[]) {
this.groupsSignal.set(value)
}
unsubscribeNotifier: Subject<any> = new Subject()
@@ -6,7 +6,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit } from '@angular/core'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
NgbCollapseModule,
@@ -90,16 +90,48 @@ export class AppFrameComponent
permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService)
appRemoteVersion: AppRemoteVersion
private appRemoteVersionSignal = signal<AppRemoteVersion>(null)
isMenuCollapsed: boolean = true
private isMenuCollapsedSignal = signal(true)
slimSidebarAnimating: boolean = false
private slimSidebarAnimatingSignal = signal(false)
public mobileSearchHidden: boolean = false
private mobileSearchHiddenSignal = signal(false)
private lastScrollY: number = 0
get appRemoteVersion(): AppRemoteVersion {
return this.appRemoteVersionSignal()
}
set appRemoteVersion(value: AppRemoteVersion) {
this.appRemoteVersionSignal.set(value)
}
get isMenuCollapsed(): boolean {
return this.isMenuCollapsedSignal()
}
set isMenuCollapsed(value: boolean) {
this.isMenuCollapsedSignal.set(value)
}
get slimSidebarAnimating(): boolean {
return this.slimSidebarAnimatingSignal()
}
set slimSidebarAnimating(value: boolean) {
this.slimSidebarAnimatingSignal.set(value)
}
get mobileSearchHidden(): boolean {
return this.mobileSearchHiddenSignal()
}
set mobileSearchHidden(value: boolean) {
this.mobileSearchHiddenSignal.set(value)
}
constructor() {
super()
const permissionsService = this.permissionsService
@@ -178,10 +210,12 @@ export class AppFrameComponent
}
get versionString(): string {
this.settingsService.trackChanges()
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
}
get customAppTitle(): string {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
}
@@ -224,6 +258,7 @@ export class AppFrameComponent
}
get slimSidebarEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
@@ -243,6 +278,7 @@ export class AppFrameComponent
}
get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges()
return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES)
@@ -268,6 +304,7 @@ export class AppFrameComponent
}
get aiEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
}
@@ -419,6 +456,7 @@ export class AppFrameComponent
}
get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
!this.settingsService.organizingSidebarSavedViews
@@ -7,6 +7,7 @@ import {
ViewChild,
ViewChildren,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
@@ -82,12 +83,36 @@ export class GlobalSearchComponent implements OnInit {
private locationStrategy = inject(LocationStrategy)
public DataType = DataType
public query: string
private querySignal = signal<string>(null)
public queryDebounce: Subject<string>
public searchResults: GlobalSearchResult
private searchResultsSignal = signal<GlobalSearchResult>(null)
private currentItemIndex: number = -1
private domIndex: number = -1
public loading: boolean = false
private loadingSignal = signal(false)
public get query(): string {
return this.querySignal()
}
public set query(value: string) {
this.querySignal.set(value)
}
public get searchResults(): GlobalSearchResult {
return this.searchResultsSignal()
}
public set searchResults(value: GlobalSearchResult) {
this.searchResultsSignal.set(value)
}
public get loading(): boolean {
return this.loadingSignal()
}
public set loading(value: boolean) {
this.loadingSignal.set(value)
}
@ViewChild('searchInput') searchInput: ElementRef
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
@@ -96,6 +121,7 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
@@ -1,5 +1,12 @@
import { DecimalPipe } from '@angular/common'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -13,6 +20,17 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
activeModal = inject(NgbActiveModal)
private titleSignal = signal($localize`Confirmation`)
private messageBoldSignal = signal<string>(undefined)
private messageSignal = signal<string>(undefined)
private btnClassSignal = signal('btn-primary')
private btnCaptionSignal = signal($localize`Confirm`)
private alternativeBtnClassSignal = signal('btn-secondary')
private alternativeBtnCaptionSignal = signal<string>(undefined)
private cancelBtnClassSignal = signal('btn-outline-secondary')
private cancelBtnCaptionSignal = signal($localize`Cancel`)
private buttonsEnabledSignal = signal(true)
@Output()
public confirmClicked = new EventEmitter()
@@ -20,34 +38,94 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
public alternativeClicked = new EventEmitter()
@Input()
title = $localize`Confirmation`
get title(): string {
return this.titleSignal()
}
set title(title: string) {
this.titleSignal.set(title)
}
@Input()
messageBold
get messageBold(): string {
return this.messageBoldSignal()
}
set messageBold(messageBold: string) {
this.messageBoldSignal.set(messageBold)
}
@Input()
message
get message(): string {
return this.messageSignal()
}
set message(message: string) {
this.messageSignal.set(message)
}
@Input()
btnClass = 'btn-primary'
get btnClass(): string {
return this.btnClassSignal()
}
set btnClass(btnClass: string) {
this.btnClassSignal.set(btnClass)
}
@Input()
btnCaption = $localize`Confirm`
get btnCaption(): string {
return this.btnCaptionSignal()
}
set btnCaption(btnCaption: string) {
this.btnCaptionSignal.set(btnCaption)
}
@Input()
alternativeBtnClass = 'btn-secondary'
get alternativeBtnClass(): string {
return this.alternativeBtnClassSignal()
}
set alternativeBtnClass(alternativeBtnClass: string) {
this.alternativeBtnClassSignal.set(alternativeBtnClass)
}
@Input()
alternativeBtnCaption
get alternativeBtnCaption(): string {
return this.alternativeBtnCaptionSignal()
}
set alternativeBtnCaption(alternativeBtnCaption: string) {
this.alternativeBtnCaptionSignal.set(alternativeBtnCaption)
}
@Input()
cancelBtnClass = 'btn-outline-secondary'
get cancelBtnClass(): string {
return this.cancelBtnClassSignal()
}
set cancelBtnClass(cancelBtnClass: string) {
this.cancelBtnClassSignal.set(cancelBtnClass)
}
@Input()
cancelBtnCaption = $localize`Cancel`
get cancelBtnCaption(): string {
return this.cancelBtnCaptionSignal()
}
set cancelBtnCaption(cancelBtnCaption: string) {
this.cancelBtnCaptionSignal.set(cancelBtnCaption)
}
@Input()
buttonsEnabled = true
get buttonsEnabled(): boolean {
return this.buttonsEnabledSignal()
}
set buttonsEnabled(buttonsEnabled: boolean) {
this.buttonsEnabledSignal.set(buttonsEnabled)
}
confirmButtonEnabled = true
alternativeButtonEnabled = true
@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core'
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -14,18 +14,11 @@ export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponen
includeMetadata: boolean = true
deleteOriginal: boolean = false
@Input()
override title = $localize`Remove password protection`
@Input()
override message =
$localize`Create an unprotected copy or replace the existing file.`
@Input()
override btnCaption = $localize`Start`
constructor() {
super()
this.title = $localize`Remove password protection`
this.message = $localize`Create an unprotected copy or replace the existing file.`
this.btnCaption = $localize`Start`
}
onUpdateDocumentChange(updateDocument: boolean) {
@@ -1,5 +1,5 @@
import { NgStyle } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -13,11 +13,30 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
documentService = inject(DocumentService)
public documentID: number
public showPDFNote: boolean = true
private documentIDSignal = signal<number>(undefined)
private showPDFNoteSignal = signal(true)
private rotationSignal = signal(0)
public get documentID(): number {
return this.documentIDSignal()
}
public set documentID(documentID: number) {
this.documentIDSignal.set(documentID)
}
public get showPDFNote(): boolean {
return this.showPDFNoteSignal()
}
public set showPDFNote(showPDFNote: boolean) {
this.showPDFNoteSignal.set(showPDFNote)
}
// animation is better if we dont normalize yet
public rotation: number = 0
public get rotation(): number {
return this.rotationSignal()
}
public get degrees(): number {
let degrees = this.rotation % 360
@@ -30,6 +49,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
}
rotate(clockwise: boolean = true) {
this.rotation += clockwise ? 90 : -90
this.rotationSignal.update((rotation) => rotation + (clockwise ? 90 : -90))
}
}
@@ -5,6 +5,7 @@ import {
OnInit,
Output,
inject,
signal,
} from '@angular/core'
import { FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
@@ -45,10 +46,20 @@ export abstract class EditDialogComponent<
protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService)
users: User[]
private usersSignal = signal<User[]>(undefined)
private dialogModeSignal = signal(EditDialogMode.CREATE, {
equal: () => false,
})
@Input()
dialogMode: EditDialogMode = EditDialogMode.CREATE
get dialogMode(): EditDialogMode {
return this.dialogModeSignal()
}
set dialogMode(dialogMode: EditDialogMode) {
this.dialogModeSignal.set(dialogMode)
}
@Input()
object: T
@@ -59,11 +70,43 @@ export abstract class EditDialogComponent<
@Output()
failed = new EventEmitter()
networkActive = false
private networkActiveSignal = signal(false)
closeEnabled = false
private closeEnabledSignal = signal(false)
error = null
private errorSignal = signal<any>(null)
get users(): User[] {
return this.usersSignal()
}
set users(users: User[]) {
this.usersSignal.set(users)
}
get networkActive(): boolean {
return this.networkActiveSignal()
}
set networkActive(networkActive: boolean) {
this.networkActiveSignal.set(networkActive)
}
get closeEnabled(): boolean {
return this.closeEnabledSignal()
}
set closeEnabled(closeEnabled: boolean) {
this.closeEnabledSignal.set(closeEnabled)
}
get error(): any {
return this.errorSignal()
}
set error(error: any) {
this.errorSignal.set(error)
}
abstract getForm(): FormGroup
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -158,9 +158,33 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService
accounts: MailAccount[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
private accountsSignal = signal<MailAccount[]>(undefined)
private correspondentsSignal = signal<Correspondent[]>(undefined)
private documentTypesSignal = signal<DocumentType[]>(undefined)
get accounts(): MailAccount[] {
return this.accountsSignal()
}
set accounts(accounts: MailAccount[]) {
this.accountsSignal.set(accounts)
}
get correspondents(): Correspondent[] {
return this.correspondentsSignal()
}
set correspondents(correspondents: Correspondent[]) {
this.correspondentsSignal.set(correspondents)
}
get documentTypes(): DocumentType[] {
return this.documentTypesSignal()
}
set documentTypes(documentTypes: DocumentType[]) {
this.documentTypesSignal.set(documentTypes)
}
constructor() {
super()
@@ -67,11 +67,11 @@ export class StoragePathEditDialogComponent
private testDocument: Document
public testResult: string
public testFailed: boolean = false
public loading = false
public testLoading = false
constructor() {
super()
this.loading = false
this.service = inject(StoragePathService)
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
@@ -1,4 +1,4 @@
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -40,9 +40,33 @@ export class UserEditDialogComponent
private toastService = inject(ToastService)
private groupsService: GroupService
groups: Group[]
passwordIsSet: boolean = false
public totpLoading: boolean = false
private groupsSignal = signal<Group[]>(undefined)
private passwordIsSetSignal = signal(false)
private totpLoadingSignal = signal(false)
get groups(): Group[] {
return this.groupsSignal()
}
set groups(groups: Group[]) {
this.groupsSignal.set(groups)
}
get passwordIsSet(): boolean {
return this.passwordIsSetSignal()
}
set passwordIsSet(passwordIsSet: boolean) {
this.passwordIsSetSignal.set(passwordIsSet)
}
public get totpLoading(): boolean {
return this.totpLoadingSignal()
}
public set totpLoading(totpLoading: boolean) {
this.totpLoadingSignal.set(totpLoading)
}
constructor() {
super()
@@ -103,7 +127,7 @@ export class UserEditDialogComponent
if (!groupsVal) return []
else
return groupsVal.flatMap(
(id) => this.groups.find((g) => g.id == id)?.permissions
(id) => this.groups?.find((g) => g.id == id)?.permissions
)
}
@@ -4,7 +4,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject, signal } from '@angular/core'
import {
AbstractControl,
FormArray,
@@ -476,17 +476,73 @@ export class WorkflowEditDialogComponent
private mailRuleService: MailRuleService
private customFieldsService: CustomFieldsService
templates: Workflow[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
mailRules: MailRule[]
customFields: CustomField[]
dateCustomFields: CustomField[]
private templatesSignal = signal<Workflow[]>(undefined)
private correspondentsSignal = signal<Correspondent[]>(undefined)
private documentTypesSignal = signal<DocumentType[]>(undefined)
private storagePathsSignal = signal<StoragePath[]>(undefined)
private mailRulesSignal = signal<MailRule[]>(undefined)
private customFieldsSignal = signal<CustomField[]>(undefined)
private dateCustomFieldsSignal = signal<CustomField[]>(undefined)
get templates(): Workflow[] {
return this.templatesSignal()
}
set templates(templates: Workflow[]) {
this.templatesSignal.set(templates)
}
get correspondents(): Correspondent[] {
return this.correspondentsSignal()
}
set correspondents(correspondents: Correspondent[]) {
this.correspondentsSignal.set(correspondents)
}
get documentTypes(): DocumentType[] {
return this.documentTypesSignal()
}
set documentTypes(documentTypes: DocumentType[]) {
this.documentTypesSignal.set(documentTypes)
}
get storagePaths(): StoragePath[] {
return this.storagePathsSignal()
}
set storagePaths(storagePaths: StoragePath[]) {
this.storagePathsSignal.set(storagePaths)
}
get mailRules(): MailRule[] {
return this.mailRulesSignal()
}
set mailRules(mailRules: MailRule[]) {
this.mailRulesSignal.set(mailRules)
}
get customFields(): CustomField[] {
return this.customFieldsSignal()
}
set customFields(customFields: CustomField[]) {
this.customFieldsSignal.set(customFields)
}
get dateCustomFields(): CustomField[] {
return this.dateCustomFieldsSignal()
}
set dateCustomFields(dateCustomFields: CustomField[]) {
this.dateCustomFieldsSignal.set(dateCustomFields)
}
expandedItem: number = null
private allowedActionTypes = []
private allowedActionTypesSignal = signal([])
private readonly triggerFilterOptionsMap = new WeakMap<
FormArray,
@@ -1294,9 +1350,18 @@ export class WorkflowEditDialogComponent
}
get actionTypeOptions() {
this.settingsService.trackChanges()
return this.allowedActionTypes
}
get allowedActionTypes() {
return this.allowedActionTypesSignal()
}
set allowedActionTypes(allowedActionTypes) {
this.allowedActionTypesSignal.set(allowedActionTypes)
}
getActionTypeOptionName(type: WorkflowActionType): string {
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
}
@@ -1,4 +1,4 @@
import { Component, Input, inject } from '@angular/core'
import { Component, Input, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -17,23 +17,37 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
private documentService = inject(DocumentService)
private toastService = inject(ToastService)
@Input()
documentIds: number[]
private documentIdsSignal = signal<number[]>(undefined)
private hasArchiveVersionSignal = signal(true)
private useArchiveVersionSignal = signal(true)
private _hasArchiveVersion: boolean = true
@Input()
get documentIds(): number[] {
return this.documentIdsSignal()
}
set documentIds(documentIds: number[]) {
this.documentIdsSignal.set(documentIds)
}
get useArchiveVersion(): boolean {
return this.useArchiveVersionSignal()
}
set useArchiveVersion(useArchiveVersion: boolean) {
this.useArchiveVersionSignal.set(useArchiveVersion)
}
@Input()
set hasArchiveVersion(value: boolean) {
this._hasArchiveVersion = value
this.hasArchiveVersionSignal.set(value)
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this._hasArchiveVersion
return this.hasArchiveVersionSignal()
}
public useArchiveVersion: boolean = true
public emailAddress: string = ''
public emailSubject: string = ''
public emailMessage: string = ''
@@ -5,6 +5,7 @@ import {
Input,
OnInit,
Output,
signal,
ViewChild,
} from '@angular/core'
import { ControlValueAccessor } from '@angular/forms'
@@ -61,7 +62,15 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
@Output()
removed: EventEmitter<AbstractInputComponent<any>> = new EventEmitter()
value: T
private valueSignal = signal<T>(undefined)
get value(): T {
return this.valueSignal()
}
set value(value: T) {
this.valueSignal.set(value)
}
ngOnInit(): void {
this.inputId = uuidv4()
@@ -1,4 +1,4 @@
import { Component, forwardRef, inject } from '@angular/core'
import { Component, forwardRef, inject, signal } from '@angular/core'
import {
FormsModule,
NG_VALUE_ACCESSOR,
@@ -24,7 +24,15 @@ import { AbstractInputComponent } from '../../abstract-input'
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
})
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
groups: Group[]
private groupsSignal = signal<Group[]>(undefined)
get groups(): Group[] {
return this.groupsSignal()
}
set groups(groups: Group[]) {
this.groupsSignal.set(groups)
}
constructor() {
const groupService = inject(GroupService)
@@ -1,4 +1,4 @@
import { Component, forwardRef, inject } from '@angular/core'
import { Component, forwardRef, inject, signal } from '@angular/core'
import {
FormsModule,
NG_VALUE_ACCESSOR,
@@ -24,7 +24,15 @@ import { AbstractInputComponent } from '../../abstract-input'
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
})
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
users: User[]
private usersSignal = signal<User[]>(undefined)
get users(): User[] {
return this.usersSignal()
}
set users(users: User[]) {
this.usersSignal.set(users)
}
constructor() {
const userService = inject(UserService)
@@ -6,6 +6,7 @@ import {
Input,
OnInit,
Output,
signal,
ViewChild,
} from '@angular/core'
import {
@@ -109,7 +110,15 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@ViewChild('tagSelect') select: NgSelectComponent
value: number[] = []
private valueSignal = signal<number[]>([])
get value(): number[] {
return this.valueSignal()
}
set value(value: number[]) {
this.valueSignal.set(value)
}
tags: Tag[] = []
@@ -3,7 +3,7 @@ import {
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -45,8 +45,25 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
private readonly settingsService = inject(SettingsService)
activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number
versionID?: number
private documentIDSignal = signal<number>(undefined)
private versionIDSignal = signal<number>(undefined)
get documentID(): number {
return this.documentIDSignal()
}
set documentID(documentID: number) {
this.documentIDSignal.set(documentID)
}
get versionID(): number {
return this.versionIDSignal()
}
set versionID(versionID: number) {
this.versionIDSignal.set(versionID)
}
pages: PageOperation[] = []
totalPages = 0
editMode: PdfEditorEditMode = this.settingsService.get(
@@ -27,7 +27,6 @@ import {
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import {
PdfRenderMode,
PdfSource,
PdfZoomLevel,
PdfZoomScale,
PngxPdfDocumentProxy,
@@ -43,7 +42,8 @@ export class PngxPdfViewerComponent
{
private readonly document = inject<Document>(DOCUMENT)
@Input() src!: PdfSource
@Input() src!: string
@Input() password?: string
@Input() page?: number
@Output() pageChange = new EventEmitter<number>()
@Input() rotation?: number
@@ -93,7 +93,7 @@ export class PngxPdfViewerComponent
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['src']) {
if (changes['src'] || changes['password']) {
this.resetViewerState()
if (this.src) {
this.loadDocument()
@@ -174,8 +174,12 @@ export class PngxPdfViewerComponent
'assets/js/pdf.worker.min.mjs',
this.document.baseURI
).toString()
this.loadingTask = getDocument(this.src)
let initOptions = {
url: this.src,
password: this.password,
withCredentials: true,
}
this.loadingTask = getDocument(initOptions)
try {
const pdf = await this.loadingTask.promise
this.pdf = pdf
@@ -2,8 +2,6 @@ export type PngxPdfDocumentProxy = {
numPages: number
}
export type PdfSource = string | { url: string; password?: string }
export enum PdfRenderMode {
Single = 'single',
All = 'all',
@@ -1,4 +1,11 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import {
FormControl,
FormGroup,
@@ -27,21 +34,44 @@ export class PermissionsDialogComponent {
activeModal = inject(NgbActiveModal)
private userService = inject(UserService)
users: User[]
private usersSignal = signal<User[]>(undefined)
private titleSignal = signal($localize`Set permissions`)
private noteSignal = signal<string>(null)
private buttonsEnabledSignal = signal(true)
private o: ObjectWithPermissions = undefined
constructor() {
this.userService.listAll().subscribe((r) => (this.users = r.results))
}
get users(): User[] {
return this.usersSignal()
}
set users(users: User[]) {
this.usersSignal.set(users)
}
@Output()
public confirmClicked = new EventEmitter()
@Input()
title = $localize`Set permissions`
get title(): string {
return this.titleSignal()
}
set title(title: string) {
this.titleSignal.set(title)
}
@Input()
note: string = null
get note(): string {
return this.noteSignal()
}
set note(note: string) {
this.noteSignal.set(note)
}
@Input()
set object(o: ObjectWithPermissions) {
@@ -65,7 +95,13 @@ export class PermissionsDialogComponent {
merge: new FormControl(true),
})
buttonsEnabled: boolean = true
get buttonsEnabled(): boolean {
return this.buttonsEnabledSignal()
}
set buttonsEnabled(buttonsEnabled: boolean) {
this.buttonsEnabledSignal.set(buttonsEnabled)
}
get permissions() {
return {
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, OnInit, inject } from '@angular/core'
import { Component, Input, OnInit, inject, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -27,50 +27,74 @@ export class ShareLinksDialogComponent implements OnInit {
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
@Input()
title = $localize`Share Links`
private titleSignal = signal($localize`Share Links`)
private documentIdSignal = signal<number>(undefined)
private hasArchiveVersionSignal = signal(true)
private shareLinksSignal = signal<ShareLink[]>(undefined)
private copiedSignal = signal<number>(undefined)
_documentId: number
@Input()
get title(): string {
return this.titleSignal()
}
set title(title: string) {
this.titleSignal.set(title)
}
@Input()
set documentId(id: number) {
if (id !== undefined) {
this._documentId = id
this.documentIdSignal.set(id)
this.refresh()
}
}
private _hasArchiveVersion: boolean = true
get documentId(): number {
return this.documentIdSignal()
}
@Input()
set hasArchiveVersion(value: boolean) {
this._hasArchiveVersion = value
this.hasArchiveVersionSignal.set(value)
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this._hasArchiveVersion
return this.hasArchiveVersionSignal()
}
shareLinks: ShareLink[]
get shareLinks(): ShareLink[] {
return this.shareLinksSignal()
}
set shareLinks(shareLinks: ShareLink[]) {
this.shareLinksSignal.set(shareLinks)
}
loading: boolean = false
copied: number
get copied(): number {
return this.copiedSignal()
}
set copied(copied: number) {
this.copiedSignal.set(copied)
}
expirationDays: number = 7
useArchiveVersion: boolean = true
ngOnInit(): void {
if (this._documentId !== undefined) this.refresh()
if (this.documentId !== undefined) this.refresh()
}
refresh() {
if (this._documentId === undefined) return
if (this.documentId === undefined) return
this.loading = true
this.shareLinkService
.getLinksForDocument(this._documentId)
.getLinksForDocument(this.documentId)
.pipe(first())
.subscribe({
next: (results) => {
@@ -141,7 +165,7 @@ export class ShareLinksDialogComponent implements OnInit {
this.loading = true
this.shareLinkService
.createLinkForDocument(
this._documentId,
this.documentId,
this.useArchiveVersion ? FileVersion.Archive : FileVersion.Original,
expiration
)
@@ -1,4 +1,4 @@
import { Component, inject, Input } from '@angular/core'
import { Component, inject, Input, signal } from '@angular/core'
import { Tag } from 'src/app/data/tag'
import {
PermissionAction,
@@ -16,16 +16,16 @@ export class TagComponent {
private permissionsService = inject(PermissionsService)
private tagService = inject(TagService)
private _tag: Tag
private tagSignal = signal<Tag>(null)
private _tagID: number
@Input()
public set tag(tag: Tag) {
this._tag = tag
this.tagSignal.set(tag)
}
public get tag(): Tag {
return this._tag
return this.tagSignal()
}
@Input()
@@ -5,7 +5,7 @@ import {
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { RouterModule } from '@angular/router'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
@@ -47,7 +47,15 @@ export class DashboardComponent extends ComponentWithPermissions {
private tourService = inject(TourService)
private toastService = inject(ToastService)
public dashboardViews: SavedView[] = []
private dashboardViewsSignal = signal<SavedView[]>([])
get dashboardViews(): SavedView[] {
return this.dashboardViewsSignal()
}
set dashboardViews(value: SavedView[]) {
this.dashboardViewsSignal.set(value)
}
constructor() {
super()
@@ -1,11 +1,14 @@
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[badge]="count"
[loading]="loading"
[loading]="false"
[draggable]="savedView"
>
@if (count !== null) {
<span title-badge class="badge bg-info text-dark ms-2">{{count}}</span>
}
@if (documents.length) {
<a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
}
@@ -30,11 +33,24 @@
</tr>
</thead>
<tbody>
@if (loading && !documents.length) {
@for (row of placeholderRows; track row) {
<tr>
@for (field of displayFields; track field; let j = $index) {
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': j > 1 }">
<div class="placeholder-glow text-start">
<span class="placeholder bg-secondary w-50" [ngStyle]="{ opacity: 1 - (row * 1/placeholderRows.length) }"></span>
</div>
</td>
}
</tr>
}
}
@for (doc of documents; track doc.id; let i = $index) {
<tr>
@for (field of displayFields; track field; let j = $index) {
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': j > 1 }">
@if (loading && show) {
@if (loading && !documents.length) {
<div class="placeholder-glow text-start">
<span class="placeholder bg-secondary w-50" [ngStyle]="{ opacity: 1 - (i * 1/documents.length) }"></span>
</div>
@@ -115,12 +131,22 @@
</table>
} @else if (displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards my-n2">
@if (loading && !documents.length) {
@for (row of placeholderRows; track row) {
<pngx-document-card-small
class="p-0"
[ngStyle]="{ opacity: 1 - (row * 1/placeholderRows.length) }"
[document]="null"
[displayFields]="displayFields">
</pngx-document-card-small>
}
}
@for (d of documents; track d.id; let i = $index) {
<pngx-document-card-small
class="p-0"
[ngStyle]="{ opacity: !loading && show ? 1 : 1 - (i * 1/documents.length) }"
[ngStyle]="{ opacity: show ? 1 : 1 - (i * 1/documents.length) }"
(dblClickDocument)="openDocumentDetail(d)"
[document]="!loading && show ? d : null"
[document]="d"
[displayFields]="displayFields"
(clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)"
@@ -131,11 +157,20 @@
</div>
} @else if (displayMode === DisplayMode.LARGE_CARDS) {
<div class="row my-n2">
@if (loading && !documents.length) {
@for (row of placeholderRows; track row) {
<pngx-document-card-large
[document]="null"
[ngStyle]="{ opacity: 1 - (row * 1/placeholderRows.length) }"
[displayFields]="displayFields">
</pngx-document-card-large>
}
}
@for (d of documents; track d.id; let i = $index) {
<pngx-document-card-large
(dblClickDocument)="openDocumentDetail(d)"
[document]="!loading && show ? d : null"
[ngStyle]="{ opacity: !loading && show ? 1 : 1 - (i * 1/documents.length) }"
[document]="d"
[ngStyle]="{ opacity: show ? 1 : 1 - (i * 1/documents.length) }"
[displayFields]="displayFields"
(clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)"
@@ -6,12 +6,13 @@ import {
OnDestroy,
OnInit,
QueryList,
signal,
ViewChildren,
} from '@angular/core'
import { Router, RouterModule } from '@angular/router'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, Subject, takeUntil, tap } from 'rxjs'
import { Subject, takeUntil } from 'rxjs'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
import { TagComponent } from 'src/app/components/common/tag/tag.component'
@@ -106,7 +107,7 @@ export class SavedViewWidgetComponent
@Input()
savedView: SavedView
documents: Document[] = []
private documentsSignal = signal<Document[]>([])
unsubscribeNotifier: Subject<any> = new Subject()
@@ -116,21 +117,56 @@ export class SavedViewWidgetComponent
mouseOnPreview = false
popoverHidden = true
displayMode: DisplayMode
private displayModeSignal = signal<DisplayMode>(null)
displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
private displayFieldsSignal = signal<DisplayField[]>(
DEFAULT_DASHBOARD_DISPLAY_FIELDS
)
count: number
private countSignal = signal<number>(null)
placeholderRows: number[] = []
get documents(): Document[] {
return this.documentsSignal()
}
set documents(value: Document[]) {
this.documentsSignal.set(value)
}
get displayMode(): DisplayMode {
return this.displayModeSignal()
}
set displayMode(value: DisplayMode) {
this.displayModeSignal.set(value)
}
get displayFields(): DisplayField[] {
return this.displayFieldsSignal()
}
set displayFields(value: DisplayField[]) {
this.displayFieldsSignal.set(value)
}
get count(): number {
return this.countSignal()
}
set count(value: number) {
this.countSignal.set(value)
}
ngOnInit(): void {
this.reload()
this.placeholderRows = Array.from(
{
length: this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
},
(_, index) => index
)
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
this.websocketStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.reload()
})
if (
this.permissionsService.currentUserCan(
@@ -159,6 +195,14 @@ export class SavedViewWidgetComponent
this.settingsService.allDisplayFields.find((f) => f.id === field) !==
undefined
)
this.reload()
this.websocketStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.reload()
})
}
ngOnDestroy(): void {
@@ -168,6 +212,7 @@ export class SavedViewWidgetComponent
reload() {
this.loading = this.documents.length == 0
this.show = true
this.documentService
.listFiltered(
1,
@@ -177,18 +222,13 @@ export class SavedViewWidgetComponent
this.savedView.filter_rules,
{ truncate_content: true }
)
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((result) => {
this.show = true
this.documents = result.results
this.count = result.count
this.savedViewService.setDocumentCount(this.savedView, result.count)
}),
delay(500)
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.documents = result.results
this.count = result.count
this.savedViewService.setDocumentCount(this.savedView, result.count)
this.loading = false
this.show = true
})
}
@@ -1,6 +1,6 @@
import { DecimalPipe } from '@angular/common'
import { HttpClient } from '@angular/common/http'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
import { RouterModule } from '@angular/router'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import * as mimeTypeNames from 'mime-names'
@@ -55,9 +55,24 @@ export class StatisticsWidgetComponent
private websocketConnectionService = inject(WebsocketStatusService)
private documentListViewService = inject(DocumentListViewService)
loading: boolean = false
private loadingSignal = signal(false)
private statisticsSignal = signal<Statistics>({})
statistics: Statistics = {}
get loading(): boolean {
return this.loadingSignal()
}
set loading(value: boolean) {
this.loadingSignal.set(value)
}
get statistics(): Statistics {
return this.statisticsSignal()
}
set statistics(value: Statistics) {
this.statisticsSignal.set(value)
}
subscription: Subscription
private unsubscribeNotifer: Subject<any> = new Subject()
@@ -1,19 +1,20 @@
@if (!cardless) {
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
@if (!cardless()) {
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable()" cdkDragPreviewContainer="parent">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
@if (draggable) {
@if (draggable()) {
<div class="ms-n2 me-1" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs>
</div>
}
<h6 class="card-title mb-0">{{title}}</h6>
@if (badge) {
<span class="badge bg-info text-dark ms-2">{{badge}}</span>
<h6 class="card-title mb-0">{{title()}}</h6>
<ng-content select="[title-badge]"></ng-content>
@if (badge() !== null && badge() !== undefined) {
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
}
</div>
@if (loading) {
@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>
}
@@ -1,3 +1,23 @@
i-bs {
cursor: move;
}
.fade.show {
animation: pngx-entry-fade 160ms ease-out;
}
@keyframes pngx-entry-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.fade.show {
animation: none;
}
}
@@ -45,21 +45,19 @@ describe('WidgetFrameComponent', () => {
})
it('should show title', () => {
component.title = 'Foo'
fixture.componentRef.setInput('title', 'Foo')
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain('Foo')
})
it('should show loading indicator', () => {
expect(fixture.debugElement.query(By.css('.spinner-border'))).toBeNull()
component.loading = true
fixture.componentRef.setInput('loading', true)
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('.spinner-border'))).not.toBeNull()
})
it('should show', () => {
expect(component.show).toBeFalsy()
jest.advanceTimersByTime(100)
expect(component.show).toBeTruthy()
})
})
@@ -1,8 +1,7 @@
import { DragDropModule } from '@angular/cdk/drag-drop'
import { NgTemplateOutlet } from '@angular/common'
import { AfterViewInit, Component, Input } from '@angular/core'
import { AfterViewInit, Component, input, signal } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
@Component({
selector: 'pngx-widget-frame',
@@ -10,32 +9,28 @@ import { LoadingComponentWithPermissions } from 'src/app/components/loading-comp
styleUrls: ['./widget-frame.component.scss'],
imports: [DragDropModule, NgxBootstrapIconsModule, NgTemplateOutlet],
})
export class WidgetFrameComponent
extends LoadingComponentWithPermissions
implements AfterViewInit
{
constructor() {
super()
export class WidgetFrameComponent implements AfterViewInit {
private showSignal = signal(false)
loading = input(false)
get show(): boolean {
return this.showSignal()
}
@Input()
title: string
set show(value: boolean) {
this.showSignal.set(value)
}
@Input()
loading: boolean = false
title = input<string>()
@Input()
draggable: any
draggable = input<any>()
@Input()
cardless: boolean = false
cardless = input(false)
@Input()
badge: string
badge = input<string | number>(null)
ngAfterViewInit(): void {
setTimeout(() => {
this.show = true
}, 100)
this.show = true
}
}
@@ -445,8 +445,7 @@
<button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
@if (hasNext()) {
<button type="button" class="order-1 btn btn-sm btn-outline-primary" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
}
@if (!hasNext()) {
} @else {
<button type="button" class="order-2 btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
}
<button type="button" class="order-0 btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
@@ -473,6 +472,7 @@
<div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer
[src]="pdfSource"
[password]="pdfPassword"
[renderMode]="PdfRenderMode.All"
[(page)]="previewCurrentPage"
[zoomScale]="previewZoomScale"
@@ -853,6 +853,7 @@ describe('DocumentDetailComponent', () => {
.find((b) => b.nativeElement.textContent === 'Save & next')
).toBeUndefined()
nextSpy.mockReturnValue(true)
component.networkActive = true
fixture.detectChanges()
expect(
fixture.debugElement
@@ -1530,11 +1531,13 @@ describe('DocumentDetailComponent', () => {
})
it('should clear pdf source if preview URL is empty', () => {
component.pdfSource = { url: '/preview', password: 'secret' } as any
component.pdfSource = '/preview'
component.pdfPassword = 'secret'
component.previewUrl = null
;(component as any).updatePdfSource()
expect(component.pdfSource).toEqual({ url: null, password: undefined })
expect(component.pdfSource).toEqual(null)
expect(component.pdfPassword).toBeUndefined()
})
it('should close incoming update modal if one is open', () => {
@@ -1871,9 +1874,8 @@ describe('DocumentDetailComponent', () => {
expect(component.thumbUrl).toBe('thumb-version')
expect(component.previewText).toBe('version text')
expect(component.documentForm.get('content').value).toBe('version-content')
const pdfSource = component.pdfSource as { url: string; password?: string }
expect(pdfSource.url).toBe('preview-version')
expect(pdfSource.password).toBeUndefined()
expect(component.pdfSource).toBe('preview-version')
expect(component.pdfPassword).toBeUndefined()
previewSpy.mockReturnValueOnce('preview-error')
component.selectVersion(11)
@@ -1,6 +1,13 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { HttpClient, HttpResponse } from '@angular/common/http'
import { Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'
import {
Component,
inject,
OnDestroy,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import {
FormArray,
FormControl,
@@ -115,7 +122,6 @@ import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import {
PdfRenderMode,
PdfSource,
PdfZoomLevel,
PdfZoomScale,
PngxPdfDocumentProxy,
@@ -228,33 +234,34 @@ export class DocumentDetailComponent
expandOriginalMetadata = false
expandArchivedMetadata = false
error: any
private errorSignal = signal<any>(undefined)
networkActive = false
private networkActiveSignal = signal(false)
documentId: number
document: Document
metadata: DocumentMetadata
suggestions: DocumentSuggestions
suggestionsLoading: boolean = false
users: User[]
private documentIdSignal = signal<number>(undefined)
private documentSignal = signal<Document>(undefined)
private metadataSignal = signal<DocumentMetadata>(undefined)
private suggestionsSignal = signal<DocumentSuggestions>(undefined)
private suggestionsLoadingSignal = signal(false)
private usersSignal = signal<User[]>(undefined)
title: string
private titleSignal = signal<string>(undefined)
titleSubject: Subject<string> = new Subject()
previewUrl: string
pdfSource?: PdfSource
thumbUrl: string
previewText: string
previewLoaded: boolean = false
tiffURL: string
tiffError: string
private previewUrlSignal = signal<string>(undefined)
private pdfSourceSignal = signal<string>(undefined)
private pdfPasswordSignal = signal<string>(undefined)
private thumbUrlSignal = signal<string>(undefined)
private previewTextSignal = signal<string>(undefined)
private previewLoadedSignal = signal(false)
private tiffURLSignal = signal<string>(undefined)
private tiffErrorSignal = signal<string>(undefined)
// Versioning
selectedVersionId: number
private selectedVersionIdSignal = signal<number>(undefined)
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
private correspondentsSignal = signal<Correspondent[]>(undefined)
private documentTypesSignal = signal<DocumentType[]>(undefined)
private storagePathsSignal = signal<StoragePath[]>(undefined)
documentForm: FormGroup = new FormGroup({
title: new FormControl(''),
@@ -269,10 +276,10 @@ export class DocumentDetailComponent
custom_fields: new FormArray([]),
})
previewCurrentPage: number = 1
previewNumPages: number
previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One
previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth
private previewCurrentPageSignal = signal(1)
private previewNumPagesSignal = signal<number>(undefined)
private previewZoomSettingSignal = signal<PdfZoomLevel>(PdfZoomLevel.One)
private previewZoomScaleSignal = signal<PdfZoomScale>(PdfZoomScale.PageWidth)
store: BehaviorSubject<any>
isDirty$: Observable<boolean>
@@ -287,9 +294,9 @@ export class DocumentDetailComponent
ogDate: Date
customFields: CustomField[]
private customFieldsSignal = signal<CustomField[]>(undefined)
public downloading: boolean = false
private downloadingSignal = signal(false)
public useFormattedFilename: boolean = false
public readonly CustomFieldDataType = CustomFieldDataType
@@ -314,13 +321,238 @@ export class DocumentDetailComponent
}
DocumentDetailNavIDs = DocumentDetailNavIDs
activeNavID: number
private activeNavIDSignal = signal<number>(undefined)
get error(): any {
return this.errorSignal()
}
set error(value: any) {
this.errorSignal.set(value)
}
get networkActive(): boolean {
return this.networkActiveSignal()
}
set networkActive(value: boolean) {
this.networkActiveSignal.set(value)
}
get documentId(): number {
return this.documentIdSignal()
}
set documentId(value: number) {
this.documentIdSignal.set(value)
}
get document(): Document {
return this.documentSignal()
}
set document(value: Document) {
this.documentSignal.set(value)
}
get metadata(): DocumentMetadata {
return this.metadataSignal()
}
set metadata(value: DocumentMetadata) {
this.metadataSignal.set(value)
}
get suggestions(): DocumentSuggestions {
return this.suggestionsSignal()
}
set suggestions(value: DocumentSuggestions) {
this.suggestionsSignal.set(value)
}
get suggestionsLoading(): boolean {
return this.suggestionsLoadingSignal()
}
set suggestionsLoading(value: boolean) {
this.suggestionsLoadingSignal.set(value)
}
get users(): User[] {
return this.usersSignal()
}
set users(value: User[]) {
this.usersSignal.set(value)
}
get title(): string {
return this.titleSignal()
}
set title(value: string) {
this.titleSignal.set(value)
}
get previewUrl(): string {
return this.previewUrlSignal()
}
set previewUrl(value: string) {
this.previewUrlSignal.set(value)
}
get pdfSource(): string {
return this.pdfSourceSignal()
}
set pdfSource(value: string) {
this.pdfSourceSignal.set(value)
}
get pdfPassword(): string {
return this.pdfPasswordSignal()
}
set pdfPassword(value: string) {
this.pdfPasswordSignal.set(value)
}
get thumbUrl(): string {
return this.thumbUrlSignal()
}
set thumbUrl(value: string) {
this.thumbUrlSignal.set(value)
}
get previewText(): string {
return this.previewTextSignal()
}
set previewText(value: string) {
this.previewTextSignal.set(value)
}
get previewLoaded(): boolean {
return this.previewLoadedSignal()
}
set previewLoaded(value: boolean) {
this.previewLoadedSignal.set(value)
}
get tiffURL(): string {
return this.tiffURLSignal()
}
set tiffURL(value: string) {
this.tiffURLSignal.set(value)
}
get tiffError(): string {
return this.tiffErrorSignal()
}
set tiffError(value: string) {
this.tiffErrorSignal.set(value)
}
get selectedVersionId(): number {
return this.selectedVersionIdSignal()
}
set selectedVersionId(value: number) {
this.selectedVersionIdSignal.set(value)
}
get correspondents(): Correspondent[] {
return this.correspondentsSignal()
}
set correspondents(value: Correspondent[]) {
this.correspondentsSignal.set(value)
}
get documentTypes(): DocumentType[] {
return this.documentTypesSignal()
}
set documentTypes(value: DocumentType[]) {
this.documentTypesSignal.set(value)
}
get storagePaths(): StoragePath[] {
return this.storagePathsSignal()
}
set storagePaths(value: StoragePath[]) {
this.storagePathsSignal.set(value)
}
get previewCurrentPage(): number {
return this.previewCurrentPageSignal()
}
set previewCurrentPage(value: number) {
this.previewCurrentPageSignal.set(value)
}
get previewNumPages(): number {
return this.previewNumPagesSignal()
}
set previewNumPages(value: number) {
this.previewNumPagesSignal.set(value)
}
get previewZoomSetting(): PdfZoomLevel {
return this.previewZoomSettingSignal()
}
set previewZoomSetting(value: PdfZoomLevel) {
this.previewZoomSettingSignal.set(value)
}
get previewZoomScale(): PdfZoomScale {
return this.previewZoomScaleSignal()
}
set previewZoomScale(value: PdfZoomScale) {
this.previewZoomScaleSignal.set(value)
}
get downloading(): boolean {
return this.downloadingSignal()
}
set downloading(value: boolean) {
this.downloadingSignal.set(value)
}
get activeNavID(): number {
return this.activeNavIDSignal()
}
set activeNavID(value: number) {
this.activeNavIDSignal.set(value)
}
get customFields(): CustomField[] {
return this.customFieldsSignal()
}
set customFields(value: CustomField[]) {
this.customFieldsSignal.set(value)
}
titleKeyUp(event) {
this.titleSubject.next(event.target?.value)
}
get useNativePdfViewer(): boolean {
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
}
@@ -329,10 +561,12 @@ export class DocumentDetailComponent
}
get aiEnabled(): boolean {
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
}
get archiveContentRenderType(): ContentRenderType {
this.settings.trackChanges()
const hasArchiveVersion =
this.metadata?.has_archive_version ?? !!this.document?.archived_file_name
return hasArchiveVersion
@@ -343,16 +577,19 @@ export class DocumentDetailComponent
}
get originalContentRenderType(): ContentRenderType {
this.settings.trackChanges()
return this.getRenderType(
this.metadata?.original_mime_type || this.document?.mime_type
)
}
get showThumbnailOverlay(): boolean {
this.settings.trackChanges()
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
}
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
this.settings.trackChanges()
return this.settings
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
.includes(fieldId)
@@ -375,10 +612,8 @@ export class DocumentDetailComponent
}
private updatePdfSource() {
this.pdfSource = {
url: this.previewUrl,
password: this.password,
}
this.pdfSource = this.previewUrl
this.pdfPassword = this.password
}
private loadMetadataForSelectedVersion() {
@@ -1494,9 +1729,7 @@ export class DocumentDetailComponent
pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) {
this.previewNumPages = pdf.numPages
if (this.password) this.requiresPassword = false
setTimeout(() => {
this.previewLoaded = true
}, 300)
this.previewLoaded = true
}
onError(event) {
@@ -2,6 +2,26 @@
overflow-wrap: anywhere;
}
.fade.show {
animation: pngx-entry-fade 160ms ease-out;
}
@keyframes pngx-entry-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.fade.show {
animation: none;
}
}
.doc-img-container {
position: relative;
}
@@ -14,7 +14,6 @@ import {
NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, of } from 'rxjs'
import {
DEFAULT_DISPLAY_FIELDS,
DisplayField,
@@ -108,11 +107,7 @@ export class DocumentCardLargeComponent
popoverHidden = true
ngAfterViewInit(): void {
of(true)
.pipe(delay(50))
.subscribe(() => {
this.show = true
})
this.show = true
}
get searchScoreClass() {
@@ -2,6 +2,26 @@
font-size: 90%;
}
.fade.show {
animation: pngx-entry-fade 160ms ease-out;
}
@keyframes pngx-entry-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.fade.show {
animation: none;
}
}
.doc-img {
object-fit: cover;
object-position: top left;
@@ -14,8 +14,6 @@ import {
NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { delay } from 'rxjs/operators'
import {
DEFAULT_DISPLAY_FIELDS,
DisplayField,
@@ -101,11 +99,7 @@ export class DocumentCardSmallComponent
@ViewChild('popupPreview') popupPreview: PreviewPopupComponent
ngAfterViewInit(): void {
of(true)
.pipe(delay(50))
.subscribe(() => {
this.show = true
})
this.show = true
}
getIsThumbInverted() {
@@ -5,6 +5,7 @@ import {
OnInit,
Output,
inject,
signal,
} from '@angular/core'
import {
FormControl,
@@ -32,29 +33,42 @@ import { TextComponent } from '../../common/input/text/text.component'
})
export class SaveViewConfigDialogComponent implements OnInit {
private modal = inject(NgbActiveModal)
private errorSignal = signal(undefined)
private buttonsEnabledSignal = signal(true)
private defaultNameSignal = signal('')
@Output()
public saveClicked = new EventEmitter()
@Input()
error
get error() {
return this.errorSignal()
}
set error(error) {
this.errorSignal.set(error)
}
@Input()
buttonsEnabled = true
get buttonsEnabled(): boolean {
return this.buttonsEnabledSignal()
}
set buttonsEnabled(buttonsEnabled: boolean) {
this.buttonsEnabledSignal.set(buttonsEnabled)
}
closeEnabled = false
users: User[]
_defaultName = ''
get defaultName() {
return this._defaultName
return this.defaultNameSignal()
}
@Input()
set defaultName(value: string) {
this._defaultName = value
this.defaultNameSignal.set(value)
this.saveViewConfigForm.patchValue({ name: value })
}
@@ -1,4 +1,4 @@
import { Directive, OnDestroy } from '@angular/core'
import { Directive, OnDestroy, signal } from '@angular/core'
import { Subject } from 'rxjs'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@@ -7,8 +7,24 @@ export abstract class LoadingComponentWithPermissions
extends ComponentWithPermissions
implements OnDestroy
{
public loading: boolean = true
public show: boolean = false
private loadingSignal = signal(true)
private showSignal = signal(false)
public get loading(): boolean {
return this.loadingSignal()
}
public set loading(value: boolean) {
this.loadingSignal.set(value)
}
public get show(): boolean {
return this.showSignal()
}
public set show(value: boolean) {
this.showSignal.set(value)
}
protected unsubscribeNotifier: Subject<any> = new Subject()
@@ -1,4 +1,4 @@
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject, signal } from '@angular/core'
import { RouterModule } from '@angular/router'
import {
NgbDropdownModule,
@@ -6,7 +6,7 @@ import {
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, takeUntil, tap } from 'rxjs'
import { takeUntil, tap } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
@@ -51,21 +51,29 @@ export class CustomFieldsComponent
private readonly documentService = inject(DocumentService)
private readonly savedViewService = inject(SavedViewService)
public fields: CustomField[] = []
private fieldsSignal = signal<CustomField[]>([])
public get fields(): CustomField[] {
return this.fieldsSignal()
}
public set fields(fields: CustomField[]) {
this.fieldsSignal.set(fields)
}
ngOnInit() {
this.reload()
}
reload() {
this.loading = true
this.customFieldsService
.listAll()
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((r) => {
this.fields = r.results
}),
delay(100)
})
)
.subscribe(() => {
this.show = true
@@ -1,11 +1,10 @@
import { NgComponentOutlet } from '@angular/common'
import {
AfterViewChecked,
ChangeDetectorRef,
Component,
inject,
OnDestroy,
OnInit,
signal,
Type,
ViewChild,
} from '@angular/core'
@@ -70,13 +69,10 @@ interface DocumentAttributesSection {
ClearableBadgeComponent,
],
})
export class DocumentAttributesComponent
implements OnInit, OnDestroy, AfterViewChecked
{
export class DocumentAttributesComponent implements OnInit, OnDestroy {
private readonly permissionsService = inject(PermissionsService)
private readonly activatedRoute = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly cdr = inject(ChangeDetectorRef)
private readonly unsubscribeNotifier = new Subject<void>()
protected readonly PermissionAction = PermissionAction
@@ -136,11 +132,20 @@ export class DocumentAttributesComponent
]
@ViewChild('activeOutlet', { read: NgComponentOutlet })
private readonly activeOutlet?: NgComponentOutlet
set activeOutlet(outlet: NgComponentOutlet | undefined) {
this.activeComponentSignal.set(outlet?.componentInstance ?? null)
}
private lastHeaderLoading: boolean
private activeComponentSignal = signal<unknown>(null)
private activeNavIDSignal = signal<number>(null)
activeNavID: number = null
get activeNavID(): number {
return this.activeNavIDSignal()
}
set activeNavID(activeNavID: number) {
this.activeNavIDSignal.set(activeNavID)
}
get visibleSections(): DocumentAttributesSection[] {
return this.sections.filter((section) =>
@@ -163,14 +168,14 @@ export class DocumentAttributesComponent
this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList
)
return null
const instance = this.activeOutlet?.componentInstance
const instance = this.activeComponentSignal()
return instance instanceof ManagementListComponent ? instance : null
}
get activeCustomFields(): CustomFieldsComponent | null {
if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
return null
const instance = this.activeOutlet?.componentInstance
const instance = this.activeComponentSignal()
return instance instanceof CustomFieldsComponent ? instance : null
}
@@ -221,14 +226,6 @@ export class DocumentAttributesComponent
this.unsubscribeNotifier.complete()
}
ngAfterViewChecked(): void {
const current = this.activeHeaderLoading
if (this.lastHeaderLoading !== current) {
this.lastHeaderLoading = current
this.cdr.detectChanges()
}
}
onNavChange(navChangeEvent: NgbNavChangeEvent): void {
const nextSection = this.getSectionForNavID(navChangeEvent.nextId)
if (!nextSection) {
@@ -5,13 +5,13 @@ import {
OnDestroy,
OnInit,
QueryList,
signal,
ViewChildren,
} from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs'
import {
debounceTime,
delay,
distinctUntilChanged,
takeUntil,
tap,
@@ -88,25 +88,89 @@ export abstract class ManagementListComponent<T extends MatchingModel>
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
public data: T[] = []
private unfilteredData: T[] = []
private dataSignal = signal<T[]>([])
private unfilteredDataSignal = signal<T[]>([])
private currentExtraParams: { [key: string]: any } = null
private allSelectionActive = false
public page = 1
private pageSignal = signal(1)
public collectionSize = 0
public displayCollectionSize = 0
private collectionSizeSignal = signal(0)
private displayCollectionSizeSignal = signal(0)
public sortField: string
public sortReverse: boolean
private sortFieldSignal = signal<string>(undefined)
private sortReverseSignal = signal<boolean>(undefined)
private nameFilterDebounce: Subject<string>
protected unsubscribeNotifier: Subject<any> = new Subject()
protected _nameFilter: string
public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false
private togggleAllSignal = signal(false)
public get data(): T[] {
return this.dataSignal()
}
public set data(data: T[]) {
this.dataSignal.set(data)
}
private get unfilteredData(): T[] {
return this.unfilteredDataSignal()
}
private set unfilteredData(data: T[]) {
this.unfilteredDataSignal.set(data)
}
public get page(): number {
return this.pageSignal()
}
public set page(page: number) {
this.pageSignal.set(page)
}
public get collectionSize(): number {
return this.collectionSizeSignal()
}
public set collectionSize(collectionSize: number) {
this.collectionSizeSignal.set(collectionSize)
}
public get displayCollectionSize(): number {
return this.displayCollectionSizeSignal()
}
public set displayCollectionSize(displayCollectionSize: number) {
this.displayCollectionSizeSignal.set(displayCollectionSize)
}
public get sortField(): string {
return this.sortFieldSignal()
}
public set sortField(sortField: string) {
this.sortFieldSignal.set(sortField)
}
public get sortReverse(): boolean {
return this.sortReverseSignal()
}
public set sortReverse(sortReverse: boolean) {
this.sortReverseSignal.set(sortReverse)
}
public get togggleAll(): boolean {
return this.togggleAllSignal()
}
public set togggleAll(togggleAll: boolean) {
this.togggleAllSignal.set(togggleAll)
}
public get hasSelection(): boolean {
return this.selectedObjects.size > 0 || this.allSelectionActive
@@ -201,8 +265,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.data = this.filterData(c.results)
this.collectionSize = this.getCollectionSize(c)
this.displayCollectionSize = this.getDisplayCollectionSize(c)
}),
delay(100)
})
)
.subscribe({
error: (error: HttpErrorResponse) => {
@@ -1,9 +1,9 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, delay, first, takeUntil, tap } from 'rxjs'
import { Subject, first, takeUntil, tap } from 'rxjs'
import { MailAccount, MailAccountType } from 'src/app/data/mail-account'
import { MailRule } from 'src/app/data/mail-rule'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
@@ -56,19 +56,32 @@ export class MailComponent
public MailAccountType = MailAccountType
private _mailAccounts: MailAccount[] = []
private mailAccountsSignal = signal<MailAccount[]>([])
public get mailAccounts() {
return this._mailAccounts
return this.mailAccountsSignal()
}
private set mailAccounts(accounts: MailAccount[]) {
this._mailAccounts = accounts
this.mailAccountsSignal.set(accounts)
this.mailAccountsById = new Map(
accounts.map((account) => [account.id, account])
)
}
public mailAccountsById: Map<number, MailAccount> = new Map()
public mailRules: MailRule[] = []
private mailAccountsByIdSignal = signal<Map<number, MailAccount>>(new Map())
public get mailAccountsById(): Map<number, MailAccount> {
return this.mailAccountsByIdSignal()
}
private set mailAccountsById(value: Map<number, MailAccount>) {
this.mailAccountsByIdSignal.set(value)
}
private mailRulesSignal = signal<MailRule[]>([])
public get mailRules(): MailRule[] {
return this.mailRulesSignal()
}
public set mailRules(value: MailRule[]) {
this.mailRulesSignal.set(value)
}
unsubscribeNotifier: Subject<any> = new Subject()
oAuthAccountId: number
@@ -81,10 +94,35 @@ export class MailComponent
return this.settingsService.get(SETTINGS_KEYS.OUTLOOK_OAUTH_URL)
}
public loadingRules: boolean = true
public showRules: boolean = false
public loadingAccounts: boolean = true
public showAccounts: boolean = false
private loadingRulesSignal = signal(true)
private showRulesSignal = signal(false)
private loadingAccountsSignal = signal(true)
private showAccountsSignal = signal(false)
public get loadingRules(): boolean {
return this.loadingRulesSignal()
}
public set loadingRules(value: boolean) {
this.loadingRulesSignal.set(value)
}
public get showRules(): boolean {
return this.showRulesSignal()
}
public set showRules(value: boolean) {
this.showRulesSignal.set(value)
}
public get loadingAccounts(): boolean {
return this.loadingAccountsSignal()
}
public set loadingAccounts(value: boolean) {
this.loadingAccountsSignal.set(value)
}
public get showAccounts(): boolean {
return this.showAccountsSignal()
}
public set showAccounts(value: boolean) {
this.showAccountsSignal.set(value)
}
ngOnInit(): void {
this.mailAccountService
@@ -94,6 +132,8 @@ export class MailComponent
takeUntil(this.unsubscribeNotifier),
tap((r) => {
this.mailAccounts = r.results
this.loadingAccounts = false
this.showAccounts = true
if (this.oAuthAccountId) {
this.editMailAccount(
this.mailAccounts.find(
@@ -101,15 +141,11 @@ export class MailComponent
)
)
}
}),
delay(100)
})
)
.subscribe({
next: () => {
this.loadingAccounts = false
this.showAccounts = true
},
error: (e) => {
this.loadingAccounts = false
this.toastService.showError(
$localize`Error retrieving mail accounts`,
e
@@ -124,15 +160,13 @@ export class MailComponent
takeUntil(this.unsubscribeNotifier),
tap((r) => {
this.mailRules = r.results
}),
delay(100)
)
.subscribe({
next: (r) => {
this.loadingRules = false
this.showRules = true
},
})
)
.subscribe({
error: (e) => {
this.loadingRules = false
this.toastService.showError($localize`Error retrieving mail rules`, e)
},
})
@@ -1,5 +1,5 @@
import { AsyncPipe } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -56,7 +56,7 @@ export class SavedViewsComponent
DisplayMode = DisplayMode
public savedViews: SavedView[]
private savedViewsSignal = signal<SavedView[]>(undefined)
private savedViewsGroup = new FormGroup({})
public savedViewsForm: FormGroup = new FormGroup({
savedViews: this.savedViewsGroup,
@@ -69,6 +69,14 @@ export class SavedViewsComponent
return this.settings.allDisplayFields
}
public get savedViews(): SavedView[] {
return this.savedViewsSignal()
}
public set savedViews(savedViews: SavedView[]) {
this.savedViewsSignal.set(savedViews)
}
constructor() {
super()
this.settings.organizingSidebarSavedViews = true
@@ -1,8 +1,8 @@
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, takeUntil, tap } from 'rxjs'
import { takeUntil, tap } from 'rxjs'
import { Workflow } from 'src/app/data/workflow'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -39,7 +39,15 @@ export class WorkflowsComponent
private modalService = inject(NgbModal)
private toastService = inject(ToastService)
public workflows: Workflow[] = []
private workflowsSignal = signal<Workflow[]>([])
public get workflows(): Workflow[] {
return this.workflowsSignal()
}
public set workflows(workflows: Workflow[]) {
this.workflowsSignal.set(workflows)
}
ngOnInit() {
this.reload()
@@ -51,8 +59,7 @@ export class WorkflowsComponent
.listAll()
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((r) => (this.workflows = r.results)),
delay(100)
tap((r) => (this.workflows = r.results))
)
.subscribe(() => {
this.show = true
@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core'
import { Injectable, inject, signal } from '@angular/core'
import { ParamMap, Router, UrlTree } from '@angular/router'
import { Observable, Subject, takeUntil } from 'rxjs'
import {
@@ -118,6 +118,7 @@ export class DocumentListViewService {
isReloading: boolean = false
initialized: boolean = false
error: string = null
private stateVersion = signal(0)
rangeSelectionAnchorIndex: number
lastRangeSelectionToIndex: number
@@ -132,6 +133,14 @@ export class DocumentListViewService {
private displayFieldsInitialized: boolean = false
private markChanged(): void {
this.stateVersion.update((version) => version + 1)
}
private trackState(): void {
this.stateVersion()
}
private restoreListViewState(savedState: unknown): ListViewState {
const newState = this.defaultListViewState()
@@ -159,10 +168,12 @@ export class DocumentListViewService {
}
get activeSavedViewId() {
this.trackState()
return this._activeSavedViewId
}
get activeSavedViewTitle() {
this.trackState()
return this.activeListViewState.title
}
@@ -191,6 +202,7 @@ export class DocumentListViewService {
)
this.saveDocumentListView()
}
this.markChanged()
})
}
@@ -243,6 +255,7 @@ export class DocumentListViewService {
} else {
this._activeSavedViewId = null
}
this.markChanged()
}
activateSavedViewWithQueryParams(view: SavedView, queryParams: ParamMap) {
@@ -267,6 +280,7 @@ export class DocumentListViewService {
this.activeListViewState.displayFields = view.display_fields
this.reduceSelectionToFilter()
this.markChanged()
if (!this.router.routerState.snapshot.url.includes('/view/')) {
this.router.navigate(['view', view.id])
@@ -297,6 +311,7 @@ export class DocumentListViewService {
this.activeListViewState.sortField = newState.sortField
this.activeListViewState.sortReverse = newState.sortReverse
this.activeListViewState.currentPage = newState.currentPage
this.markChanged()
this.reload(null, paramsEmpty) // update the params if there aren't any
}
}
@@ -305,6 +320,7 @@ export class DocumentListViewService {
this.cancelPending()
this.isReloading = true
this.error = null
this.markChanged()
let activeListViewState = this.activeListViewState
this.documentService
.listFiltered(
@@ -325,6 +341,7 @@ export class DocumentListViewService {
activeListViewState.documents = result.results
this.selectionData = resultWithSelectionData.selection_data ?? null
this.syncSelectedToCurrentPage()
this.markChanged()
if (updateQueryParams && !this._activeSavedViewId) {
let base = ['/documents']
@@ -343,12 +360,14 @@ export class DocumentListViewService {
onFinish()
}
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
this.markChanged()
},
error: (error) => {
this.isReloading = false
if (activeListViewState.currentPage != 1 && error.status == 404) {
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
activeListViewState.currentPage = 1
this.markChanged()
this.reload()
} else if (
activeListViewState.sortField.indexOf('custom_field') === 0 &&
@@ -381,6 +400,7 @@ export class DocumentListViewService {
errorMessage = error.error
}
this.error = errorMessage
this.markChanged()
}
},
})
@@ -397,12 +417,14 @@ export class DocumentListViewService {
if (resetPage) {
this.activeListViewState.currentPage = 1
}
this.markChanged()
this.reload()
this.reduceSelectionToFilter()
this.saveDocumentListView()
}
get filterRules(): FilterRule[] {
this.trackState()
return this.activeListViewState.filterRules
}
@@ -416,48 +438,58 @@ export class DocumentListViewService {
set sortField(field: string) {
this.activeListViewState.sortField = field
this.markChanged()
this.reload()
this.saveDocumentListView()
}
get sortField(): string {
this.trackState()
return this.activeListViewState.sortField
}
set sortReverse(reverse: boolean) {
this.activeListViewState.sortReverse = reverse
this.markChanged()
this.reload()
this.saveDocumentListView()
}
get sortReverse(): boolean {
this.trackState()
return this.activeListViewState.sortReverse
}
get collectionSize(): number {
this.trackState()
return this.activeListViewState.collectionSize
}
get currentPage(): number {
this.trackState()
return this.activeListViewState.currentPage
}
set currentPage(page: number) {
if (this.activeListViewState.currentPage == page) return
this.activeListViewState.currentPage = page
this.markChanged()
this.reload()
this.saveDocumentListView()
}
get documents(): Document[] {
this.trackState()
return this.activeListViewState.documents
}
get selected(): Set<number> {
this.trackState()
return this.activeListViewState.selected
}
get allSelected(): boolean {
this.trackState()
return this.activeListViewState.allSelected ?? false
}
@@ -474,16 +506,19 @@ export class DocumentListViewService {
setSort(field: string, reverse: boolean) {
this.activeListViewState.sortField = field
this.activeListViewState.sortReverse = reverse
this.markChanged()
this.reload()
this.saveDocumentListView()
}
set displayMode(mode: DisplayMode) {
this.activeListViewState.displayMode = mode
this.markChanged()
this.saveDocumentListView()
}
get displayMode(): DisplayMode {
this.trackState()
const mode = this.activeListViewState.displayMode ?? DisplayMode.SMALL_CARDS
if (mode === ('details' as any)) {
// legacy
@@ -494,11 +529,13 @@ export class DocumentListViewService {
set pageSize(size: number) {
this.activeListViewState.pageSize = size
this.markChanged()
this.reload()
this.saveDocumentListView()
}
get pageSize(): number {
this.trackState()
return (
this.activeListViewState.pageSize ??
this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
@@ -506,6 +543,7 @@ export class DocumentListViewService {
}
get displayFields(): DisplayField[] {
this.trackState()
return this.activeListViewState.displayFields ?? LIST_DEFAULT_DISPLAY_FIELDS
}
@@ -517,6 +555,7 @@ export class DocumentListViewService {
undefined
)
: fields
this.markChanged()
this.saveDocumentListView()
}
@@ -540,6 +579,7 @@ export class DocumentListViewService {
quickFilter(filterRules: FilterRule[]) {
this._activeSavedViewId = null
this.markChanged()
this.setFilterRules(filterRules)
this.router.navigate(['documents'])
}
@@ -628,6 +668,7 @@ export class DocumentListViewService {
this.activeListViewState.allSelected = false
this.selected.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
this.markChanged()
}
reduceSelectionToFilter() {
@@ -644,6 +685,7 @@ export class DocumentListViewService {
this.selected.delete(id)
}
}
this.markChanged()
})
}
}
@@ -651,6 +693,7 @@ export class DocumentListViewService {
selectAll() {
this.activeListViewState.allSelected = true
this.syncSelectedToCurrentPage()
this.markChanged()
}
selectPage() {
@@ -659,6 +702,7 @@ export class DocumentListViewService {
this.documents.forEach((doc) => {
this.selected.add(doc.id)
})
this.markChanged()
}
isSelected(d: Document) {
@@ -673,6 +717,7 @@ export class DocumentListViewService {
else this.selected.add(d.id)
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
this.lastRangeSelectionToIndex = null
this.markChanged()
}
selectRangeTo(d: Document) {
@@ -710,6 +755,7 @@ export class DocumentListViewService {
this.selected.add(d.id)
})
this.lastRangeSelectionToIndex = documentToIndex
this.markChanged()
} else {
// e.g. shift key but was first click
this.toggleSelected(d)
@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core'
import { Injectable, inject, signal } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable, Subject, of } from 'rxjs'
import { first } from 'rxjs/operators'
@@ -15,6 +15,7 @@ export class OpenDocumentsService {
private modalService = inject(NgbModal)
private MAX_OPEN_DOCUMENTS = 5
private stateVersion = signal(0)
constructor() {
this.load()
@@ -36,6 +37,14 @@ export class OpenDocumentsService {
private openDocuments: Document[] = []
private dirtyDocuments: Set<number> = new Set<number>()
private trackState(): void {
this.stateVersion()
}
private markChanged(): void {
this.stateVersion.update((version) => version + 1)
}
refreshDocument(id: number) {
let index = this.openDocuments.findIndex((doc) => doc.id == id)
if (index > -1) {
@@ -53,10 +62,12 @@ export class OpenDocumentsService {
}
getOpenDocuments(): Document[] {
this.trackState()
return this.openDocuments
}
getOpenDocument(id: number): Document {
this.trackState()
return this.openDocuments.find((d) => d.id == id)
}
@@ -101,10 +112,12 @@ export class OpenDocumentsService {
}
hasDirty(): boolean {
this.trackState()
return this.dirtyDocuments.size > 0
}
isDirty(doc: Document): boolean {
this.trackState()
return this.dirtyDocuments.has(doc.id)
}
@@ -170,6 +183,7 @@ export class OpenDocumentsService {
}
save() {
this.markChanged()
try {
sessionStorage.setItem(
OPEN_DOCUMENT_SERVICE.DOCUMENTS,
@@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { inject, Injectable, signal } from '@angular/core'
import { combineLatest, Observable, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators'
import { Results } from 'src/app/data/results'
@@ -17,8 +17,8 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
private settingsService = inject(SettingsService)
private documentService = inject(DocumentService)
private savedViews: SavedView[] = []
private savedViewDocumentCounts: Map<number, number> = new Map()
private savedViewsSignal = signal<SavedView[]>([])
private savedViewDocumentCountsSignal = signal<Map<number, number>>(new Map())
private unsubscribeNotifier: Subject<void> = new Subject<void>()
constructor() {
@@ -26,6 +26,22 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
this.resourceName = 'saved_views'
}
private get savedViews(): SavedView[] {
return this.savedViewsSignal()
}
private set savedViews(views: SavedView[]) {
this.savedViewsSignal.set(views)
}
private get savedViewDocumentCounts(): Map<number, number> {
return this.savedViewDocumentCountsSignal()
}
private set savedViewDocumentCounts(counts: Map<number, number>) {
this.savedViewDocumentCountsSignal.set(counts)
}
public list(
page?: number,
pageSize?: number,
@@ -176,7 +192,9 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
}
public setDocumentCount(view: SavedView, count: number) {
this.savedViewDocumentCounts.set(view.id, count)
const counts = new Map(this.savedViewDocumentCounts)
counts.set(view.id, count)
this.savedViewDocumentCounts = counts
}
public getDocumentCount(view: SavedView): number {
+97 -53
View File
@@ -7,6 +7,7 @@ import {
LOCALE_ID,
Renderer2,
RendererFactory2,
signal,
} from '@angular/core'
import { Meta } from '@angular/platform-browser'
import { CookieService } from 'ngx-cookie-service'
@@ -294,7 +295,16 @@ export class SettingsService {
protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/'
private settings: Record<string, any> = {}
currentUser: User
private settingsVersion = signal(0)
private currentUserSignal = signal<User>(undefined)
get currentUser(): User {
return this.currentUserSignal()
}
set currentUser(value: User) {
this.currentUserSignal.set(value)
}
public settingsSaved: EventEmitter<any> = new EventEmitter()
@@ -303,16 +313,48 @@ export class SettingsService {
return this._renderer
}
public dashboardIsEmpty: boolean = false
private dashboardIsEmptySignal = signal(false)
private globalDropzoneEnabledSignal = signal(true)
private globalDropzoneActiveSignal = signal(false)
private organizingSidebarSavedViewsSignal = signal(false)
public globalDropzoneEnabled: boolean = true
public globalDropzoneActive: boolean = false
public organizingSidebarSavedViews: boolean = false
public get dashboardIsEmpty(): boolean {
return this.dashboardIsEmptySignal()
}
private _allDisplayFields: Array<{ id: DisplayField; name: string }> =
DEFAULT_DISPLAY_FIELDS
public set dashboardIsEmpty(value: boolean) {
this.dashboardIsEmptySignal.set(value)
}
public get globalDropzoneEnabled(): boolean {
return this.globalDropzoneEnabledSignal()
}
public set globalDropzoneEnabled(value: boolean) {
this.globalDropzoneEnabledSignal.set(value)
}
public get globalDropzoneActive(): boolean {
return this.globalDropzoneActiveSignal()
}
public set globalDropzoneActive(value: boolean) {
this.globalDropzoneActiveSignal.set(value)
}
public get organizingSidebarSavedViews(): boolean {
return this.organizingSidebarSavedViewsSignal()
}
public set organizingSidebarSavedViews(value: boolean) {
this.organizingSidebarSavedViewsSignal.set(value)
}
private allDisplayFieldsSignal = signal<
Array<{ id: DisplayField; name: string }>
>(DEFAULT_DISPLAY_FIELDS)
public get allDisplayFields(): Array<{ id: DisplayField; name: string }> {
return this._allDisplayFields
return this.allDisplayFieldsSignal()
}
public displayFieldsInit: EventEmitter<boolean> = new EventEmitter()
@@ -326,6 +368,10 @@ export class SettingsService {
return !UNSAFE_OBJECT_KEYS.has(key)
}
public trackChanges(): void {
this.settingsVersion()
}
private assignSafeSettings(source: Record<string, any>) {
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return
@@ -375,44 +421,39 @@ export class SettingsService {
}
public initializeDisplayFields() {
this._allDisplayFields = DEFAULT_DISPLAY_FIELDS
const displayFields = DEFAULT_DISPLAY_FIELDS?.map((field) => {
if (
field.id === DisplayField.NOTES &&
!this.get(SETTINGS_KEYS.NOTES_ENABLED)
) {
return null
}
this._allDisplayFields = this._allDisplayFields
?.map((field) => {
if (
field.id === DisplayField.NOTES &&
!this.get(SETTINGS_KEYS.NOTES_ENABLED)
) {
return null
}
if (
[
DisplayField.TITLE,
DisplayField.CREATED,
DisplayField.ADDED,
DisplayField.ASN,
DisplayField.PAGE_COUNT,
DisplayField.SHARED,
].includes(field.id)
) {
return field
}
if (
[
DisplayField.TITLE,
DisplayField.CREATED,
DisplayField.ADDED,
DisplayField.ASN,
DisplayField.PAGE_COUNT,
DisplayField.SHARED,
].includes(field.id)
) {
return field
}
let type: PermissionType = Object.values(PermissionType).find((t) =>
t.includes(field.id)
)
if (field.id === DisplayField.OWNER) {
type = PermissionType.User
}
return this.permissionsService.currentUserCan(PermissionAction.View, type)
? field
: null
}).filter((f) => f)
let type: PermissionType = Object.values(PermissionType).find((t) =>
t.includes(field.id)
)
if (field.id === DisplayField.OWNER) {
type = PermissionType.User
}
return this.permissionsService.currentUserCan(
PermissionAction.View,
type
)
? field
: null
})
.filter((f) => f)
this.allDisplayFieldsSignal.set(displayFields)
if (
this.permissionsService.currentUserCan(
@@ -421,13 +462,15 @@ export class SettingsService {
)
) {
this.customFieldsService.listAll().subscribe((r) => {
this._allDisplayFields = this._allDisplayFields.concat(
r.results.map((field) => {
return {
id: `${DisplayField.CUSTOM_FIELD}${field.id}` as any,
name: field.name,
}
})
this.allDisplayFieldsSignal.set(
displayFields.concat(
r.results.map((field) => {
return {
id: `${DisplayField.CUSTOM_FIELD}${field.id}` as any,
name: field.name,
}
})
)
)
this.displayFieldsInit.emit(true)
})
@@ -438,8 +481,8 @@ export class SettingsService {
get displayName(): string {
return (
this.currentUser.first_name ??
this.currentUser.username ??
this.currentUser?.first_name ??
this.currentUser?.username ??
''
).trim()
}
@@ -572,7 +615,7 @@ export class SettingsService {
// special case to fallback
if (key === SETTINGS_KEYS.DEFAULT_PERMS_OWNER && value === undefined) {
return this.currentUser.id
return this.currentUser?.id
}
if (value !== undefined) {
@@ -606,6 +649,7 @@ export class SettingsService {
if (index == keys.length - 1) settingObj[keyPart] = value
else settingObj = settingObj[keyPart]
})
this.settingsVersion.update((version) => version + 1)
}
private settingIsSet(key: string): boolean {
+10 -2
View File
@@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { Injectable, inject, signal } from '@angular/core'
import { Observable, Subject } from 'rxjs'
import { first, map, takeUntil, tap } from 'rxjs/operators'
import {
@@ -23,10 +23,18 @@ export class TasksService {
public loading: boolean = false
private fileTasks: PaperlessTask[] = []
private fileTasksSignal = signal<PaperlessTask[]>([])
private unsubscribeNotifer: Subject<any> = new Subject()
private get fileTasks(): PaperlessTask[] {
return this.fileTasksSignal()
}
private set fileTasks(tasks: PaperlessTask[]) {
this.fileTasksSignal.set(tasks)
}
public get total(): number {
return this.fileTasks.length
}
+2 -2
View File
@@ -2,7 +2,7 @@ import {
importProvidersFrom,
inject,
provideAppInitializer,
provideZoneChangeDetection,
provideZonelessChangeDetection,
} from '@angular/core'
import { DragDropModule } from '@angular/cdk/drag-drop'
@@ -372,7 +372,7 @@ const icons = {
bootstrapApplication(AppComponent, {
providers: [
provideZoneChangeDetection(),
provideZonelessChangeDetection(),
importProvidersFrom(
BrowserModule,
AppRoutingModule,
-5
View File
@@ -46,11 +46,6 @@ import '@angular/localize/init'
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js' // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
+3
View File
@@ -10,6 +10,8 @@
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"ignoreDeprecations": "6.0",
"strict": false,
"target": "ES2022",
"module": "es2020",
"lib": [
@@ -20,5 +22,6 @@
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictTemplates": false
}
}
+3 -3
View File
@@ -70,13 +70,13 @@ def suggestions_last_modified(request, pk: int) -> datetime | None:
def metadata_etag(request, pk: int) -> str | None:
"""
Metadata is extracted from the original file, so use its checksum as the
ETag
Metadata responses include metadata as well as document fields, so include
the modification time with the checksum so metadata-only changes invalidate cache.
"""
doc = resolve_effective_document_by_pk(pk, request).document
if doc is None:
return None
return doc.checksum
return f"{doc.checksum}:{doc.modified.isoformat()}"
def metadata_last_modified(request, pk: int) -> datetime | None:
@@ -1,63 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-04 15:31
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
replaces = [
("documents", "0003_remove_document_storage_type"),
("documents", "0004_workflowtrigger_filter_has_any_correspondents_and_more"),
("documents", "0005_alter_document_checksum_unique"),
]
dependencies = [
("documents", "0002_squashed"),
]
operations = [
migrations.RemoveField(
model_name="document",
name="storage_type",
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_any_correspondents",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_any_correspondent",
to="documents.correspondent",
verbose_name="has one of these correspondents",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_any_document_types",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_any_document_type",
to="documents.documenttype",
verbose_name="has one of these document types",
),
),
migrations.AddField(
model_name="workflowtrigger",
name="filter_has_any_storage_paths",
field=models.ManyToManyField(
blank=True,
related_name="workflowtriggers_has_any_storage_path",
to="documents.storagepath",
verbose_name="has one of these storage paths",
),
),
migrations.AlterField(
model_name="document",
name="checksum",
field=models.CharField(
editable=False,
help_text="The checksum of the original document.",
max_length=32,
verbose_name="checksum",
),
),
]
@@ -1,252 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-04 15:31
import django.db.models.deletion
import django.db.models.functions.text
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
replaces = [
("documents", "0008_workflowaction_passwords_alter_workflowaction_type"),
("documents", "0009_alter_document_content_length"),
("documents", "0010_optimize_integer_field_sizes"),
("documents", "0011_alter_workflowaction_type"),
("documents", "0012_document_root_document"),
]
dependencies = [
("documents", "0007_sharelinkbundle"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="passwords",
field=models.JSONField(
blank=True,
help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.",
null=True,
verbose_name="passwords",
),
),
migrations.AlterField(
model_name="document",
name="content_length",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.functions.text.Length("content"),
help_text="Length of the content field in characters. Automatically maintained by the database for faster statistics computation.",
output_field=models.PositiveIntegerField(default=0),
serialize=False,
),
),
migrations.AlterField(
model_name="correspondent",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="documenttype",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveSmallIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
(32, "owner is"),
(33, "has owner in"),
(34, "does not have owner"),
(35, "does not have owner in"),
(36, "has custom field value"),
(37, "is shared by me"),
(38, "has custom fields"),
(39, "has custom field in"),
(40, "does not have custom field in"),
(41, "does not have custom field"),
(42, "custom fields query"),
(43, "created to"),
(44, "created from"),
(45, "added to"),
(46, "added from"),
(47, "mime type is"),
],
verbose_name="rule type",
),
),
migrations.AlterField(
model_name="storagepath",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="tag",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="workflowrun",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Consumption Started"),
(2, "Document Added"),
(3, "Document Updated"),
(4, "Scheduled"),
],
null=True,
verbose_name="workflow trigger type",
),
),
migrations.AlterField(
model_name="workflowtrigger",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
],
default=0,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="workflowtrigger",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Consumption Started"),
(2, "Document Added"),
(3, "Document Updated"),
(4, "Scheduled"),
],
default=1,
verbose_name="Workflow Trigger Type",
),
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
(6, "Move to trash"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
migrations.AddField(
model_name="document",
name="root_document",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="documents.document",
verbose_name="root document for this version",
),
),
migrations.AddField(
model_name="document",
name="version_label",
field=models.CharField(
blank=True,
help_text="Optional short label for a document version.",
max_length=64,
null=True,
verbose_name="version label",
),
),
]
+29 -8
View File
@@ -45,6 +45,12 @@ class SanityCheckMessages:
def __init__(self) -> None:
self._messages: dict[int | None, list[MessageEntry]] = defaultdict(list)
self._document_pks: set[int] = set()
self._document_error_pks: set[int] = set()
self._document_warning_pks: set[int] = set()
self._document_info_pks: set[int] = set()
self._document_error_issue_count: int = 0
self._document_warning_issue_count: int = 0
self.has_error: bool = False
self.has_warning: bool = False
self.has_info: bool = False
@@ -56,20 +62,33 @@ class SanityCheckMessages:
# -- Recording ----------------------------------------------------------
def _add_document_issue(self, doc_pk: int, document_pks: set[int]) -> bool:
if doc_pk not in self._document_pks:
self._document_pks.add(doc_pk)
self.document_count += 1
if doc_pk in document_pks:
return False
document_pks.add(doc_pk)
return True
def error(self, doc_pk: int | None, message: str) -> None:
self._messages[doc_pk].append({"level": logging.ERROR, "message": message})
self.has_error = True
if doc_pk is not None:
self.document_count += 1
self.document_error_count += 1
self._document_error_issue_count += 1
if self._add_document_issue(doc_pk, self._document_error_pks):
self.document_error_count += 1
def warning(self, doc_pk: int | None, message: str) -> None:
self._messages[doc_pk].append({"level": logging.WARNING, "message": message})
self.has_warning = True
if doc_pk is not None:
self.document_count += 1
self.document_warning_count += 1
self._document_warning_issue_count += 1
if self._add_document_issue(doc_pk, self._document_warning_pks):
self.document_warning_count += 1
else:
# This is the only type of global message we do right now
self.global_warning_count += 1
@@ -78,8 +97,10 @@ class SanityCheckMessages:
self._messages[doc_pk].append({"level": logging.INFO, "message": message})
self.has_info = True
if doc_pk is not None:
self.document_count += 1
if doc_pk is not None and self._add_document_issue(
doc_pk,
self._document_info_pks,
):
self.document_info_count += 1
# -- Iteration / query --------------------------------------------------
@@ -105,8 +126,8 @@ class SanityCheckMessages:
def total_issue_count(self) -> int:
"""Total number of error and warning messages across all documents and global."""
return (
self.document_error_count
+ self.document_warning_count
self._document_error_issue_count
+ self._document_warning_issue_count
+ self.global_warning_count
)

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