mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-25 10:50:32 +00:00
Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2104fd5078 | ||
|
|
10dbb77d47 | ||
|
|
2a44d8b5ba | ||
|
|
abf5050ea7 | ||
|
|
091ddf7c45 | ||
|
|
c9f7f2cfbe | ||
|
|
b457610ffb | ||
|
|
969c2ea0e2 | ||
|
|
31b806a285 | ||
|
|
99851b418c | ||
|
|
e34eda07bb | ||
|
|
793459b416 | ||
|
|
04297fd02c | ||
|
|
1b277dd8e1 | ||
|
|
3c20abeb4c | ||
|
|
b11f1f8459 | ||
|
|
7424e7ce0b | ||
|
|
a53a3d3769 | ||
|
|
15b73b890c | ||
|
|
02d355061f | ||
|
|
d8b5b4d447 | ||
|
|
03ac4aed7e | ||
|
|
90d23bad9c | ||
|
|
e4367b5648 | ||
|
|
cb85441c2f | ||
|
|
cceaa559d4 | ||
|
|
a748d4c64f | ||
|
|
452ed005bd | ||
|
|
40058ff7d5 | ||
|
|
f502cd5e34 | ||
|
|
99ce6b5db3 | ||
|
|
2d955e9697 | ||
|
|
f440e8d33c | ||
|
|
4c264651e8 | ||
|
|
48d97b78bb | ||
|
|
659a0cb2ef | ||
|
|
d02d1e1711 | ||
|
|
3b41810e7b | ||
|
|
12314fcaa8 | ||
|
|
8705bd510a | ||
|
|
7575d60782 | ||
|
|
8b02a23b86 | ||
|
|
87d157096e | ||
|
|
54e332d259 | ||
|
|
506afb4200 | ||
|
|
22a8a4f4ad | ||
|
|
d5425a893d | ||
|
|
cc25144dbf | ||
|
|
961f018d3e | ||
|
|
405ab39b40 | ||
|
|
36c10b72b9 | ||
|
|
2c7ad1ed7e | ||
|
|
54306ec0f2 | ||
|
|
4c4ef5617c | ||
|
|
31d6e984f2 | ||
|
|
cf2f913a06 | ||
|
|
f4ff87e69b | ||
|
|
d80997a070 | ||
|
|
c0000d37c7 | ||
|
|
d9ebbe1486 | ||
|
|
24b157622d | ||
|
|
51f3266014 | ||
|
|
fdd32b73bb | ||
|
|
20d309a413 | ||
|
|
762e8cf4d1 | ||
|
|
420bf503e8 | ||
|
|
3e38b246d4 | ||
|
|
684b3c9885 | ||
|
|
6ad95b63b2 | ||
|
|
0d14d80995 | ||
|
|
c97c6923ea | ||
|
|
cd53d1b2c8 | ||
|
|
530059c5c0 | ||
|
|
989f138556 | ||
|
|
a419f1ffac | ||
|
|
7e698360ff | ||
|
|
e8b7a98d3e | ||
|
|
f919c981e1 | ||
|
|
ec4e45ecf4 | ||
|
|
27927fab4d | ||
|
|
683f2ac250 | ||
|
|
73de6b17bf | ||
|
|
ef26bc1570 | ||
|
|
ff1e61b162 | ||
|
|
1f374cd656 | ||
|
|
7354427229 | ||
|
|
970eb55a70 | ||
|
|
d2170f1267 | ||
|
|
2ac5a1c2e2 | ||
|
|
a4e69aac9d | ||
|
|
1e62f5e811 | ||
|
|
1f8b6c95b1 | ||
|
|
e78521c912 | ||
|
|
9cea08118e | ||
|
|
cb2506900e | ||
|
|
5293194551 | ||
|
|
1b86488e2e | ||
|
|
c9a5607902 | ||
|
|
05b7697c35 | ||
|
|
c626ecd9bc | ||
|
|
aeed83b14a | ||
|
|
72ea38ab12 | ||
|
|
4421d4fe58 | ||
|
|
4d64632f70 | ||
|
|
26094bc863 | ||
|
|
9dbad4de09 | ||
|
|
4a54935b3d | ||
|
|
d53c9070ba | ||
|
|
f885833a38 | ||
|
|
197c80ea68 | ||
|
|
a0c9500b6a | ||
|
|
df8e95cbd4 | ||
|
|
95944a553d | ||
|
|
2256cb3d38 | ||
|
|
9a8163fbbb | ||
|
|
d5f9605daf | ||
|
|
8593f84cae | ||
|
|
ca512af5ec | ||
|
|
a00755907e | ||
|
|
abdf15466c | ||
|
|
54a6f0fd2b | ||
|
|
2d64684043 | ||
|
|
60709b8319 | ||
|
|
1c96819625 | ||
|
|
3e56dace73 | ||
|
|
310628699d | ||
|
|
aff0f9cf41 | ||
|
|
bf716ebfd1 | ||
|
|
7d67a10a35 | ||
|
|
8d1bc5dd24 | ||
|
|
43a8d7d412 | ||
|
|
c40922440b |
@@ -15,6 +15,8 @@
|
|||||||
# Test related
|
# Test related
|
||||||
**/.pytest_cache
|
**/.pytest_cache
|
||||||
**/tests
|
**/tests
|
||||||
|
src/paperless_testing
|
||||||
|
src/conftest.py
|
||||||
**/*.spec.ts
|
**/*.spec.ts
|
||||||
**/htmlcov
|
**/htmlcov
|
||||||
# Local folders
|
# Local folders
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ updates:
|
|||||||
# Data, NLP, and Search
|
# Data, NLP, and Search
|
||||||
data-nlp-search:
|
data-nlp-search:
|
||||||
patterns:
|
patterns:
|
||||||
- "nltk"
|
|
||||||
- "scikit-learn"
|
- "scikit-learn"
|
||||||
- "langdetect"
|
- "langdetect"
|
||||||
- "rapidfuzz"
|
- "rapidfuzz"
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
# Match the Docker image: nltk refuses to read hardlinked data files, such as
|
||||||
|
# the copy bundled with llama-index when uv links packages from its cache
|
||||||
|
UV_LINK_MODE: copy
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
@@ -100,7 +102,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -125,12 +127,8 @@ jobs:
|
|||||||
- name: List installed Python dependencies
|
- name: List installed Python dependencies
|
||||||
run: |
|
run: |
|
||||||
uv pip list
|
uv pip list
|
||||||
- name: Install NLTK data
|
|
||||||
run: |
|
|
||||||
uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d "${NLTK_DATA}"
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
NLTK_DATA: ${{ env.NLTK_DATA }}
|
|
||||||
PAPERLESS_CI_TEST: 1
|
PAPERLESS_CI_TEST: 1
|
||||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||||
run: |
|
run: |
|
||||||
@@ -178,7 +176,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy GitHub Pages
|
- name: Deploy GitHub Pages
|
||||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||||
id: deployment
|
id: deployment
|
||||||
with:
|
with:
|
||||||
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -152,7 +152,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -216,7 +216,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: '0.12.x'
|
version: '0.12.x'
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -255,7 +255,7 @@ jobs:
|
|||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# ---- Frontend Build ----
|
# ---- Frontend Build ----
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -212,7 +212,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
semgrep:
|
semgrep:
|
||||||
name: Semgrep CE
|
name: Semgrep CE
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
- name: Run Semgrep
|
- name: Run Semgrep
|
||||||
run: semgrep scan --config auto --sarif-output results.sarif
|
run: semgrep scan --config auto --sarif-output results.sarif
|
||||||
- name: Upload results to GitHub code scanning
|
- name: Upload results to GitHub code scanning
|
||||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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.
|
# 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
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@e4a6c1338b4063c77d46a81875265f9e8bd76f95 # v3.0.0
|
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq --no-install-recommends gettext
|
sudo apt-get install -qq --no-install-recommends gettext
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
|
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
|
||||||
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
|
|||||||
+343
-320
@@ -451,166 +451,174 @@ src/documents/search/_backend.py:0: error: Tuple index out of range [misc]
|
|||||||
src/documents/search/_backend.py:0: error: Tuple index out of range [misc]
|
src/documents/search/_backend.py:0: error: Tuple index out of range [misc]
|
||||||
src/documents/search/_query.py:0: error: Argument 3 to "regex_phrase_query" of "Query" has incompatible type "list[str]"; expected "list[str | tuple[int, str]]" [arg-type]
|
src/documents/search/_query.py:0: error: Argument 3 to "regex_phrase_query" of "Query" has incompatible type "list[str]"; expected "list[str | tuple[int, str]]" [arg-type]
|
||||||
src/documents/search/_query.py:0: error: Library stubs not installed for "regex" [import-untyped]
|
src/documents/search/_query.py:0: error: Library stubs not installed for "regex" [import-untyped]
|
||||||
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "_meta" [attr-defined]
|
||||||
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/serialisers.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/serialisers.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type]
|
src/documents/serialisers/base.py:0: error: "type[_MT?]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/serialisers.py:0: error: Argument "default" to "MultipleChoiceField" has incompatible type "set[DocumentSource]"; expected "set[str | int] | set[str] | set[int] | Callable[[], set[str | int] | set[str] | set[int]] | _Empty | None" [arg-type]
|
src/documents/serialisers/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Argument 1 of "get_value" is incompatible with supertype "rest_framework.fields.Field"; supertype defines the argument type as "Mapping[Any, Any]" [override]
|
src/documents/serialisers/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Argument 1 of "update" is incompatible with supertype "rest_framework.serializers.BaseSerializer"; supertype defines the argument type as "dict[str, Any]" [override]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Argument 1 to "get_value_field_name" of "CustomFieldInstance" has incompatible type "str"; expected "FieldDataType" [arg-type]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Argument 1 to "list" has incompatible type "QuerySet[Document, TypedDict({'id': int, 'title': str, 'deleted_at': datetime | None})]"; expected "Iterable[dict[str, Any]]" [arg-type]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Expected iterable as variadic argument [misc]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "Iterable" [type-arg]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/base.py:0: error: Need type annotation for "permissions_dict" [var-annotated]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[Correspondent]", variable has type "type[Tag] | None") [assignment]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[DocumentType]", variable has type "type[Tag] | None") [assignment]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[StoragePath]", variable has type "type[Tag] | None") [assignment]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Argument 1 of "update" is incompatible with supertype "rest_framework.serializers.BaseSerializer"; supertype defines the argument type as "dict[str, Any]" [override]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Incompatible types in assignment (expression has type "type[SearchResultListSerializer]", base class "Meta" defined the type as "type[OwnedObjectListSerializer]") [assignment]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Skipping analyzing "auditlog.context": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/documents.py:0: error: Value of type "Any | None" is not indexable [index]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Argument "choices" to "ChoiceField" has incompatible type "type[FieldDataType]"; expected "Sequence[Any]" [arg-type]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Argument 1 of "get_value" is incompatible with supertype "rest_framework.fields.Field"; supertype defines the argument type as "Mapping[Any, Any]" [override]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Argument 1 to "get_value_field_name" of "CustomFieldInstance" has incompatible type "str"; expected "FieldDataType" [arg-type]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Expected iterable as variadic argument [misc]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Incompatible types in assignment (expression has type "PrimaryKeyRelatedField[Tag]", base class "Field" defined the type as "BaseSerializer[Any]") [assignment]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Incompatible types in assignment (expression has type "Sequence[str] | tuple[Lower]", variable has type "Sequence[str] | None") [assignment]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr]
|
||||||
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "Field" [type-arg]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "PrimaryKeyRelatedField[Tag]", base class "Field" defined the type as "BaseSerializer[Any]") [assignment]
|
src/documents/serialisers/metadata.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "UnknownQuerySet[Document, Document]") [assignment]
|
src/documents/serialisers/metadata.py:0: error: Need type annotation for "document" [var-annotated]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "Sequence[str] | tuple[Lower]", variable has type "Sequence[str] | None") [assignment]
|
src/documents/serialisers/metadata.py:0: error: Value of type "Any | None" is not indexable [index]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment]
|
src/documents/serialisers/saved_views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[Correspondent]", variable has type "type[Tag] | None") [assignment]
|
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[DocumentType]", variable has type "type[Tag] | None") [assignment]
|
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[SearchResultListSerializer]", base class "Meta" defined the type as "type[OwnedObjectListSerializer]") [assignment]
|
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Incompatible types in assignment (expression has type "type[StoragePath]", variable has type "type[Tag] | None") [assignment]
|
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
src/documents/serialisers/saved_views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers/saved_views.py:0: error: Value of type "Match[str] | None" is not indexable [index]
|
||||||
src/documents/serialisers.py:0: error: Item "Field[Any, Any, Any, Any]" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr]
|
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Any | None" has no attribute "pk" [union-attr]
|
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "CustomField | None" has no attribute "name" [union-attr]
|
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
src/documents/serialisers/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "context" [union-attr]
|
src/documents/serialisers/sharing.py:0: error: Need type annotation for "documents" [var-annotated]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "fetch_documents" [union-attr]
|
src/documents/serialisers/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "Field[Any, Any, Any, Any] | None" has no attribute "get_shared_object_pks" [union-attr]
|
src/documents/serialisers/tasks.py:0: error: Argument 1 to "list" has incompatible type "QuerySet[Document, TypedDict({'id': int, 'title': str, 'deleted_at': datetime | None})]"; expected "Iterable[dict[str, Any]]" [arg-type]
|
||||||
src/documents/serialisers.py:0: error: Item "None" of "ObjectPermissionChecker | None" has no attribute "has_perm" [union-attr]
|
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Item "list[str]" of "Any | list[str]" has no attribute "values_list" [union-attr]
|
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Field" [type-arg]
|
src/documents/serialisers/tasks.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Missing type arguments for generic type "Iterable" [type-arg]
|
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
||||||
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
||||||
src/documents/serialisers.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/serialisers/tasks.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
||||||
src/documents/serialisers.py:0: error: Need type annotation for "document" [var-annotated]
|
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Need type annotation for "documents" [var-annotated]
|
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Need type annotation for "permissions_dict" [var-annotated]
|
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Need type annotation for "with_perms" [var-annotated]
|
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload]
|
src/documents/serialisers/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Skipping analyzing "auditlog.context": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/serialisers/workflows.py:0: error: Argument "default" to "MultipleChoiceField" has incompatible type "set[DocumentSource]"; expected "set[str | int] | set[str] | set[int] | Callable[[], set[str | int] | set[str] | set[int]] | _Empty | None" [arg-type]
|
||||||
src/documents/serialisers.py:0: error: Value of type "Any | None" is not indexable [index]
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Value of type "Any | None" is not indexable [index]
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/serialisers.py:0: error: Value of type "Match[str] | None" is not indexable [index]
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
|
src/documents/serialisers/workflows.py:0: error: Incompatible types in assignment (expression has type "WorkflowActionWebhookSerializer", variable has type "WorkflowActionEmailSerializer") [assignment]
|
||||||
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
|
src/documents/signals/handlers.py:0: error: "BaseDatabaseWrapper" has no attribute "close_pool" [attr-defined]
|
||||||
src/documents/signals/handlers.py:0: error: Argument 1 to "Path" has incompatible type "Any | None"; expected "str | PathLike[str]" [arg-type]
|
src/documents/signals/handlers.py:0: error: Argument 1 to "Path" has incompatible type "Any | None"; expected "str | PathLike[str]" [arg-type]
|
||||||
src/documents/signals/handlers.py:0: error: Argument 1 to "Path" has incompatible type "Any | None"; expected "str | PathLike[str]" [arg-type]
|
src/documents/signals/handlers.py:0: error: Argument 1 to "Path" has incompatible type "Any | None"; expected "str | PathLike[str]" [arg-type]
|
||||||
@@ -1413,166 +1421,181 @@ src/documents/utils.py:0: error: Incompatible types in assignment (expression ha
|
|||||||
src/documents/utils.py:0: error: Incompatible types in assignment (expression has type "float | int", variable has type "int | None") [assignment]
|
src/documents/utils.py:0: error: Incompatible types in assignment (expression has type "float | int", variable has type "int | None") [assignment]
|
||||||
src/documents/utils.py:0: error: Missing type arguments for generic type "CompletedProcess" [type-arg]
|
src/documents/utils.py:0: error: Missing type arguments for generic type "CompletedProcess" [type-arg]
|
||||||
src/documents/validators.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/validators.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "filter_queryset" [attr-defined]
|
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "filter_queryset" [attr-defined]
|
||||||
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "get_queryset" [attr-defined]
|
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "get_queryset" [attr-defined]
|
||||||
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
||||||
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
src/documents/views/base.py:0: error: "BulkPermissionMixin" has no attribute "queryset" [attr-defined]
|
||||||
src/documents/views.py:0: error: "BulkPermissionMixin" has no attribute "request" [attr-defined]
|
src/documents/views/base.py:0: error: "get_serializer_context" undefined in superclass [misc]
|
||||||
src/documents/views.py:0: error: "get_serializer_context" undefined in superclass [misc]
|
src/documents/views/base.py:0: error: "type[Model]" has no attribute "objects" [attr-defined]
|
||||||
src/documents/views.py:0: error: "object" has no attribute "apply_async" [attr-defined]
|
src/documents/views/base.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views.py:0: error: "type[Model]" has no attribute "objects" [attr-defined]
|
src/documents/views/base.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument 1 to "TaskSummarySerializer" has incompatible type "QuerySet[PaperlessTask@AnnotatedWith[TypedDict({'total_count': int, 'pending_count': int, 'success_count': int, 'failure_count': int, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})], dict[str, Any]]"; expected "dict[str, Any] | None" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument 1 to "get_objects_for_user_owner_aware" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
|
src/documents/views/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
src/documents/views/base.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
src/documents/views/bulk_edit.py:0: error: Argument "user" to "_has_document_permissions" of "DocumentOperationPermissionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, Any]" [arg-type]
|
src/documents/views/bulk_edit.py:0: error: Argument "user" to "_resolve_document_ids" of "DocumentSelectionMixin" has incompatible type "User | AnonymousUser"; expected "User" [arg-type]
|
||||||
src/documents/views.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views/bulk_edit.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
||||||
src/documents/views.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views/bulk_edit.py:0: error: Argument 1 to "permitted_object_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
|
||||||
src/documents/views.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Argument 3 to "autocomplete" of "TantivyBackend" has incompatible type "User | AnonymousUser | None"; expected "AbstractBaseUser | None" [arg-type]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Incompatible types in assignment (expression has type "type[OriginalsOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/bulk_edit.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/chat.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: "type[DocumentFilterSet]" has no attribute "declared_filters" [attr-defined]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Argument 1 to "paginate_queryset" of "GenericAPIView" has incompatible type "TantivyRelevanceList"; expected "QuerySet[Any, Any]" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Argument 2 to "match_tags" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Incompatible types in assignment (expression has type "ClassificationSuggestions", variable has type "dict[Any, Any]") [assignment]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Need type annotation for "doc" [var-annotated]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: No overload variant of "prefetch_related" of "QuerySet" matches argument type "list[object]" [call-overload]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Skipping analyzing "langdetect": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/documents.py:0: error: Unsupported operand types for + ("None" and "int") [operator]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/index.py:0: error: Argument "filename" to "FileResponse" has incompatible type "str | Any | None"; expected "str" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/index.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/index.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/index.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "get_full_name" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/index.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/index.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/logs.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/logs.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/views/metadata.py:0: error: Argument 1 to "int" has incompatible type "Any | Collection[str]"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views/metadata.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
|
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Any]") [assignment]
|
src/documents/views/metadata.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Document]") [assignment]
|
src/documents/views/metadata.py:0: error: Incompatible types in assignment (expression has type "tuple[type[IsAuthenticated], type[ViewDocumentsPermissions]]", variable has type "tuple[type[IsAuthenticated], type[PaperlessObjectPermissions]]") [assignment]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
src/documents/views/metadata.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Argument 3 to "autocomplete" of "TantivyBackend" has incompatible type "User | AnonymousUser | None"; expected "AbstractUser | None" [arg-type]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "int", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Document, Document]", variable has type "list[Any]") [assignment]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "tuple[type[IsAuthenticated]]", variable has type "tuple[type[IsAuthenticated], type[PaperlessObjectPermissions]]") [assignment]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[ArchiveOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[DocumentSerializer]", variable has type "type[TrashSerializer]") [assignment]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "type[OriginalsOnlyStrategy]", variable has type "type[OriginalAndArchiveStrategy]") [assignment]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "get_full_name" [union-attr]
|
src/documents/views/search.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Item "BasePagination" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
src/documents/views/search.py:0: error: Value of type "Iterable[User]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
src/documents/views/search.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
|
||||||
src/documents/views.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
src/documents/views/sharing.py:0: error: "Sequence[_SupportsHasPermission]" has no attribute "append" [attr-defined]
|
||||||
src/documents/views.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "app_logo" [union-attr]
|
src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "get_page_size" [union-attr]
|
src/documents/views/sharing.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Item "None" of "BasePagination | None" has no attribute "page_size" [union-attr]
|
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Item "None" of "dict[str, _PingReply] | None" has no attribute "keys" [union-attr]
|
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Missing positional argument "request" in call to "email_documents" [call-arg]
|
src/documents/views/sharing.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg]
|
src/documents/views/sharing.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg]
|
||||||
src/documents/views.py:0: error: Missing type arguments for generic type "list" [type-arg]
|
src/documents/views/sharing.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated]
|
||||||
src/documents/views.py:0: error: Need type annotation for "authentication_classes" (hint: "authentication_classes: list[<type>] = ...") [var-annotated]
|
src/documents/views/sharing.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated]
|
||||||
src/documents/views.py:0: error: Need type annotation for "children_map" (hint: "children_map: dict[<type>, <type>] = ...") [var-annotated]
|
src/documents/views/system.py:0: error: Argument 1 to "permitted_document_ids" has incompatible type "User | AnonymousUser"; expected "User | None" [arg-type]
|
||||||
src/documents/views.py:0: error: Need type annotation for "doc" [var-annotated]
|
src/documents/views/system.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item]
|
||||||
src/documents/views.py:0: error: Need type annotation for "docs" (hint: "docs: list[<type>] = ...") [var-annotated]
|
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Need type annotation for "permission_classes" (hint: "permission_classes: list[<type>] = ...") [var-annotated]
|
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Need type annotation for "ui_settings" (hint: "ui_settings: dict[<type>, <type>] = ...") [var-annotated]
|
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/views/system.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/views.py:0: error: Skipping analyzing "langdetect": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
|
||||||
src/documents/views.py:0: error: TypedDict key must be a string literal; expected one of ("pk", "correspondent", "document_type", "storage_path", "tags", ...) [literal-required]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Unsupported operand types for + ("None" and "int") [operator]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "int", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "str", target has type "dict[str, str]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[User]" is not indexable [index]
|
src/documents/views/system.py:0: error: Incompatible types in assignment (expression has type "type[DocumentSerializer]", variable has type "type[TrashSerializer]") [assignment]
|
||||||
src/documents/views.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
|
src/documents/views/system.py:0: error: Need type annotation for "ui_settings" (hint: "ui_settings: dict[<type>, <type>] = ...") [var-annotated]
|
||||||
src/documents/views.py:0: error: Value of type "dict[str, _PingReply] | None" is not indexable [index]
|
src/documents/views/tasks.py:0: error: "object" has no attribute "apply_async" [attr-defined]
|
||||||
|
src/documents/views/tasks.py:0: error: Argument 1 to "TaskSummarySerializer" has incompatible type "QuerySet[PaperlessTask@AnnotatedWith[TypedDict({'total_count': int, 'pending_count': int, 'success_count': int, 'failure_count': int, 'avg_duration_seconds': Any, 'avg_wait_time_seconds': Any, 'last_run': Any, 'last_success': Any, 'last_failure': Any})], dict[str, Any]]"; expected "dict[str, Any] | None" [arg-type]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/tasks.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/upload.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
|
src/documents/views/workflows.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/workflows/actions.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
src/documents/workflows/actions.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/workflows/actions.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/workflows/actions.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/workflows/actions.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
src/documents/workflows/actions.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def]
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.16.5
|
rev: v0.16.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.28.1"
|
rev: "v2.29.4"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|||||||
+1184
-1124
File diff suppressed because it is too large
Load Diff
+1
-5
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.12.9-python3.14-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.12.16-python3.14-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -199,10 +199,6 @@ RUN set -eux \
|
|||||||
--index https://download.pytorch.org/whl/cpu \
|
--index https://download.pytorch.org/whl/cpu \
|
||||||
--index-strategy unsafe-best-match \
|
--index-strategy unsafe-best-match \
|
||||||
--requirements requirements.txt \
|
--requirements requirements.txt \
|
||||||
&& echo "Installing NLTK data" \
|
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
|
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
|
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt_tab \
|
|
||||||
&& echo "Cleaning up image" \
|
&& echo "Cleaning up image" \
|
||||||
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
||||||
&& apt-get --yes autoremove --purge \
|
&& apt-get --yes autoremove --purge \
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.36
|
image: docker.io/gotenberg/gotenberg:8.37
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "3143:3143" # IMAP
|
- "3143:3143" # IMAP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
nginx:
|
nginx:
|
||||||
image: docker.io/nginx:1.31.5-alpine
|
image: docker.io/nginx:1.31.6-alpine
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.36
|
image: docker.io/gotenberg/gotenberg:8.37
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.36
|
image: docker.io/gotenberg/gotenberg:8.37
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.36
|
image: docker.io/gotenberg/gotenberg:8.37
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/command/with-contenv /usr/bin/bash
|
||||||
|
# shellcheck shell=bash
|
||||||
|
declare -r log_prefix="[init-compile-bytecode]"
|
||||||
|
|
||||||
|
# PYTHONDONTWRITEBYTECODE=1 is set for the whole container. This unit compiles a
|
||||||
|
# scoped set of libraries anyway, to speed up startup without bloating image size.
|
||||||
|
|
||||||
|
# Handle the people using a read only file system
|
||||||
|
if [[ "${S6_READ_ONLY_ROOT}" == "1" ]]; then
|
||||||
|
echo "${log_prefix} S6_READ_ONLY_ROOT=1, skipping (nothing to write bytecode to)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# When running as a non-root user, site-packages is still root-owned and unwritable,
|
||||||
|
# so this step would just fail loudly on every container start. Skip it.
|
||||||
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
|
echo "${log_prefix} USER_IS_NON_ROOT is set, skipping (site-packages is not writable)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -r site_packages="$(python3 -c 'import site; print(site.getsitepackages()[0])')"
|
||||||
|
|
||||||
|
# Deliberately scoped to packages that paperless.settings/paperless/__init__.py import
|
||||||
|
# unconditionally on every manage.py invocation (Django itself, the always-loaded
|
||||||
|
# INSTALLED_APPS, and celery). This is NOT "compile everything" - the optional AI stack
|
||||||
|
# (torch, llama-index, sentence-transformers, ...) is intentionally excluded since it is
|
||||||
|
# lazy-imported and large.
|
||||||
|
declare -a scope=(
|
||||||
|
"${PAPERLESS_SRC_DIR}"
|
||||||
|
"${site_packages}/django"
|
||||||
|
"${site_packages}/celery"
|
||||||
|
"${site_packages}/kombu"
|
||||||
|
"${site_packages}/rest_framework"
|
||||||
|
"${site_packages}/django_filters"
|
||||||
|
"${site_packages}/whitenoise"
|
||||||
|
"${site_packages}/corsheaders"
|
||||||
|
"${site_packages}/django_extensions"
|
||||||
|
"${site_packages}/guardian"
|
||||||
|
"${site_packages}/allauth"
|
||||||
|
"${site_packages}/drf_spectacular"
|
||||||
|
"${site_packages}/drf_spectacular_sidecar"
|
||||||
|
"${site_packages}/treenode"
|
||||||
|
"${site_packages}/compression_middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
declare -a existing_scope=()
|
||||||
|
for path in "${scope[@]}"; do
|
||||||
|
[[ -d "${path}" ]] && existing_scope+=("${path}")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "${log_prefix} Compiling bytecode for: ${existing_scope[*]}"
|
||||||
|
declare -r start_seconds=${SECONDS}
|
||||||
|
|
||||||
|
if ! PYTHONDONTWRITEBYTECODE= python3 -m compileall -q "${existing_scope[@]}"; then
|
||||||
|
echo "${log_prefix} WARNING: compileall reported errors (read-only filesystem or unwritable site-packages?); continuing without a bytecode cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${log_prefix} Done in $((SECONDS - start_seconds))s"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
oneshot
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
|
|
||||||
declare -r log_prefix="[svc-flower]"
|
declare -r log_prefix="[svc-flower]"
|
||||||
|
declare -r flower_config="${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py"
|
||||||
|
|
||||||
echo "${log_prefix} Checking if we should start flower..."
|
echo "${log_prefix} Checking if we should start flower..."
|
||||||
|
|
||||||
@@ -9,12 +10,20 @@ if [[ -n "${PAPERLESS_ENABLE_FLOWER}" ]]; then
|
|||||||
# Small delay to allow celery to be up first
|
# Small delay to allow celery to be up first
|
||||||
echo "${log_prefix} Starting flower in 5s"
|
echo "${log_prefix} Starting flower in 5s"
|
||||||
sleep 5
|
sleep 5
|
||||||
cd ${PAPERLESS_SRC_DIR}
|
cd "${PAPERLESS_SRC_DIR}" || exit 1
|
||||||
|
|
||||||
|
# Only pass --conf if the file is actually there. The image does not ship one, and
|
||||||
|
# flower >= 2.1.0 exits with FileNotFoundError when an explicitly given --conf path
|
||||||
|
# does not exist (mher/flower#1391). Earlier versions silently ignored it.
|
||||||
|
declare -a conf_args=()
|
||||||
|
if [[ -f "${flower_config}" ]]; then
|
||||||
|
conf_args=(--conf="${flower_config}")
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
exec /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
exec /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
||||||
else
|
else
|
||||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower --conf=${PAPERLESS_SRC_DIR}/paperless/flowerconfig.py
|
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless flower "${conf_args[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -521,7 +521,8 @@ Pass `--recreate` to wipe the existing index before rebuilding. Use this when th
|
|||||||
index is corrupted or you want a fully clean rebuild.
|
index is corrupted or you want a fully clean rebuild.
|
||||||
|
|
||||||
Pass `--if-needed` to skip the rebuild if the index is already up to date (schema
|
Pass `--if-needed` to skip the rebuild if the index is already up to date (schema
|
||||||
version and search language match). Safe to run on every startup or upgrade.
|
version, schema fingerprint and search language all match). Safe to run on every
|
||||||
|
startup or upgrade.
|
||||||
|
|
||||||
Specify `optimize` to optimize the index. This command is regularly invoked by the
|
Specify `optimize` to optimize the index. This command is regularly invoked by the
|
||||||
task scheduler.
|
task scheduler.
|
||||||
|
|||||||
@@ -153,8 +153,11 @@ in similar existing documents, and the document chat can retrieve relevant conte
|
|||||||
|
|
||||||
Enable it by setting
|
Enable it by setting
|
||||||
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
[`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
|
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). By default, the main
|
||||||
built when AI is enabled **and** an embedding backend is set.
|
LLM API key and endpoint are used, but an optional embedding-specific[API key](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_API_KEY)
|
||||||
|
and [endpoint](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) can be configured.
|
||||||
|
|
||||||
|
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
|
The index is updated automatically on a schedule controlled by
|
||||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
||||||
|
|||||||
@@ -1,5 +1,210 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.2.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: only pass --conf to flower when flowerconfig.py exists [@bitfoo1](https://github.com/bitfoo1) ([#14182](https://github.com/paperless-ngx/paperless-ngx/pull/14182))
|
||||||
|
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||||
|
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||||
|
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps): Bump anyio from 4.12.1 to 4.14.2 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14175](https://github.com/paperless-ngx/paperless-ngx/pull/14175))
|
||||||
|
- Fix: replace stale mail-fetch overlap check with a self-expiring lock [@stumpylog](https://github.com/stumpylog) ([#14189](https://github.com/paperless-ngx/paperless-ngx/pull/14189))
|
||||||
|
- Fix: bump ocrmypdf to 17.12 to pick up the ligature text-layer fix [@stumpylog](https://github.com/stumpylog) ([#14190](https://github.com/paperless-ngx/paperless-ngx/pull/14190))
|
||||||
|
- Fix: rebuild the search index automatically when it is missing Tantivy files [@stumpylog](https://github.com/stumpylog) ([#14180](https://github.com/paperless-ngx/paperless-ngx/pull/14180))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 3.2.0
|
||||||
|
|
||||||
|
### Features / Enhancements
|
||||||
|
|
||||||
|
- Enhancement (QoL): support deselecting single items from "select all" [@shamoon](https://github.com/shamoon) ([#14117](https://github.com/paperless-ngx/paperless-ngx/pull/14117))
|
||||||
|
- Enhancement: Match fuzzy terms in place inside the parsed query [@stumpylog](https://github.com/stumpylog) ([#14157](https://github.com/paperless-ngx/paperless-ngx/pull/14157))
|
||||||
|
- Enhancement: Match CJK terms through their bigram fields in place [@stumpylog](https://github.com/stumpylog) ([#14156](https://github.com/paperless-ngx/paperless-ngx/pull/14156))
|
||||||
|
- Enhancement: centralized management of share links + bundles [@shamoon](https://github.com/shamoon) ([#14115](https://github.com/paperless-ngx/paperless-ngx/pull/14115))
|
||||||
|
- Enhancement: parse advanced search with whoosh-compat and delete the handwritten translation [@stumpylog](https://github.com/stumpylog) ([#14072](https://github.com/paperless-ngx/paperless-ngx/pull/14072))
|
||||||
|
- Enhancement: allow regex timeout configuration [@shamoon](https://github.com/shamoon) ([#14085](https://github.com/paperless-ngx/paperless-ngx/pull/14085))
|
||||||
|
- Enhancement: hide-able sidebar items [@shamoon](https://github.com/shamoon) ([#14052](https://github.com/paperless-ngx/paperless-ngx/pull/14052))
|
||||||
|
- Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold [@dewey](https://github.com/dewey) ([#12164](https://github.com/paperless-ngx/paperless-ngx/pull/12164))
|
||||||
|
- Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates [@stumpylog](https://github.com/stumpylog) ([#13820](https://github.com/paperless-ngx/paperless-ngx/pull/13820))
|
||||||
|
- Enhancement (QoL): surface externally-set options in Config UI [@shamoon](https://github.com/shamoon) ([#13989](https://github.com/paperless-ngx/paperless-ngx/pull/13989))
|
||||||
|
- Enhancement: allow disabling auto-suggestions for inbox documents [@shamoon](https://github.com/shamoon) ([#13946](https://github.com/paperless-ngx/paperless-ngx/pull/13946))
|
||||||
|
- Change: skip documents with empty content in apply AI suggestions WF [@shamoon](https://github.com/shamoon) ([#13985](https://github.com/paperless-ngx/paperless-ngx/pull/13985))
|
||||||
|
- Enhancement: duplicates filter [@shamoon](https://github.com/shamoon) ([#13994](https://github.com/paperless-ngx/paperless-ngx/pull/13994))
|
||||||
|
- Tweak: note that apply AI suggestions runs async in WF editor [@shamoon](https://github.com/shamoon) ([#14004](https://github.com/paperless-ngx/paperless-ngx/pull/14004))
|
||||||
|
- Enhancement (QoL): attempt to localize firstDayOfWeek for date picker [@shamoon](https://github.com/shamoon) ([#13999](https://github.com/paperless-ngx/paperless-ngx/pull/13999))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: don't redirect to signup on first install when regular login is disabled [@cyberb](https://github.com/cyberb) ([#14165](https://github.com/paperless-ngx/paperless-ngx/pull/14165))
|
||||||
|
- Fix: better catch email workflow placeholder parsing errors [@shamoon](https://github.com/shamoon) ([#14129](https://github.com/paperless-ngx/paperless-ngx/pull/14129))
|
||||||
|
- Fix: validate legacy bulk edit owner, rotate and split parameters [@stumpylog](https://github.com/stumpylog) ([#14120](https://github.com/paperless-ngx/paperless-ngx/pull/14120))
|
||||||
|
- Fix: validate set\_permissions with a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14119](https://github.com/paperless-ngx/paperless-ngx/pull/14119))
|
||||||
|
- Fix: Use prefetching to reduce query counts during classifier training [@stumpylog](https://github.com/stumpylog) ([#14122](https://github.com/paperless-ngx/paperless-ngx/pull/14122))
|
||||||
|
- Fix: reject non-dict user\_args/barcode\_tag\_mapping in config API [@stumpylog](https://github.com/stumpylog) ([#14118](https://github.com/paperless-ngx/paperless-ngx/pull/14118))
|
||||||
|
- Fix: type edit\_pdf operations via a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14116](https://github.com/paperless-ngx/paperless-ngx/pull/14116))
|
||||||
|
- Fix: ensure django setup is run for management comments under 3.14 [@shamoon](https://github.com/shamoon) ([#14100](https://github.com/paperless-ngx/paperless-ngx/pull/14100))
|
||||||
|
- Fix: validate PDF output doc indexes in bulk edit [@shamoon](https://github.com/shamoon) ([#14083](https://github.com/paperless-ngx/paperless-ngx/pull/14083))
|
||||||
|
- Fix: avoid IntegrityError when a retried task republishes with the same ID [@stumpylog](https://github.com/stumpylog) ([#14096](https://github.com/paperless-ngx/paperless-ngx/pull/14096))
|
||||||
|
- Fix: update some api global perms inconsistencies [@shamoon](https://github.com/shamoon) ([#14086](https://github.com/paperless-ngx/paperless-ngx/pull/14086))
|
||||||
|
- Fix: ignore nested action IDs on WF create [@shamoon](https://github.com/shamoon) ([#14084](https://github.com/paperless-ngx/paperless-ngx/pull/14084))
|
||||||
|
- Fix: correct text/stream compression workaround [@shamoon](https://github.com/shamoon) ([#14064](https://github.com/paperless-ngx/paperless-ngx/pull/14064))
|
||||||
|
- Fix: ui version content switching inconsistencies [@shamoon](https://github.com/shamoon) ([#14066](https://github.com/paperless-ngx/paperless-ngx/pull/14066))
|
||||||
|
- Fix: prevent saving changes to stale cached document object [@shamoon](https://github.com/shamoon) ([#14065](https://github.com/paperless-ngx/paperless-ngx/pull/14065))
|
||||||
|
- Fix: ensure remove inbox tag children on remove\_inbox\_tags [@shamoon](https://github.com/shamoon) ([#14050](https://github.com/paperless-ngx/paperless-ngx/pull/14050))
|
||||||
|
- Fix: connect add\_to\_index handler after document\_added [@shamoon](https://github.com/shamoon) ([#14058](https://github.com/paperless-ngx/paperless-ngx/pull/14058))
|
||||||
|
- Fix: Drop empty files from tracking after the stability window has passed [@stumpylog](https://github.com/stumpylog) ([#14047](https://github.com/paperless-ngx/paperless-ngx/pull/14047))
|
||||||
|
- Fixhancement: better LLM errors [@shamoon](https://github.com/shamoon) ([#14031](https://github.com/paperless-ngx/paperless-ngx/pull/14031))
|
||||||
|
- Fix: prevent orphaned versions from bulk delete [@shamoon](https://github.com/shamoon) ([#14030](https://github.com/paperless-ngx/paperless-ngx/pull/14030))
|
||||||
|
- Fixhancement: prevent overlapping mail-account processing runs [@stumpylog](https://github.com/stumpylog) ([#14046](https://github.com/paperless-ngx/paperless-ngx/pull/14046))
|
||||||
|
- Fix: Use PAPERLESS\_REDIS\_PREFIX for Celery result backend keys [@bdd](https://github.com/bdd) ([#14015](https://github.com/paperless-ngx/paperless-ngx/pull/14015))
|
||||||
|
- Fix: correct setting ai\_enabled to false via UI [@shamoon](https://github.com/shamoon) ([#13987](https://github.com/paperless-ngx/paperless-ngx/pull/13987))
|
||||||
|
- Fix: catch some frontend failed object retrievals [@shamoon](https://github.com/shamoon) ([#14023](https://github.com/paperless-ngx/paperless-ngx/pull/14023))
|
||||||
|
- Fix: more v3 icons cleanup [@shamoon](https://github.com/shamoon) ([#14017](https://github.com/paperless-ngx/paperless-ngx/pull/14017))
|
||||||
|
- Fix: correct add version actor parity [@shamoon](https://github.com/shamoon) ([#14016](https://github.com/paperless-ngx/paperless-ngx/pull/14016))
|
||||||
|
- Fix: fix v3 favicon file [@shamoon](https://github.com/shamoon) ([#14014](https://github.com/paperless-ngx/paperless-ngx/pull/14014))
|
||||||
|
- Fix: change share link bundle dialog button to close after create, don't toast on copied [@shamoon](https://github.com/shamoon) ([#14002](https://github.com/paperless-ngx/paperless-ngx/pull/14002))
|
||||||
|
- Fix: truncate mail subjects to field max length [@shamoon](https://github.com/shamoon) ([#13991](https://github.com/paperless-ngx/paperless-ngx/pull/13991))
|
||||||
|
- Fix: enforce the overflow hidden rule on pdf editor thumbnails [@shamoon](https://github.com/shamoon) ([#13976](https://github.com/paperless-ngx/paperless-ngx/pull/13976))
|
||||||
|
- Fix: also correct unbroken long names on small cards [@shamoon](https://github.com/shamoon) ([#13974](https://github.com/paperless-ngx/paperless-ngx/pull/13974))
|
||||||
|
- Fix: ensure parent + child tags change together in bulk editor [@shamoon](https://github.com/shamoon) ([#13972](https://github.com/paperless-ngx/paperless-ngx/pull/13972))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>29 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14167](https://github.com/paperless-ngx/paperless-ngx/pull/14167))
|
||||||
|
- docker(deps): Bump astral-sh/uv from 0.12.9-python3.14-trixie-slim to 0.12.16-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14134](https://github.com/paperless-ngx/paperless-ngx/pull/14134))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14149](https://github.com/paperless-ngx/paperless-ngx/pull/14149))
|
||||||
|
- Chore(deps): Bump the actions group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14162](https://github.com/paperless-ngx/paperless-ngx/pull/14162))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 19 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14141](https://github.com/paperless-ngx/paperless-ngx/pull/14141))
|
||||||
|
- docker-compose(deps): bump gotenberg/gotenberg from 8.36 to 8.37 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14137](https://github.com/paperless-ngx/paperless-ngx/pull/14137))
|
||||||
|
- docker-compose(deps): Bump nginx from 1.31.5-alpine to 1.31.6-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14138](https://github.com/paperless-ngx/paperless-ngx/pull/14138))
|
||||||
|
- Chore(deps): Bump pdfjs-dist from 6.2.108 to 6.3.289 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14144](https://github.com/paperless-ngx/paperless-ngx/pull/14144))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14143](https://github.com/paperless-ngx/paperless-ngx/pull/14143))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.4.0 to 26.5.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14146](https://github.com/paperless-ngx/paperless-ngx/pull/14146))
|
||||||
|
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14142](https://github.com/paperless-ngx/paperless-ngx/pull/14142))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13988](https://github.com/paperless-ngx/paperless-ngx/pull/13988))
|
||||||
|
- Chore(deps): Bump sentence-transformers from 5.6.1 to 6.0.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13983](https://github.com/paperless-ngx/paperless-ngx/pull/13983))
|
||||||
|
- Chore(deps-dev): Bump types-markdown from 3.10.2.20260518 to 3.10.2.20260712 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13982](https://github.com/paperless-ngx/paperless-ngx/pull/13982))
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13980](https://github.com/paperless-ngx/paperless-ngx/pull/13980))
|
||||||
|
- Chore(deps): Update granian[uvloop] requirement from ~=2.7.0 to >=2.7,\<2.9 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13984](https://github.com/paperless-ngx/paperless-ngx/pull/13984))
|
||||||
|
- Chore: Updates our direct Redis pin [@stumpylog](https://github.com/stumpylog) ([#13986](https://github.com/paperless-ngx/paperless-ngx/pull/13986))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13918](https://github.com/paperless-ngx/paperless-ngx/pull/13918))
|
||||||
|
- Chore(deps): Bump uuid from 14.0.1 to 14.0.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13921](https://github.com/paperless-ngx/paperless-ngx/pull/13921))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.2.0 to 26.4.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13919](https://github.com/paperless-ngx/paperless-ngx/pull/13919))
|
||||||
|
- Chore: update ng-select to v24, handle breaking changes [@shamoon](https://github.com/shamoon) ([#13951](https://github.com/paperless-ngx/paperless-ngx/pull/13951))
|
||||||
|
- Chore(deps): Bump djangorestframework from 3.17.2 to 3.18.0 in the django-ecosystem group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13912](https://github.com/paperless-ngx/paperless-ngx/pull/13912))
|
||||||
|
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13922](https://github.com/paperless-ngx/paperless-ngx/pull/13922))
|
||||||
|
- Chore(deps): Bump the document-processing group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13916](https://github.com/paperless-ngx/paperless-ngx/pull/13916))
|
||||||
|
- Chore(deps): Bump flower from 2.0.1 to 2.1.0 in the async-tasks group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13913](https://github.com/paperless-ngx/paperless-ngx/pull/13913))
|
||||||
|
- Chore(deps): Bump the actions group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13920](https://github.com/paperless-ngx/paperless-ngx/pull/13920))
|
||||||
|
- docker-compose(deps): Bump gotenberg/gotenberg from 8.34 to 8.36 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13910](https://github.com/paperless-ngx/paperless-ngx/pull/13910))
|
||||||
|
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13911](https://github.com/paperless-ngx/paperless-ngx/pull/13911))
|
||||||
|
- docker(deps): Bump astral-sh/uv from 0.12.5-python3.14-trixie-slim to 0.12.9-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#13914](https://github.com/paperless-ngx/paperless-ngx/pull/13914))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>80 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14167](https://github.com/paperless-ngx/paperless-ngx/pull/14167))
|
||||||
|
- Fix: don't redirect to signup on first install when regular login is disabled [@cyberb](https://github.com/cyberb) ([#14165](https://github.com/paperless-ngx/paperless-ngx/pull/14165))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14149](https://github.com/paperless-ngx/paperless-ngx/pull/14149))
|
||||||
|
- Enhancement (QoL): support deselecting single items from "select all" [@shamoon](https://github.com/shamoon) ([#14117](https://github.com/paperless-ngx/paperless-ngx/pull/14117))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 19 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14141](https://github.com/paperless-ngx/paperless-ngx/pull/14141))
|
||||||
|
- Enhancement: Match fuzzy terms in place inside the parsed query [@stumpylog](https://github.com/stumpylog) ([#14157](https://github.com/paperless-ngx/paperless-ngx/pull/14157))
|
||||||
|
- Enhancement: Match CJK terms through their bigram fields in place [@stumpylog](https://github.com/stumpylog) ([#14156](https://github.com/paperless-ngx/paperless-ngx/pull/14156))
|
||||||
|
- Chore(deps): Bump pdfjs-dist from 6.2.108 to 6.3.289 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14144](https://github.com/paperless-ngx/paperless-ngx/pull/14144))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14143](https://github.com/paperless-ngx/paperless-ngx/pull/14143))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.4.0 to 26.5.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14146](https://github.com/paperless-ngx/paperless-ngx/pull/14146))
|
||||||
|
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14142](https://github.com/paperless-ngx/paperless-ngx/pull/14142))
|
||||||
|
- Enhancement: centralized management of share links + bundles [@shamoon](https://github.com/shamoon) ([#14115](https://github.com/paperless-ngx/paperless-ngx/pull/14115))
|
||||||
|
- Performance: Preprocess classifier text with Tantivy instead of NLTK [@stumpylog](https://github.com/stumpylog) ([#14127](https://github.com/paperless-ngx/paperless-ngx/pull/14127))
|
||||||
|
- Performance: Drops fields from the classifier before pickling [@stumpylog](https://github.com/stumpylog) ([#14114](https://github.com/paperless-ngx/paperless-ngx/pull/14114))
|
||||||
|
- Fix: better catch email workflow placeholder parsing errors [@shamoon](https://github.com/shamoon) ([#14129](https://github.com/paperless-ngx/paperless-ngx/pull/14129))
|
||||||
|
- Performance: Improves the memory efficiency of classifier training [@stumpylog](https://github.com/stumpylog) ([#14124](https://github.com/paperless-ngx/paperless-ngx/pull/14124))
|
||||||
|
- Performance: Streams the classifier pickle file during save as well [@stumpylog](https://github.com/stumpylog) ([#14121](https://github.com/paperless-ngx/paperless-ngx/pull/14121))
|
||||||
|
- Fix: validate legacy bulk edit owner, rotate and split parameters [@stumpylog](https://github.com/stumpylog) ([#14120](https://github.com/paperless-ngx/paperless-ngx/pull/14120))
|
||||||
|
- Fix: validate set\_permissions with a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14119](https://github.com/paperless-ngx/paperless-ngx/pull/14119))
|
||||||
|
- Fix: Use prefetching to reduce query counts during classifier training [@stumpylog](https://github.com/stumpylog) ([#14122](https://github.com/paperless-ngx/paperless-ngx/pull/14122))
|
||||||
|
- Fix: reject non-dict user\_args/barcode\_tag\_mapping in config API [@stumpylog](https://github.com/stumpylog) ([#14118](https://github.com/paperless-ngx/paperless-ngx/pull/14118))
|
||||||
|
- Fix: type edit\_pdf operations via a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14116](https://github.com/paperless-ngx/paperless-ngx/pull/14116))
|
||||||
|
- Performance: Loads the classifier through a memory view to reduce memory usage [@stumpylog](https://github.com/stumpylog) ([#14113](https://github.com/paperless-ngx/paperless-ngx/pull/14113))
|
||||||
|
- Feature: parse advanced search with whoosh-compat and delete the handwritten translation [@stumpylog](https://github.com/stumpylog) ([#14072](https://github.com/paperless-ngx/paperless-ngx/pull/14072))
|
||||||
|
- Fix: ensure django setup is run for management comments under 3.14 [@shamoon](https://github.com/shamoon) ([#14100](https://github.com/paperless-ngx/paperless-ngx/pull/14100))
|
||||||
|
- Fix: validate PDF output doc indexes in bulk edit [@shamoon](https://github.com/shamoon) ([#14083](https://github.com/paperless-ngx/paperless-ngx/pull/14083))
|
||||||
|
- Enhancement: allow regex timeout configuration [@shamoon](https://github.com/shamoon) ([#14085](https://github.com/paperless-ngx/paperless-ngx/pull/14085))
|
||||||
|
- Fix: avoid IntegrityError when a retried task republishes with the same ID [@stumpylog](https://github.com/stumpylog) ([#14096](https://github.com/paperless-ngx/paperless-ngx/pull/14096))
|
||||||
|
- Chore: include Apply AI Suggestions in the tasks UI filter dropdown [@shamoon](https://github.com/shamoon) ([#14093](https://github.com/paperless-ngx/paperless-ngx/pull/14093))
|
||||||
|
- Fix: update some api global perms inconsistencies [@shamoon](https://github.com/shamoon) ([#14086](https://github.com/paperless-ngx/paperless-ngx/pull/14086))
|
||||||
|
- Fix: ignore nested action IDs on WF create [@shamoon](https://github.com/shamoon) ([#14084](https://github.com/paperless-ngx/paperless-ngx/pull/14084))
|
||||||
|
- Fix: correct text/stream compression workaround [@shamoon](https://github.com/shamoon) ([#14064](https://github.com/paperless-ngx/paperless-ngx/pull/14064))
|
||||||
|
- Fix: ui version content switching inconsistencies [@shamoon](https://github.com/shamoon) ([#14066](https://github.com/paperless-ngx/paperless-ngx/pull/14066))
|
||||||
|
- Fix: prevent saving changes to stale cached document object [@shamoon](https://github.com/shamoon) ([#14065](https://github.com/paperless-ngx/paperless-ngx/pull/14065))
|
||||||
|
- Performance: batch permission assignment in bulk `set_permissions` [@stumpylog](https://github.com/stumpylog) ([#13806](https://github.com/paperless-ngx/paperless-ngx/pull/13806))
|
||||||
|
- Performance: skip effective\_content annotation on document list unless required [@stumpylog](https://github.com/stumpylog) ([#13789](https://github.com/paperless-ngx/paperless-ngx/pull/13789))
|
||||||
|
- Fix: ensure remove inbox tag children on remove\_inbox\_tags [@shamoon](https://github.com/shamoon) ([#14050](https://github.com/paperless-ngx/paperless-ngx/pull/14050))
|
||||||
|
- Fix: connect add\_to\_index handler after document\_added [@shamoon](https://github.com/shamoon) ([#14058](https://github.com/paperless-ngx/paperless-ngx/pull/14058))
|
||||||
|
- Enhancement: hide-able sidebar items [@shamoon](https://github.com/shamoon) ([#14052](https://github.com/paperless-ngx/paperless-ngx/pull/14052))
|
||||||
|
- Performance: cut redundant per-document lookups in bulk `modify_custom_fields` [@stumpylog](https://github.com/stumpylog) ([#13807](https://github.com/paperless-ngx/paperless-ngx/pull/13807))
|
||||||
|
- Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold [@dewey](https://github.com/dewey) ([#12164](https://github.com/paperless-ngx/paperless-ngx/pull/12164))
|
||||||
|
- Fix: Drop empty files from tracking after the stability window has passed [@stumpylog](https://github.com/stumpylog) ([#14047](https://github.com/paperless-ngx/paperless-ngx/pull/14047))
|
||||||
|
- Fixhancement: better LLM errors [@shamoon](https://github.com/shamoon) ([#14031](https://github.com/paperless-ngx/paperless-ngx/pull/14031))
|
||||||
|
- Fix: prevent orphaned versions from bulk delete [@shamoon](https://github.com/shamoon) ([#14030](https://github.com/paperless-ngx/paperless-ngx/pull/14030))
|
||||||
|
- Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates [@stumpylog](https://github.com/stumpylog) ([#13820](https://github.com/paperless-ngx/paperless-ngx/pull/13820))
|
||||||
|
- Fixhancement: prevent overlapping mail-account processing runs [@stumpylog](https://github.com/stumpylog) ([#14046](https://github.com/paperless-ngx/paperless-ngx/pull/14046))
|
||||||
|
- Performance: ensure version-aware content filters on querysets [@shamoon](https://github.com/shamoon) ([#13792](https://github.com/paperless-ngx/paperless-ngx/pull/13792))
|
||||||
|
- Performance: skip nested TagSerializer construction when a tag has no children [@stumpylog](https://github.com/stumpylog) ([#14039](https://github.com/paperless-ngx/paperless-ngx/pull/14039))
|
||||||
|
- Enhancement (QoL): surface externally-set options in Config UI [@shamoon](https://github.com/shamoon) ([#13989](https://github.com/paperless-ngx/paperless-ngx/pull/13989))
|
||||||
|
- Enhancement: allow disabling auto-suggestions for inbox documents [@shamoon](https://github.com/shamoon) ([#13946](https://github.com/paperless-ngx/paperless-ngx/pull/13946))
|
||||||
|
- Change: skip documents with empty content in apply AI suggestions WF [@shamoon](https://github.com/shamoon) ([#13985](https://github.com/paperless-ngx/paperless-ngx/pull/13985))
|
||||||
|
- Performance: resolve index-write permissions and effective content in bulk [@stumpylog](https://github.com/stumpylog) ([#13869](https://github.com/paperless-ngx/paperless-ngx/pull/13869))
|
||||||
|
- Fix: Use PAPERLESS\_REDIS\_PREFIX for Celery result backend keys [@bdd](https://github.com/bdd) ([#14015](https://github.com/paperless-ngx/paperless-ngx/pull/14015))
|
||||||
|
- Enhancement: duplicates filter [@shamoon](https://github.com/shamoon) ([#13994](https://github.com/paperless-ngx/paperless-ngx/pull/13994))
|
||||||
|
- Fix: correct setting ai\_enabled to false via UI [@shamoon](https://github.com/shamoon) ([#13987](https://github.com/paperless-ngx/paperless-ngx/pull/13987))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13988](https://github.com/paperless-ngx/paperless-ngx/pull/13988))
|
||||||
|
- Fix: catch some frontend failed object retrievals [@shamoon](https://github.com/shamoon) ([#14023](https://github.com/paperless-ngx/paperless-ngx/pull/14023))
|
||||||
|
- Fix: more v3 icons cleanup [@shamoon](https://github.com/shamoon) ([#14017](https://github.com/paperless-ngx/paperless-ngx/pull/14017))
|
||||||
|
- Fix: correct add version actor parity [@shamoon](https://github.com/shamoon) ([#14016](https://github.com/paperless-ngx/paperless-ngx/pull/14016))
|
||||||
|
- Fix: fix v3 favicon file [@shamoon](https://github.com/shamoon) ([#14014](https://github.com/paperless-ngx/paperless-ngx/pull/14014))
|
||||||
|
- Tweak: note that apply AI suggestions runs async in WF editor [@shamoon](https://github.com/shamoon) ([#14004](https://github.com/paperless-ngx/paperless-ngx/pull/14004))
|
||||||
|
- Fix: change share link bundle dialog button to close after create, don't toast on copied [@shamoon](https://github.com/shamoon) ([#14002](https://github.com/paperless-ngx/paperless-ngx/pull/14002))
|
||||||
|
- Enhancement (QoL): attempt to localize firstDayOfWeek for date picker [@shamoon](https://github.com/shamoon) ([#13999](https://github.com/paperless-ngx/paperless-ngx/pull/13999))
|
||||||
|
- Fix: truncate mail subjects to field max length [@shamoon](https://github.com/shamoon) ([#13991](https://github.com/paperless-ngx/paperless-ngx/pull/13991))
|
||||||
|
- Chore(deps): Bump sentence-transformers from 5.6.1 to 6.0.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13983](https://github.com/paperless-ngx/paperless-ngx/pull/13983))
|
||||||
|
- Chore(deps-dev): Bump types-markdown from 3.10.2.20260518 to 3.10.2.20260712 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13982](https://github.com/paperless-ngx/paperless-ngx/pull/13982))
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13980](https://github.com/paperless-ngx/paperless-ngx/pull/13980))
|
||||||
|
- Chore(deps): Update granian[uvloop] requirement from ~=2.7.0 to >=2.7,\<2.9 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13984](https://github.com/paperless-ngx/paperless-ngx/pull/13984))
|
||||||
|
- Chore: Updates our direct Redis pin [@stumpylog](https://github.com/stumpylog) ([#13986](https://github.com/paperless-ngx/paperless-ngx/pull/13986))
|
||||||
|
- Fix: enforce the overflow hidden rule on pdf editor thumbnails [@shamoon](https://github.com/shamoon) ([#13976](https://github.com/paperless-ngx/paperless-ngx/pull/13976))
|
||||||
|
- Fix: also correct unbroken long names on small cards [@shamoon](https://github.com/shamoon) ([#13974](https://github.com/paperless-ngx/paperless-ngx/pull/13974))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13918](https://github.com/paperless-ngx/paperless-ngx/pull/13918))
|
||||||
|
- Chore(deps): Bump uuid from 14.0.1 to 14.0.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13921](https://github.com/paperless-ngx/paperless-ngx/pull/13921))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.2.0 to 26.4.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13919](https://github.com/paperless-ngx/paperless-ngx/pull/13919))
|
||||||
|
- Chore: update ng-select to v24, handle breaking changes [@shamoon](https://github.com/shamoon) ([#13951](https://github.com/paperless-ngx/paperless-ngx/pull/13951))
|
||||||
|
- Chore(deps): Bump djangorestframework from 3.17.2 to 3.18.0 in the django-ecosystem group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13912](https://github.com/paperless-ngx/paperless-ngx/pull/13912))
|
||||||
|
- Chore(deps): Bump the document-processing group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13916](https://github.com/paperless-ngx/paperless-ngx/pull/13916))
|
||||||
|
- Chore(deps): Bump flower from 2.0.1 to 2.1.0 in the async-tasks group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13913](https://github.com/paperless-ngx/paperless-ngx/pull/13913))
|
||||||
|
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13911](https://github.com/paperless-ngx/paperless-ngx/pull/13911))
|
||||||
|
- Fix: ensure parent + child tags change together in bulk editor [@shamoon](https://github.com/shamoon) ([#13972](https://github.com/paperless-ngx/paperless-ngx/pull/13972))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.1.3
|
## paperless-ngx 3.1.3
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+48
-16
@@ -413,18 +413,12 @@ details.
|
|||||||
|
|
||||||
Defaults to `PAPERLESS_DATA_DIR/log/`.
|
Defaults to `PAPERLESS_DATA_DIR/log/`.
|
||||||
|
|
||||||
#### [`PAPERLESS_NLTK_DIR=<path>`](#PAPERLESS_NLTK_DIR) {#PAPERLESS_NLTK_DIR}
|
#### ~~[`PAPERLESS_NLTK_DIR`](#PAPERLESS_NLTK_DIR)~~ {#PAPERLESS_NLTK_DIR}
|
||||||
|
|
||||||
: This is where paperless will search for the data required for NLTK
|
!!! failure "Removed in v3.2"
|
||||||
processing, if you are using it. If you are using the Docker image,
|
|
||||||
this should not be changed, as the data is included in the image
|
|
||||||
already.
|
|
||||||
|
|
||||||
Previously, the location defaulted to `PAPERLESS_DATA_DIR/nltk`.
|
Removed and ignored. Any previously downloaded NLTK data folder can be
|
||||||
Unless you are using this in a bare metal install or other setup,
|
deleted.
|
||||||
this folder is no longer needed and can be removed manually.
|
|
||||||
|
|
||||||
Defaults to `/usr/share/nltk_data`
|
|
||||||
|
|
||||||
#### [`PAPERLESS_MODEL_FILE=<path>`](#PAPERLESS_MODEL_FILE) {#PAPERLESS_MODEL_FILE}
|
#### [`PAPERLESS_MODEL_FILE=<path>`](#PAPERLESS_MODEL_FILE) {#PAPERLESS_MODEL_FILE}
|
||||||
|
|
||||||
@@ -1190,15 +1184,31 @@ for details on how to set it.
|
|||||||
|
|
||||||
Defaults to UTC.
|
Defaults to UTC.
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_NLTK=<bool>`](#PAPERLESS_ENABLE_NLTK) {#PAPERLESS_ENABLE_NLTK}
|
#### ~~[`PAPERLESS_ENABLE_NLTK`](#PAPERLESS_ENABLE_NLTK)~~ {#PAPERLESS_ENABLE_NLTK}
|
||||||
|
|
||||||
: Enables or disables the advanced natural language processing
|
!!! failure "Removed in v3.2"
|
||||||
used during automatic classification. If disabled, paperless will
|
|
||||||
still perform some basic text pre-processing before matching.
|
|
||||||
|
|
||||||
: See also `PAPERLESS_NLTK_DIR`.
|
Removed and ignored. Automatic classification always removes stop words
|
||||||
|
and stems words when the primary OCR language is Danish, Dutch, English,
|
||||||
|
Finnish, French, German, Italian, Norwegian, Portuguese, Russian, Spanish
|
||||||
|
or Swedish. Other languages are only lowercased and split into words.
|
||||||
|
|
||||||
Defaults to true, enabling the feature.
|
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD}
|
||||||
|
|
||||||
|
: Sets the minimum confidence score (0.0-1.0) required for the automatic
|
||||||
|
classifier to assign a correspondent, document type, or storage path to a
|
||||||
|
document. Predictions below this threshold are discarded and the field is
|
||||||
|
left unassigned, preventing low-confidence guesses from being applied.
|
||||||
|
|
||||||
|
Defaults to 0.3.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS=<float>`](#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS) {#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS}
|
||||||
|
|
||||||
|
: Sets the timeout, in seconds, for regular expression matching. Increase this
|
||||||
|
value if date parsing or user-defined matching rules time out when processing
|
||||||
|
long documents, especially on slower hardware.
|
||||||
|
|
||||||
|
Defaults to 0.1 seconds.
|
||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
@@ -1269,6 +1279,8 @@ Tantivy stemmer equivalent, stemming is disabled.
|
|||||||
matching. Fuzzy results rank below exact matches. A value of `0.5` is a reasonable
|
matching. Fuzzy results rank below exact matches. A value of `0.5` is a reasonable
|
||||||
starting point. Leave unset to disable fuzzy matching entirely.
|
starting point. Leave unset to disable fuzzy matching entirely.
|
||||||
|
|
||||||
|
Words of a single character are not fuzzy-matched, since a single-character approximate match would match nearly every term in the index.
|
||||||
|
|
||||||
Defaults to unset (disabled).
|
Defaults to unset (disabled).
|
||||||
|
|
||||||
#### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
|
#### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
|
||||||
@@ -2121,6 +2133,13 @@ for language and resource considerations.
|
|||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_AI_LLM_EMBEDDING_API_KEY=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_API_KEY) {#PAPERLESS_AI_LLM_EMBEDDING_API_KEY}
|
||||||
|
|
||||||
|
: The API key to use for the embedding backend. If not supplied, embeddings use
|
||||||
|
`PAPERLESS_AI_LLM_API_KEY`.
|
||||||
|
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
#### [`PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT) {#PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT}
|
||||||
|
|
||||||
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
: The endpoint / url to use for the embedding backend. If not supplied, embeddings use
|
||||||
@@ -2205,6 +2224,19 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
|||||||
|
|
||||||
Defaults to true, which allows internal endpoints.
|
Defaults to true, which allows internal endpoints.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_AI_LLM_EXTRA_PARAMS=<json>`](#PAPERLESS_AI_LLM_EXTRA_PARAMS) {#PAPERLESS_AI_LLM_EXTRA_PARAMS}
|
||||||
|
|
||||||
|
: A JSON object of extra parameters sent with every LLM request, for providers that require a parameter Paperless does not
|
||||||
|
set itself. Values here override Paperless' own, and no validation is performed. Whatever you put here is passed to the
|
||||||
|
backend as-is, so an invalid parameter will simply be rejected by your provider. For example, current OpenAI reasoning
|
||||||
|
models refuse tool calls on the chat completions API unless reasoning is off:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAPERLESS_AI_LLM_EXTRA_PARAMS={"reasoning_effort": "none"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults to empty, which adds nothing to requests.
|
||||||
|
|
||||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_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
|
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ pnpm ng build --configuration production
|
|||||||
is loaded as well. However, the tests rely on the default
|
is loaded as well. However, the tests rely on the default
|
||||||
configuration. This is not ideal. But for now, make sure no settings
|
configuration. This is not ideal. But for now, make sure no settings
|
||||||
except for DEBUG are overridden when testing.
|
except for DEBUG are overridden when testing.
|
||||||
|
- Tests run in a random order each session, so that one test cannot quietly depend on another having run first. The seed is printed at the top of the run; pass `--randomly-seed=<seed>` to replay that exact order, or `--randomly-seed=last` to repeat the previous run.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
|
|||||||
@@ -430,11 +430,6 @@ to a positive number to enable polling and disable native filesystem notificatio
|
|||||||
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||||
software has been patented until around 2017 and binary packages are not available for most distributions.
|
software has been patented until around 2017 and binary packages are not available for most distributions.
|
||||||
|
|
||||||
**Optional: download the NLTK data**
|
|
||||||
If using the NLTK machine-learning processing (see [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
|
||||||
download the NLTK data for the Snowball Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
|
|
||||||
instructions](https://www.nltk.org/data.html) for details on how to download the data.
|
|
||||||
|
|
||||||
#### After installation
|
#### After installation
|
||||||
|
|
||||||
Your Paperless-ngx instance should now be accessible at `http://localhost:8000` (or similar, depending on your configuration).
|
Your Paperless-ngx instance should now be accessible at `http://localhost:8000` (or similar, depending on your configuration).
|
||||||
@@ -650,9 +645,6 @@ hardware, but a few settings can improve performance:
|
|||||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||||
less memory at the expense of slightly worse OCR results.
|
less memory at the expense of slightly worse OCR results.
|
||||||
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
- If using Docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
||||||
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
|
||||||
more advanced language processing, which can take more memory and
|
|
||||||
processing time.
|
|
||||||
|
|
||||||
For details, refer to [configuration](configuration.md).
|
For details, refer to [configuration](configuration.md).
|
||||||
|
|
||||||
|
|||||||
+85
-38
@@ -927,52 +927,105 @@ typed in the search bar. A few things to know about how matching works:
|
|||||||
|
|
||||||
Paperless also offers advanced search syntax if you want to drill down further.
|
Paperless also offers advanced search syntax if you want to drill down further.
|
||||||
|
|
||||||
Matching documents with logical expressions:
|
#### Combining terms
|
||||||
|
|
||||||
```
|
```
|
||||||
shopname AND (product1 OR product2)
|
shopname AND (product1 OR product2)
|
||||||
|
invoice NOT draft
|
||||||
|
"quick brown fox"
|
||||||
```
|
```
|
||||||
|
|
||||||
Matching specific tags, correspondents or types:
|
- `AND`, `OR` and `NOT` must be written in capitals. Parentheses group terms.
|
||||||
|
- Terms with no operator between them are combined with `AND`.
|
||||||
|
- Quotes match an exact phrase, with the words in that order.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
A leading `-` does **not** exclude a term. `invoice -secret` finds documents containing both words. Use `invoice NOT secret` instead.
|
||||||
|
|
||||||
|
#### Searching by field
|
||||||
|
|
||||||
|
Put a field name and a colon in front of a value to search only that field:
|
||||||
|
|
||||||
```
|
```
|
||||||
type:invoice tag:unpaid
|
type:invoice tag:unpaid
|
||||||
correspondent:university certificate
|
correspondent:"acme corp"
|
||||||
|
tag:bills,unpaid
|
||||||
|
asn:[50 to 150]
|
||||||
|
checksum:9f86d081*
|
||||||
```
|
```
|
||||||
|
|
||||||
Matching dates:
|
| Field | Searches |
|
||||||
|
| ------------------------- | ---------------------------------------- |
|
||||||
|
| `title` | Title |
|
||||||
|
| `content` | Text content |
|
||||||
|
| `correspondent` | Correspondent |
|
||||||
|
| `document_type` or `type` | Document type |
|
||||||
|
| `storage_path` or `path` | Storage path |
|
||||||
|
| `tag` | Tags |
|
||||||
|
| `original_filename` | File name the document was consumed with |
|
||||||
|
| `asn` | Archive serial number |
|
||||||
|
| `page_count` | Number of pages |
|
||||||
|
| `num_notes` | Number of notes |
|
||||||
|
| `checksum` | Checksum of the original file |
|
||||||
|
| `created` | Created date |
|
||||||
|
| `added` | When the document was added to paperless |
|
||||||
|
| `modified` | When the document was last modified |
|
||||||
|
|
||||||
|
- A field applies only to the word right after it. Quote multi-word values: `correspondent:"acme corp"`.
|
||||||
|
- A comma-separated `tag` list requires every listed tag, so `tag:bills,unpaid` only matches documents tagged with both.
|
||||||
|
- `asn`, `page_count` and `num_notes` are numbers. They accept ranges like `asn:[50 to 150]`, but not wildcards.
|
||||||
|
- `checksum` only matches the complete checksum, in lowercase. To search by its first few characters, add a wildcard: `checksum:9f86d081*`.
|
||||||
|
- `created`, `added` and `modified` take the values described in [Searching by date](#searching-by-date).
|
||||||
|
- Custom fields and notes have their own syntax, described [below](#searching-custom-fields).
|
||||||
|
|
||||||
|
#### Wildcards
|
||||||
|
|
||||||
|
```
|
||||||
|
invoice*
|
||||||
|
title:Invoice*
|
||||||
|
20[12]?
|
||||||
|
20[!0]?
|
||||||
|
```
|
||||||
|
|
||||||
|
- `*` matches any number of characters, and `?` matches exactly one.
|
||||||
|
- `[...]` matches one character from a set or range, and `[!...]` matches one character not in it. `20[12]?` matches 2010 to 2029.
|
||||||
|
- Brackets only act as a wildcard when the value also contains a `*` or `?`. Otherwise they are searched as ordinary text. The exception is a single-character range such as `title:200[1-9]`, which is rejected with an error. Add a wildcard to use it as a pattern: `title:200[1-9]*`.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
When a [stemmer is available](configuration.md#PAPERLESS_SEARCH_LANGUAGE) for your search language, words are indexed by their stem, so `copy*` also finds "copies". A prefix that runs past the stem can find nothing: `universit*` misses "university", which is stored as `univers`. If a wildcard finds nothing, try a shorter prefix, such as `univers*`.
|
||||||
|
|
||||||
|
#### Searching by date
|
||||||
|
|
||||||
```
|
```
|
||||||
created:[2005 to 2009]
|
|
||||||
added:yesterday
|
added:yesterday
|
||||||
modified:today
|
modified:"previous month"
|
||||||
|
created:[2005 to 2009]
|
||||||
|
added:[-1 week to now]
|
||||||
```
|
```
|
||||||
|
|
||||||
Matching inexact words:
|
These keywords each cover a whole period, and work with or without quotes: `today`, `yesterday`, `tomorrow`, `previous week`, `this month`, `previous month`, `previous quarter`, `this year`, `previous year`.
|
||||||
|
|
||||||
```
|
Other supported forms:
|
||||||
produ*name
|
|
||||||
```
|
|
||||||
|
|
||||||
Matching natural date keywords:
|
| Example | Matches | Quotes |
|
||||||
|
| ------------------------------------------------------- | ------------------------------ | -------- |
|
||||||
|
| `created:2005`, `created:2005-01`, `created:2005-03-04` | That year, month or day | Optional |
|
||||||
|
| `added:january` | That month in the current year | Optional |
|
||||||
|
| `added:"next monday"`, `added:"last monday"` | That day | Required |
|
||||||
|
| `added:"12 december 2019"` | That day | Required |
|
||||||
|
| `added:"2005-01-01T00:00:00Z"` | That exact time | Required |
|
||||||
|
|
||||||
```
|
Ranges take two bounds in square brackets, for example `created:[2005 to 2009]`. A bound can be any of the forms above, or a relative time like `-1 week`, `now-7d` or `now`. Bounds don't need quotes. If you do quote one, use single quotes (`added:['-1 week' to now]`), because double quotes are rejected.
|
||||||
added:today
|
|
||||||
modified:yesterday
|
|
||||||
created:"previous week"
|
|
||||||
added:"previous month"
|
|
||||||
modified:"this year"
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported date keywords: `today`, `yesterday`, `previous week`,
|
!!! warning
|
||||||
`this month`, `previous month`, `this year`, `previous year`,
|
|
||||||
`previous quarter`.
|
`now`, `noon`, `midnight` and relative times like `-1 week` only work as range bounds. On their own they mean a single instant, so `added:"-1 week"` finds nothing. Use `added:[-1 week to now]` instead. A bare weekday (`monday`) and spellings like `3 days ago` or `this week` are not supported at all.
|
||||||
|
|
||||||
#### Searching custom fields
|
#### Searching custom fields
|
||||||
|
|
||||||
Custom field names and values are included in the full-text index, but they
|
Custom field names and values are included in the full-text index, but a plain search without a field name does not look at them. Use the advanced search syntax to search by field name or value:
|
||||||
are not searched by a plain, unqualified query. Use the advanced search syntax
|
|
||||||
to search by field name or value:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
custom_fields.value:policy
|
custom_fields.value:policy
|
||||||
@@ -983,10 +1036,9 @@ custom_fields.name:Insurance custom_fields.value:policy
|
|||||||
- `custom_fields.value` matches against the value of any custom field.
|
- `custom_fields.value` matches against the value of any custom field.
|
||||||
- `custom_fields.name` matches the name of the field (use quotes for multi-word names).
|
- `custom_fields.name` matches the name of the field (use quotes for multi-word names).
|
||||||
- Combine both to find documents where a specific named field contains a specific value.
|
- Combine both to find documents where a specific named field contains a specific value.
|
||||||
|
- The bare `custom_fields:` prefix is shorthand for `custom_fields.value:`.
|
||||||
|
|
||||||
Because separators are stripped during indexing, individual parts of formatted
|
Because separators are stripped during indexing, each part of a formatted code can be searched on its own. A value stored as `A-1312/99.50` is indexed as `a`, `1312`, `99` and `50`:
|
||||||
codes are searchable on their own. A value stored as `A-1312/99.50` produces the
|
|
||||||
tokens `a`, `1312`, `99`, `50` — each searchable independently:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
custom_fields.value:1312
|
custom_fields.value:1312
|
||||||
@@ -995,14 +1047,11 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Custom date fields do not support relative date syntax (e.g. `[now to 2 weeks]`).
|
Custom date fields do not support relative date syntax such as `[now to 2 weeks]`. For date ranges on custom date fields, use the document list filters in the web UI.
|
||||||
For date ranges on custom date fields, use the document list filters in the web UI.
|
|
||||||
|
|
||||||
#### Searching notes
|
#### Searching notes
|
||||||
|
|
||||||
Notes are included in the full-text index, but they are not searched by a
|
Notes are included in the full-text index, but a plain search without a field name does not look at them. Use the advanced search syntax to search by note author or content:
|
||||||
plain, unqualified query. Use the advanced search syntax to search by note
|
|
||||||
author or content:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
notes.user:alice
|
notes.user:alice
|
||||||
@@ -1010,15 +1059,13 @@ notes.note:reminder
|
|||||||
notes.user:alice notes.note:insurance
|
notes.user:alice notes.note:insurance
|
||||||
```
|
```
|
||||||
|
|
||||||
All of these constructs can be combined as you see fit. If you want to
|
The bare `notes:` prefix is shorthand for `notes.note:`.
|
||||||
learn more about the query language used by paperless, see the
|
|
||||||
[Tantivy query language documentation](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
|
All of these can be combined. Syntax not described here may not work as expected, and an unknown field name is searched as ordinary text.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Fuzzy (approximate) matching can be enabled by setting
|
Fuzzy (approximate) matching can be enabled by setting [`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD`](configuration.md#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD). When enabled, paperless also includes near-miss results, ranked below exact matches.
|
||||||
[`PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD`](configuration.md#PAPERLESS_ADVANCED_FUZZY_SEARCH_THRESHOLD).
|
|
||||||
When enabled, paperless will include near-miss results ranked below exact matches.
|
|
||||||
|
|
||||||
## Keyboard shortcuts / hotkeys
|
## Keyboard shortcuts / hotkeys
|
||||||
|
|
||||||
|
|||||||
+23
-12
@@ -1,7 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.1.3"
|
version = "3.2.1"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = """\
|
||||||
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
|
"""
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -10,6 +12,7 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: 3.14",
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Programming Language :: Python :: 3.15",
|
||||||
]
|
]
|
||||||
# TODO: Move certain things to groups and then utilize that further
|
# TODO: Move certain things to groups and then utilize that further
|
||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
@@ -39,7 +42,7 @@ dependencies = [
|
|||||||
"django-treenode>=0.24",
|
"django-treenode>=0.24",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.9",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower>=2.0.1,<2.2",
|
"flower>=2.0.1,<2.2",
|
||||||
@@ -55,8 +58,7 @@ dependencies = [
|
|||||||
"llama-index-embeddings-openai-like>=0.2.2",
|
"llama-index-embeddings-openai-like>=0.2.2",
|
||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.10.0",
|
"ocrmypdf[heic]>=17.12,<17.13",
|
||||||
"ocrmypdf>=17.7,<17.12",
|
|
||||||
"openai>=2.48",
|
"openai>=2.48",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
@@ -74,9 +76,10 @@ dependencies = [
|
|||||||
"sqlite-vec==0.1.9",
|
"sqlite-vec==0.1.9",
|
||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client[httpx]~=1.0",
|
"tika-client[httpx]~=1.0",
|
||||||
"torch~=2.13.0",
|
"torch>=2.13,<2.15",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
|
"whoosh-compat[tantivy]==0.3",
|
||||||
"zxing-cpp~=3.1.0",
|
"zxing-cpp~=3.1.0",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -109,7 +112,7 @@ lint = [
|
|||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker>=40.36,<40.38",
|
"faker>=40.36,<40.39",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.1.1",
|
"pytest~=9.1.1",
|
||||||
"pytest-cov~=7.1.0",
|
"pytest-cov~=7.1.0",
|
||||||
@@ -117,7 +120,7 @@ testing = [
|
|||||||
"pytest-env~=1.7.0",
|
"pytest-env~=1.7.0",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.15.1",
|
||||||
# "pytest-randomly~=4.0.1",
|
"pytest-randomly~=5.0.0",
|
||||||
"pytest-rerunfailures~=16.4",
|
"pytest-rerunfailures~=16.4",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist~=3.8.0",
|
"pytest-xdist~=3.8.0",
|
||||||
@@ -244,10 +247,14 @@ per-file-ignores."docker/wait-for-redis.py" = [
|
|||||||
per-file-ignores."src/documents/models.py" = [
|
per-file-ignores."src/documents/models.py" = [
|
||||||
"SIM115",
|
"SIM115",
|
||||||
]
|
]
|
||||||
|
per-file-ignores."src/documents/tests/*.py" = [
|
||||||
|
"TID251",
|
||||||
|
]
|
||||||
|
flake8-tidy-imports.banned-api."documents.tests".msg = "Shared test infrastructure lives in src/paperless_testing/."
|
||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
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\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
@@ -271,9 +278,9 @@ plugins = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.pyrefly]
|
[tool.pyrefly]
|
||||||
baseline = ".pyrefly-baseline.json"
|
|
||||||
python-platform = "linux"
|
python-platform = "linux"
|
||||||
search-path = [ "src" ]
|
search-path = [ "src" ]
|
||||||
|
baseline = ".pyrefly-baseline.json"
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
@@ -326,6 +333,8 @@ PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
|||||||
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
|
||||||
# I don't think anything hits this, but just in case, basically infinite
|
# I don't think anything hits this, but just in case, basically infinite
|
||||||
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min"
|
||||||
|
# The 0.1s production default trips on a stalled CI runner, the date parsing tests then find no dates
|
||||||
|
PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS = "5"
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = [
|
source = [
|
||||||
@@ -334,13 +343,15 @@ source = [
|
|||||||
omit = [
|
omit = [
|
||||||
"*/tests/*",
|
"*/tests/*",
|
||||||
"manage.py",
|
"manage.py",
|
||||||
"paperless/wsgi.py",
|
|
||||||
"paperless/auth.py",
|
"paperless/auth.py",
|
||||||
|
"paperless/wsgi.py",
|
||||||
|
"src/conftest.py",
|
||||||
|
"src/paperless_testing/*",
|
||||||
]
|
]
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
exclude_also = [
|
exclude_also = [
|
||||||
"if settings.AUDIT_LOG_ENABLED:",
|
|
||||||
"if AUDIT_LOG_ENABLED:",
|
"if AUDIT_LOG_ENABLED:",
|
||||||
|
"if settings.AUDIT_LOG_ENABLED:",
|
||||||
"if TYPE_CHECKING:",
|
"if TYPE_CHECKING:",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+809
-559
File diff suppressed because it is too large
Load Diff
+28
-28
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.1.3",
|
"version": "3.2.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -15,16 +15,16 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.4",
|
"@angular/cdk": "^22.1.6",
|
||||||
"@angular/common": "~22.1.3",
|
"@angular/common": "~22.1.6",
|
||||||
"@angular/compiler": "~22.1.3",
|
"@angular/compiler": "~22.1.6",
|
||||||
"@angular/core": "~22.1.3",
|
"@angular/core": "~22.1.6",
|
||||||
"@angular/forms": "~22.1.3",
|
"@angular/forms": "~22.1.6",
|
||||||
"@angular/localize": "~22.1.3",
|
"@angular/localize": "~22.1.6",
|
||||||
"@angular/platform-browser": "~22.1.3",
|
"@angular/platform-browser": "~22.1.6",
|
||||||
"@angular/router": "~22.1.3",
|
"@angular/router": "~22.1.6",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~24.0.2",
|
"@ng-select/ng-select": "~24.1.1",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"ngx-device-detector": "^12.0.0",
|
"ngx-device-detector": "^12.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||||
"normalize-diacritics": "^5.0.0",
|
"normalize-diacritics": "^5.0.0",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.3.289",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
@@ -45,25 +45,25 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.1.6",
|
"@angular-devkit/core": "^22.1.8",
|
||||||
"@angular-devkit/schematics": "^22.1.6",
|
"@angular-devkit/schematics": "^22.1.8",
|
||||||
"@angular-eslint/builder": "22.1.0",
|
"@angular-eslint/builder": "22.5.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
"@angular-eslint/eslint-plugin": "22.5.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
"@angular-eslint/eslint-plugin-template": "22.5.0",
|
||||||
"@angular-eslint/schematics": "22.1.0",
|
"@angular-eslint/schematics": "22.5.0",
|
||||||
"@angular-eslint/template-parser": "22.1.0",
|
"@angular-eslint/template-parser": "22.5.0",
|
||||||
"@angular/build": "22.1.6",
|
"@angular/build": "22.1.8",
|
||||||
"@angular/cli": "22.1.6",
|
"@angular/cli": "22.1.8",
|
||||||
"@angular/compiler-cli": "~22.1.3",
|
"@angular/compiler-cli": "~22.1.6",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.5.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.68.0",
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||||
"@typescript-eslint/parser": "^8.68.0",
|
"@typescript-eslint/parser": "^8.70.0",
|
||||||
"@typescript-eslint/utils": "^8.68.0",
|
"@typescript-eslint/utils": "^8.70.0",
|
||||||
"eslint": "^10.9.1",
|
"eslint": "^10.10.0",
|
||||||
"jest": "30.4.2",
|
"jest": "30.5.1",
|
||||||
"jest-environment-jsdom": "^30.4.1",
|
"jest-environment-jsdom": "^30.5.1",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
|
|||||||
Generated
+1304
-1055
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import { DocumentListComponent } from './components/document-list/document-list.
|
|||||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
|
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||||
@@ -310,6 +311,24 @@ export const routes: Routes = [
|
|||||||
componentName: 'SavedViewsComponent',
|
componentName: 'SavedViewsComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-links',
|
||||||
|
component: ShareLinksComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermissionAny: [
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.ShareLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.ShareLinkBundle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
componentName: 'ShareLinksComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,22 @@
|
|||||||
|
|
||||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||||
|
|
||||||
|
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
|
||||||
|
@for (option of sidebarItemOptions; track option.id) {
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
[id]="'sidebar-item-setting-' + option.id"
|
||||||
|
[checked]="isSidebarItemShown(option.id)"
|
||||||
|
(change)="toggleSidebarItem(option.id, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
|
||||||
|
{{ option.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
@@ -209,6 +209,45 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('supports configuring sidebar items and canceling changes', () => {
|
||||||
|
completeSetup()
|
||||||
|
|
||||||
|
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||||
|
HideableSidebarItemID.Workflows
|
||||||
|
)
|
||||||
|
|
||||||
|
settingsService.updateSidebarItemVisibility(
|
||||||
|
HideableSidebarItemID.Mail,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||||
|
HideableSidebarItemID.Mail
|
||||||
|
)
|
||||||
|
|
||||||
|
component.reset()
|
||||||
|
|
||||||
|
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||||
|
HideableSidebarItemID.Workflows
|
||||||
|
)
|
||||||
|
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||||
|
HideableSidebarItemID.Mail
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables sidebar item controls on general settings until destroyed', () => {
|
||||||
|
completeSetup()
|
||||||
|
|
||||||
|
expect(settingsService.organizingSidebarItems()).toBe(true)
|
||||||
|
|
||||||
|
component.ngOnDestroy()
|
||||||
|
|
||||||
|
expect(settingsService.organizingSidebarItems()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
@@ -249,6 +288,7 @@ describe('SettingsComponent', () => {
|
|||||||
|
|
||||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
|
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||||
@@ -267,7 +307,10 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
expect(setSpy).toHaveBeenCalledTimes(34)
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
|
HideableSidebarItemID.Workflows,
|
||||||
|
])
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import {
|
||||||
|
GlobalSearchType,
|
||||||
|
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||||
|
HideableSidebarItemID,
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
} from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -102,6 +107,15 @@ const documentDetailFieldOptions = [
|
|||||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
||||||
|
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
|
||||||
|
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||||
|
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
||||||
|
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||||
|
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||||
|
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@@ -149,6 +163,7 @@ export class SettingsComponent
|
|||||||
bulkEditApplyOnClose: new FormControl(null),
|
bulkEditApplyOnClose: new FormControl(null),
|
||||||
documentListItemPerPage: new FormControl(null),
|
documentListItemPerPage: new FormControl(null),
|
||||||
slimSidebarEnabled: new FormControl(null),
|
slimSidebarEnabled: new FormControl(null),
|
||||||
|
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
||||||
darkModeUseSystem: new FormControl(null),
|
darkModeUseSystem: new FormControl(null),
|
||||||
darkModeEnabled: new FormControl(null),
|
darkModeEnabled: new FormControl(null),
|
||||||
darkModeInvertThumbs: new FormControl(null),
|
darkModeInvertThumbs: new FormControl(null),
|
||||||
@@ -186,6 +201,7 @@ export class SettingsComponent
|
|||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
|
sidebarItemsSub: Subscription
|
||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
isDirty: boolean = false
|
isDirty: boolean = false
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -203,6 +219,10 @@ export class SettingsComponent
|
|||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
|
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
||||||
|
id,
|
||||||
|
label: sidebarItemLabels[id],
|
||||||
|
}))
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
const status = this.systemStatus()
|
const status = this.systemStatus()
|
||||||
@@ -230,6 +250,10 @@ export class SettingsComponent
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
this.sidebarItemsSub =
|
||||||
|
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
||||||
|
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
||||||
|
)
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
if (!this.savePending) this.initialize()
|
if (!this.savePending) this.initialize()
|
||||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||||
@@ -279,14 +303,21 @@ export class SettingsComponent
|
|||||||
|
|
||||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||||
const section = paramMap.get('section')
|
const section = paramMap.get('section')
|
||||||
|
let navID = SettingsNavIDs.General
|
||||||
if (section) {
|
if (section) {
|
||||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
navID = SettingsNavIDs[navIDKey]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.activeNavID.set(navID)
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set(
|
||||||
|
navID === SettingsNavIDs.General
|
||||||
|
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
||||||
|
: null
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +341,7 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||||
),
|
),
|
||||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||||
|
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
|
||||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||||
darkModeInvertThumbs: this.settings.get(
|
darkModeInvertThumbs: this.settings.get(
|
||||||
@@ -436,6 +468,12 @@ export class SettingsComponent
|
|||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.settings.organizingSidebarItems()) {
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set([
|
||||||
|
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
if (this.canViewSystemStatus) {
|
if (this.canViewSystemStatus) {
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus.set(status)
|
this.systemStatus.set(status)
|
||||||
@@ -444,8 +482,18 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set(null)
|
||||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||||
this.storeSub && this.storeSub.unsubscribe()
|
this.storeSub && this.storeSub.unsubscribe()
|
||||||
|
this.sidebarItemsSub.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
isSidebarItemShown(item: HideableSidebarItemID): boolean {
|
||||||
|
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
|
||||||
|
this.settings.updateSidebarItemVisibility(item, checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
public saveSettings() {
|
public saveSettings() {
|
||||||
@@ -473,6 +521,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||||
this.settingsForm.value.slimSidebarEnabled
|
this.settingsForm.value.slimSidebarEnabled
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||||
|
this.settingsForm.value.sidebarHiddenItems
|
||||||
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||||
this.settingsForm.value.darkModeUseSystem
|
this.settingsForm.value.darkModeUseSystem
|
||||||
@@ -632,6 +684,11 @@ export class SettingsComponent
|
|||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.settingsForm.patchValue(this.store.getValue())
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
|
if (this.settings.organizingSidebarItems()) {
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set([
|
||||||
|
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const TASK_TYPE_OPTIONS: Array<{
|
|||||||
value: PaperlessTaskType.BulkDelete,
|
value: PaperlessTaskType.BulkDelete,
|
||||||
label: $localize`Bulk Delete`,
|
label: $localize`Bulk Delete`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: PaperlessTaskType.ApplyAiSuggestions,
|
||||||
|
label: $localize`Apply AI Suggestions`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const TRIGGER_SOURCE_OPTIONS: Array<{
|
const TRIGGER_SOURCE_OPTIONS: Array<{
|
||||||
|
|||||||
@@ -86,12 +86,15 @@
|
|||||||
}
|
}
|
||||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
@@ -237,29 +240,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
@if (canManageShareLinks) {
|
||||||
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks) && !settingsService.organizingSidebarItems()">
|
||||||
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
|
||||||
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Share Links" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.ShareLinks, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
@@ -322,13 +346,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
||||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import {
|
import {
|
||||||
@@ -287,6 +287,87 @@ describe('AppFrameComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should hide configured sidebar items', () => {
|
||||||
|
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
|
HideableSidebarItemID.Dashboard,
|
||||||
|
HideableSidebarItemID.Workflows,
|
||||||
|
HideableSidebarItemID.ShareLinks,
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||||
|
.parentElement.classList
|
||||||
|
).toContain('d-none')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="workflows"]')
|
||||||
|
.parentElement.classList
|
||||||
|
).toContain('d-none')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
||||||
|
.parentElement.classList
|
||||||
|
).toContain('d-none')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||||
|
.classList
|
||||||
|
).not.toContain('d-none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show hidden items and visibility switches while customizing', () => {
|
||||||
|
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
|
HideableSidebarItemID.Dashboard,
|
||||||
|
])
|
||||||
|
settingsService.sidebarHiddenItemsEditing.set([
|
||||||
|
HideableSidebarItemID.Dashboard,
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
||||||
|
).toBe(6)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||||
|
.parentElement.classList
|
||||||
|
).not.toContain('d-none')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||||
|
).toContain('opacity-50')
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||||
|
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||||
|
).not.toContain('pe-5')
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
||||||
|
component.slimSidebarAnimating.set(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||||
|
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
component.slimSidebarAnimating.set(false)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||||
|
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||||
|
).toContain('pe-5')
|
||||||
|
})
|
||||||
|
|
||||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -21,7 +22,11 @@ import { Observable } from 'rxjs'
|
|||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import {
|
||||||
|
CollapsibleSection,
|
||||||
|
HideableSidebarItemID,
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
} from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -48,6 +53,7 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
|||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
|
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -76,6 +82,8 @@ const SCROLL_THRESHOLD = 16
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
|
FormsModule,
|
||||||
|
SwitchComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -98,6 +106,7 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
|
readonly HideableSidebarItemID = HideableSidebarItemID
|
||||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
SETTINGS_KEYS.VERSION
|
SETTINGS_KEYS.VERSION
|
||||||
)
|
)
|
||||||
@@ -195,6 +204,10 @@ export class AppFrameComponent
|
|||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
||||||
|
this.settingsService.updateSidebarItemVisibility(item, visible)
|
||||||
|
}
|
||||||
|
|
||||||
toggleAttributesSections(event?: Event): void {
|
toggleAttributesSections(event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
@@ -221,6 +234,19 @@ export class AppFrameComponent
|
|||||||
return this.appTitleSetting() || environment.appTitle
|
return this.appTitleSetting() || environment.appTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canManageShareLinks(): boolean {
|
||||||
|
return (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLink
|
||||||
|
) ||
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLinkBundle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
return this.appTitleSetting()
|
return this.appTitleSetting()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="mb-3">
|
<div [class.mb-3]="!compact">
|
||||||
<div class="row">
|
<div [class.row]="!compact">
|
||||||
@if (!horizontal) {
|
@if (!horizontal && !compact) {
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||||
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
|
||||||
@if (horizontal) {
|
@if (horizontal && !compact) {
|
||||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@if (showUnsetNote && isUnset) {
|
@if (showUnsetNote && isUnset) {
|
||||||
|
|||||||
@@ -48,4 +48,14 @@ describe('SwitchComponent', () => {
|
|||||||
component.value = undefined
|
component.value = undefined
|
||||||
expect(component.isUnset).toBeTruthy()
|
expect(component.isUnset).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support a compact layout', () => {
|
||||||
|
component.compact = true
|
||||||
|
component.title = 'Test switch'
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
|
||||||
|
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
|
||||||
|
expect(input.getAttribute('aria-label')).toEqual('Test switch')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
|||||||
@Input()
|
@Input()
|
||||||
showUnsetNote: boolean = false
|
showUnsetNote: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
compact: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
|
|||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
|
||||||
import { DataType } from 'src/app/data/datatype'
|
import { DataType } from 'src/app/data/datatype'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
import {
|
import {
|
||||||
FILTER_CORRESPONDENT,
|
FILTER_CORRESPONDENT,
|
||||||
@@ -100,13 +101,18 @@ const doc: Document = {
|
|||||||
custom_fields: [
|
custom_fields: [
|
||||||
{
|
{
|
||||||
field: 0,
|
field: 0,
|
||||||
document: 3,
|
|
||||||
created: new Date(),
|
|
||||||
value: 'custom foo bar',
|
value: 'custom foo bar',
|
||||||
},
|
},
|
||||||
],
|
] as CustomFieldInstance[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Newest first, as the API returns them: 12 is the latest, 3 is the root
|
||||||
|
const docVersions: DocumentVersionInfo[] = [
|
||||||
|
{ id: 12, is_root: false },
|
||||||
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
|
]
|
||||||
|
|
||||||
const customFields = [
|
const customFields = [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -2045,6 +2051,208 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(saveSpy).toHaveBeenCalled()
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selectVersion should use the version content as the baseline and ignore stale responses', () => {
|
||||||
|
initNormally()
|
||||||
|
const version10Content = new Subject<Document>()
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValueOnce(version10Content)
|
||||||
|
.mockReturnValueOnce(of({ content: 'version 12 content' } as Document))
|
||||||
|
const version10Metadata = new Subject<any>()
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'getMetadata')
|
||||||
|
.mockReturnValueOnce(version10Metadata)
|
||||||
|
.mockReturnValueOnce(of({ lang: 'de' }))
|
||||||
|
|
||||||
|
component.selectVersion(10)
|
||||||
|
component.selectVersion(12)
|
||||||
|
version10Content.next({ content: 'version 10 content' } as Document)
|
||||||
|
version10Metadata.next({ lang: 'en' })
|
||||||
|
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
expect(component.store.value.content).toEqual('version 12 content')
|
||||||
|
expect(component.metadata().lang).toEqual('de')
|
||||||
|
expect(
|
||||||
|
httpTestingController.expectOne(component.previewUrl()).cancelled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
httpTestingController.match((req) => req.url.includes('version=10'))[0]
|
||||||
|
?.cancelled
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm before discarding unsaved content edits when switching versions', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockImplementation((id, versionID) =>
|
||||||
|
of({ content: `version ${versionID} content` } as Document)
|
||||||
|
)
|
||||||
|
let openModal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
// shared fields carry over between versions, so no confirmation
|
||||||
|
component.documentForm.get('title').setValue('Edited title')
|
||||||
|
component.documentForm.get('title').markAsDirty()
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.onVersionSelected(12)
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.onVersionSelected(12) // already selected, nothing to do
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(
|
||||||
|
ConfirmDialogComponent,
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
openModal.componentInstance.cancel()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'edited content'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
openModal.componentInstance.confirmClicked.emit()
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 10 content'
|
||||||
|
)
|
||||||
|
expect(component.documentForm.get('content').dirty).toBeFalsy()
|
||||||
|
expect(component.documentForm.get('title').value).toEqual('Edited title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save unsaved content edits to the current version before switching, and stay if that fails', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
component.selectedVersionId.set(12)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ content: 'version 10 content' } as Document))
|
||||||
|
const savedDoc = new Subject<Document>()
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(documentService, 'patch')
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('failed to save')))
|
||||||
|
.mockReturnValueOnce(savedDoc)
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
let modal: NgbModalRef = modalSpy.mock.results[0].value
|
||||||
|
const closeSpy = jest.spyOn(modal, 'close')
|
||||||
|
modal.componentInstance.alternativeClicked.emit()
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'edited content'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
modal = modalSpy.mock.results[1].value
|
||||||
|
modal.componentInstance.alternativeClicked.emit()
|
||||||
|
expect(patchSpy).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ content: 'edited content' }),
|
||||||
|
12
|
||||||
|
)
|
||||||
|
component.onVersionSelected(doc.id) // ignored while saving
|
||||||
|
expect(modalSpy).toHaveBeenCalledTimes(2)
|
||||||
|
savedDoc.next(doc)
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 10 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch without confirmation when the selected version was deleted, even while saving', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
component.selectedVersionId.set(10)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ content: 'version 12 content' } as Document))
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.networkActive.set(true)
|
||||||
|
|
||||||
|
// the version dropdown emits this after deleting the selected version
|
||||||
|
component.onVersionsUpdated(docVersions.filter((v) => v.id !== 10))
|
||||||
|
component.onVersionSelected(12)
|
||||||
|
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore the selected version and its unsaved content when returning to a document', () => {
|
||||||
|
initNormally()
|
||||||
|
const openDoc = component.document()
|
||||||
|
openDoc.versions = docVersions
|
||||||
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockImplementation((id, versionID) =>
|
||||||
|
of(
|
||||||
|
(versionID
|
||||||
|
? { content: `version ${versionID} content` }
|
||||||
|
: { ...doc, versions: docVersions }) as Document
|
||||||
|
)
|
||||||
|
)
|
||||||
|
component.selectVersion(10)
|
||||||
|
// an edit that happens to match the latest version's content
|
||||||
|
component.documentForm.get('content').setValue(doc.content)
|
||||||
|
openDoc.__changedFields = ['content']
|
||||||
|
|
||||||
|
component['loadDocument'](doc.id)
|
||||||
|
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(doc.content)
|
||||||
|
expect(openDocumentsService.isDirty(openDoc)).toBeTruthy()
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(documentService, 'patch')
|
||||||
|
.mockReturnValue(of(doc))
|
||||||
|
component.save()
|
||||||
|
expect(patchSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: doc.content }),
|
||||||
|
10
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to the latest version when the remembered version no longer exists', () => {
|
||||||
|
initNormally()
|
||||||
|
const openDoc = component.document()
|
||||||
|
openDoc.versions = docVersions
|
||||||
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
|
jest.spyOn(documentService, 'get').mockImplementation((id, versionID) =>
|
||||||
|
of(
|
||||||
|
(versionID
|
||||||
|
? { content: `version ${versionID} content` }
|
||||||
|
: {
|
||||||
|
...doc,
|
||||||
|
content: 'version 12 content',
|
||||||
|
versions: docVersions.filter((v) => v.id !== 10),
|
||||||
|
}) as Document
|
||||||
|
)
|
||||||
|
)
|
||||||
|
component.selectVersion(10)
|
||||||
|
|
||||||
|
component['loadDocument'](doc.id)
|
||||||
|
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
||||||
currentUserCan = false
|
currentUserCan = false
|
||||||
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
|||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
|
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -304,6 +304,7 @@ export class DocumentDetailComponent
|
|||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
docChangeNotifier: Subject<any> = new Subject()
|
docChangeNotifier: Subject<any> = new Subject()
|
||||||
|
versionChangeNotifier: Subject<void> = new Subject()
|
||||||
private incomingUpdateModal: NgbModalRef
|
private incomingUpdateModal: NgbModalRef
|
||||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||||
private lastLocalSaveModified: string | null = null
|
private lastLocalSaveModified: string | null = null
|
||||||
@@ -417,7 +418,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
@@ -533,7 +535,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => this.previewText.set(res.toString()),
|
next: (res) => this.previewText.set(res.toString()),
|
||||||
@@ -595,6 +598,13 @@ export class DocumentDetailComponent
|
|||||||
openDocument.duplicate_documents = doc.duplicate_documents
|
openDocument.duplicate_documents = doc.duplicate_documents
|
||||||
this.openDocumentService.save()
|
this.openDocumentService.save()
|
||||||
}
|
}
|
||||||
|
// use server versions
|
||||||
|
if (openDocument) {
|
||||||
|
openDocument.versions = doc.versions
|
||||||
|
if (!openDocument.__changedFields?.includes('content')) {
|
||||||
|
openDocument.content = doc.content
|
||||||
|
}
|
||||||
|
}
|
||||||
let useDoc = openDocument || doc
|
let useDoc = openDocument || doc
|
||||||
if (openDocument && forceRemote) {
|
if (openDocument && forceRemote) {
|
||||||
Object.assign(openDocument, doc)
|
Object.assign(openDocument, doc)
|
||||||
@@ -642,7 +652,14 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.patchValue({ title: titleValue })
|
this.documentForm.patchValue({ title: titleValue })
|
||||||
this.documentForm.get('title').markAsDirty()
|
this.documentForm.get('title').markAsDirty()
|
||||||
})
|
})
|
||||||
|
const keepContentEdits =
|
||||||
|
useDoc.__selectedVersionId === this.selectedVersionId() &&
|
||||||
|
!!useDoc.__changedFields?.includes('content')
|
||||||
this.setupDirtyTracking(useDoc, doc)
|
this.setupDirtyTracking(useDoc, doc)
|
||||||
|
// Maybe load the stored version
|
||||||
|
if (useDoc.__selectedVersionId) {
|
||||||
|
this.selectVersion(this.selectedVersionId(), keepContentEdits)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -903,9 +920,11 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
updateComponent(doc: Document) {
|
updateComponent(doc: Document) {
|
||||||
this.document.set(doc)
|
this.document.set(doc)
|
||||||
// Default selected version is the newest version, which the API returns first
|
// Load the selected version, or default to API first (newest)
|
||||||
const versions = doc.versions ?? []
|
const versions = doc.versions ?? []
|
||||||
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
const selectedVersion =
|
||||||
|
versions.find((v) => v.id === doc.__selectedVersionId) ?? versions[0]
|
||||||
|
this.selectedVersionId.set(selectedVersion?.id ?? doc.id)
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.requiresPassword = false
|
this.requiresPassword = false
|
||||||
this.updateFormForCustomFields()
|
this.updateFormForCustomFields()
|
||||||
@@ -940,8 +959,12 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update file preview and download target to a specific version (by document id)
|
// Update file preview and download target to a specific version (by document id)
|
||||||
selectVersion(versionId: number) {
|
selectVersion(versionId: number, keepContentEdits: boolean = false) {
|
||||||
|
this.versionChangeNotifier.next()
|
||||||
this.selectedVersionId.set(versionId)
|
this.selectedVersionId.set(versionId)
|
||||||
|
// remember so the version can be restored when returning to the document
|
||||||
|
this.document().__selectedVersionId = versionId
|
||||||
|
this.openDocumentService.save()
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.previewUrl.set(
|
this.previewUrl.set(
|
||||||
this.documentsService.getPreviewUrl(
|
this.documentsService.getPreviewUrl(
|
||||||
@@ -963,20 +986,20 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
const content = doc?.content ?? ''
|
const content = doc?.content ?? ''
|
||||||
this.document().content = content
|
if (keepContentEdits) {
|
||||||
this.documentForm.patchValue(
|
this.store.next({ ...this.store.value, content })
|
||||||
{
|
} else {
|
||||||
content,
|
// Update in-place and avoid the debounce wait
|
||||||
},
|
this.store.value.content = content
|
||||||
{
|
this.documentForm.patchValue({ content })
|
||||||
emitEvent: false,
|
this.documentForm.get('content').markAsPristine()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -991,7 +1014,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => this.previewText.set(res.toString()),
|
next: (res) => this.previewText.set(res.toString()),
|
||||||
@@ -1005,7 +1029,39 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onVersionSelected(versionId: number) {
|
onVersionSelected(versionId: number) {
|
||||||
|
if (versionId === this.selectedVersionId()) return
|
||||||
|
// Bail if the selected version was just deleted.
|
||||||
|
const selectedVersionExists = this.document()?.versions?.some(
|
||||||
|
(v) => v.id === this.selectedVersionId()
|
||||||
|
)
|
||||||
|
if (this.networkActive() && selectedVersionExists) return
|
||||||
|
if (
|
||||||
|
!selectedVersionExists ||
|
||||||
|
this.documentForm.get('content').value === this.store.value.content
|
||||||
|
) {
|
||||||
this.selectVersion(versionId)
|
this.selectVersion(versionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm any unsaved content changes
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Unsaved Changes`
|
||||||
|
modal.componentInstance.messageBold = $localize`You have unsaved changes to the content of this version.`
|
||||||
|
modal.componentInstance.message = $localize`Switching versions will discard them.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-secondary'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Discard and switch`
|
||||||
|
modal.componentInstance.alternativeBtnClass = 'btn-primary'
|
||||||
|
modal.componentInstance.alternativeBtnCaption = $localize`Save and switch`
|
||||||
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
this.selectVersion(versionId)
|
||||||
|
})
|
||||||
|
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
this.save(false, () => this.selectVersion(versionId))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
||||||
@@ -1233,7 +1289,7 @@ export class DocumentDetailComponent
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
save(close: boolean = false) {
|
save(close: boolean = false, savedCallback: () => void = null) {
|
||||||
this.networkActive.set(true)
|
this.networkActive.set(true)
|
||||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
@@ -1266,6 +1322,7 @@ export class DocumentDetailComponent
|
|||||||
this.flushPendingIncomingUpdate()
|
this.flushPendingIncomingUpdate()
|
||||||
}
|
}
|
||||||
this.savedViewService.maybeRefreshDocumentCounts()
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
|
savedCallback?.()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
|
|||||||
+101
-20
@@ -7,8 +7,9 @@ import {
|
|||||||
import { EventEmitter, signal } from '@angular/core'
|
import { EventEmitter, signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -46,7 +47,6 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
|
|||||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
|
||||||
import { BulkEditorComponent } from './bulk-editor.component'
|
import { BulkEditorComponent } from './bulk-editor.component'
|
||||||
|
|
||||||
const selectionData: SelectionData = {
|
const selectionData: SelectionData = {
|
||||||
@@ -82,6 +82,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let shareLinkBundleService: ShareLinkBundleService
|
let shareLinkBundleService: ShareLinkBundleService
|
||||||
|
let router: Router
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -167,11 +168,14 @@ describe('BulkEditorComponent', () => {
|
|||||||
provide: ShareLinkBundleService,
|
provide: ShareLinkBundleService,
|
||||||
useValue: {
|
useValue: {
|
||||||
createBundle: jest.fn(),
|
createBundle: jest.fn(),
|
||||||
listAllBundles: jest.fn(),
|
|
||||||
rebuildBundle: jest.fn(),
|
rebuildBundle: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: Router,
|
||||||
|
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||||
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
@@ -189,6 +193,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -387,6 +392,42 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should request selection data for tags when documents are excluded from an all-filtered selection', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'excluded', 'get')
|
||||||
|
.mockReturnValue(new Set([4]))
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||||
|
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||||
|
.mockReturnValue(2)
|
||||||
|
const adjustedSelectionData: SelectionData = {
|
||||||
|
...selectionData,
|
||||||
|
selected_tags: [{ id: 12, document_count: 2 }],
|
||||||
|
}
|
||||||
|
const getSelectionDataSpy = jest
|
||||||
|
.spyOn(documentService, 'getSelectionData')
|
||||||
|
.mockReturnValue(of(adjustedSelectionData))
|
||||||
|
|
||||||
|
component.openTagsDropdown()
|
||||||
|
|
||||||
|
expect(getSelectionDataSpy).toHaveBeenCalledWith({
|
||||||
|
all: true,
|
||||||
|
filters: { title_search: 'apple' },
|
||||||
|
excluded_documents: [4],
|
||||||
|
})
|
||||||
|
expect(component.tagDocumentCounts()).toEqual(
|
||||||
|
adjustedSelectionData.selected_tags
|
||||||
|
)
|
||||||
|
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
|
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -455,6 +496,47 @@ describe('BulkEditorComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should request selection data for the other metadata menus when documents are excluded', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'excluded', 'get')
|
||||||
|
.mockReturnValue(new Set([4]))
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||||
|
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||||
|
const getSelectionDataSpy = jest
|
||||||
|
.spyOn(documentService, 'getSelectionData')
|
||||||
|
.mockReturnValue(of(selectionData))
|
||||||
|
|
||||||
|
component.openDocumentTypeDropdown()
|
||||||
|
component.openCorrespondentDropdown()
|
||||||
|
component.openStoragePathDropdown()
|
||||||
|
component.openCustomFieldsDropdown()
|
||||||
|
|
||||||
|
expect(getSelectionDataSpy).toHaveBeenCalledTimes(4)
|
||||||
|
expect(getSelectionDataSpy).toHaveBeenCalledWith({
|
||||||
|
all: true,
|
||||||
|
filters: { title_search: 'apple' },
|
||||||
|
excluded_documents: [4],
|
||||||
|
})
|
||||||
|
expect(component.documentTypeDocumentCounts()).toEqual(
|
||||||
|
selectionData.selected_document_types
|
||||||
|
)
|
||||||
|
expect(component.correspondentDocumentCounts()).toEqual(
|
||||||
|
selectionData.selected_correspondents
|
||||||
|
)
|
||||||
|
expect(component.storagePathDocumentCounts()).toEqual(
|
||||||
|
selectionData.selected_storage_paths
|
||||||
|
)
|
||||||
|
expect(component.customFieldDocumentCounts()).toEqual(
|
||||||
|
selectionData.selected_custom_fields
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should execute modify tags bulk operation', () => {
|
it('should execute modify tags bulk operation', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
@@ -496,16 +578,19 @@ describe('BulkEditorComponent', () => {
|
|||||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'selected', 'get')
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
.mockReturnValue(new Set([3, 4]))
|
.mockReturnValue(new Set([3]))
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
.mockReturnValue(true)
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'excluded', 'get')
|
||||||
|
.mockReturnValue(new Set([4]))
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'filterRules', 'get')
|
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||||
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'selectedCount', 'get')
|
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||||
.mockReturnValue(25)
|
.mockReturnValue(24)
|
||||||
jest
|
jest
|
||||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
.mockReturnValue(true)
|
.mockReturnValue(true)
|
||||||
@@ -524,6 +609,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
all: true,
|
all: true,
|
||||||
filters: { title_search: 'apple' },
|
filters: { title_search: 'apple' },
|
||||||
|
excluded_documents: [4],
|
||||||
method: 'modify_tags',
|
method: 'modify_tags',
|
||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
@@ -1824,9 +1910,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSpy = jest.spyOn(modalService, 'open')
|
const openSpy = jest
|
||||||
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
|
.spyOn(modalService, 'open')
|
||||||
openSpy.mockReturnValueOnce({} as NgbModalRef)
|
.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||||
of({ id: 42 })
|
of({ id: 42 })
|
||||||
)
|
)
|
||||||
@@ -1860,11 +1946,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
|
|
||||||
dialogInstance.onOpenManage()
|
dialogInstance.onOpenManage()
|
||||||
expect(modalRef.close).toHaveBeenCalled()
|
expect(modalRef.close).toHaveBeenCalled()
|
||||||
expect(openSpy).toHaveBeenNthCalledWith(
|
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||||
2,
|
queryParams: { type: 'bundles' },
|
||||||
ShareLinkBundleManageDialogComponent,
|
})
|
||||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
|
||||||
)
|
|
||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1917,13 +2001,10 @@ describe('BulkEditorComponent', () => {
|
|||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open share link bundle management dialog', () => {
|
it('should navigate to share link bundle management', () => {
|
||||||
const openSpy = jest.spyOn(modalService, 'open')
|
|
||||||
component.manageShareLinkBundles()
|
component.manageShareLinkBundles()
|
||||||
expect(openSpy).toHaveBeenCalledWith(
|
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||||
ShareLinkBundleManageDialogComponent,
|
queryParams: { type: 'bundles' },
|
||||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
})
|
||||||
)
|
|
||||||
openSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbModal,
|
NgbModal,
|
||||||
@@ -69,7 +70,6 @@ import {
|
|||||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||||
|
|
||||||
@@ -104,6 +104,7 @@ export class BulkEditorComponent
|
|||||||
public readonly permissionService = inject(PermissionsService)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
|
||||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
@@ -360,6 +361,7 @@ export class BulkEditorComponent
|
|||||||
return {
|
return {
|
||||||
all: true,
|
all: true,
|
||||||
filters: queryParamsFromFilterRules(this.list.filterRules),
|
filters: queryParamsFromFilterRules(this.list.filterRules),
|
||||||
|
excluded_documents: Array.from(this.list.excluded),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +375,8 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openTagsDropdown() {
|
openTagsDropdown() {
|
||||||
if (this.list.allSelected) {
|
// If none excluded, use the selection data already available in the list view, otherwise fetch
|
||||||
|
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
||||||
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
||||||
@@ -381,7 +384,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(this.getSelectionQuery())
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.tagDocumentCounts.set(s.selected_tags)
|
this.tagDocumentCounts.set(s.selected_tags)
|
||||||
@@ -390,7 +393,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openDocumentTypeDropdown() {
|
openDocumentTypeDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.documentTypeDocumentCounts.set(
|
this.documentTypeDocumentCounts.set(
|
||||||
selectionData?.selected_document_types ?? []
|
selectionData?.selected_document_types ?? []
|
||||||
@@ -403,7 +406,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(this.getSelectionQuery())
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
||||||
@@ -415,7 +418,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCorrespondentDropdown() {
|
openCorrespondentDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.correspondentDocumentCounts.set(
|
this.correspondentDocumentCounts.set(
|
||||||
selectionData?.selected_correspondents ?? []
|
selectionData?.selected_correspondents ?? []
|
||||||
@@ -428,7 +431,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(this.getSelectionQuery())
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
||||||
@@ -440,7 +443,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openStoragePathDropdown() {
|
openStoragePathDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.storagePathDocumentCounts.set(
|
this.storagePathDocumentCounts.set(
|
||||||
selectionData?.selected_storage_paths ?? []
|
selectionData?.selected_storage_paths ?? []
|
||||||
@@ -453,7 +456,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(this.getSelectionQuery())
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
||||||
@@ -465,7 +468,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCustomFieldsDropdown() {
|
openCustomFieldsDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.customFieldDocumentCounts.set(
|
this.customFieldDocumentCounts.set(
|
||||||
selectionData?.selected_custom_fields ?? []
|
selectionData?.selected_custom_fields ?? []
|
||||||
@@ -478,7 +481,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.documentService
|
this.documentService
|
||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(this.getSelectionQuery())
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
||||||
@@ -1135,9 +1138,8 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
manageShareLinkBundles() {
|
manageShareLinkBundles() {
|
||||||
this.modalService.open(ShareLinkBundleManageDialogComponent, {
|
void this.router.navigate(['/share-links'], {
|
||||||
backdrop: 'static',
|
queryParams: { type: 'bundles' },
|
||||||
size: 'lg',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ describe('DocumentListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should stop reloading on document deleted after destroy', () => {
|
||||||
|
const reloadSpy = jest.spyOn(documentListService, 'reload')
|
||||||
|
const documentDeletedSubject = new Subject<boolean>()
|
||||||
|
jest
|
||||||
|
.spyOn(websocketStatusService, 'onDocumentDeleted')
|
||||||
|
.mockReturnValue(documentDeletedSubject)
|
||||||
|
fixture.detectChanges()
|
||||||
|
fixture.destroy()
|
||||||
|
reloadSpy.mockClear()
|
||||||
|
documentDeletedSubject.next(true)
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should show score sort fields on fulltext queries', () => {
|
it('should show score sort fields on fulltext queries', () => {
|
||||||
documentListService.setFilterRules([
|
documentListService.setFilterRules([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -270,7 +270,10 @@ export class DocumentListComponent
|
|||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
|
this.websocketStatusService
|
||||||
|
.onDocumentDeleted()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+11
-12
@@ -1423,11 +1423,16 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clickTextFilterTarget = (name: string) => {
|
||||||
|
const item = fixture.debugElement
|
||||||
|
.queryAll(By.directive(NgbDropdownItem))
|
||||||
|
.find((el) => el.nativeElement.textContent.trim() === name)
|
||||||
|
expect(item).not.toBeUndefined()
|
||||||
|
item.triggerEventHandler('click')
|
||||||
|
}
|
||||||
|
|
||||||
it('should convert duplicate target input to the correct filter rule', () => {
|
it('should convert duplicate target input to the correct filter rule', () => {
|
||||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
clickTextFilterTarget('Duplicates')
|
||||||
By.directive(NgbDropdownItem)
|
|
||||||
)[5]
|
|
||||||
textFieldTargetDropdown.triggerEventHandler('click')
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(component.textFilterTarget).toEqual('duplicates')
|
expect(component.textFilterTarget).toEqual('duplicates')
|
||||||
@@ -1453,10 +1458,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
it('should convert user input to correct filter rules on full text query', () => {
|
it('should convert user input to correct filter rules on full text query', () => {
|
||||||
component.textFilterInput.nativeElement.value = 'foo'
|
component.textFilterInput.nativeElement.value = 'foo'
|
||||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
clickTextFilterTarget('Advanced search')
|
||||||
By.directive(NgbDropdownItem)
|
|
||||||
)[4]
|
|
||||||
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.textFilterTarget).toEqual('fulltext-query')
|
expect(component.textFilterTarget).toEqual('fulltext-query')
|
||||||
@@ -1925,10 +1927,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
it('should leave relative dates not in quick list intact', () => {
|
it('should leave relative dates not in quick list intact', () => {
|
||||||
component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
|
component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
|
||||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
clickTextFilterTarget('Advanced search')
|
||||||
By.directive(NgbDropdownItem)
|
|
||||||
)[4]
|
|
||||||
textFieldTargetDropdown.triggerEventHandler('click')
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
|
|||||||
@@ -205,11 +205,11 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
|||||||
},
|
},
|
||||||
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
|
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
|
||||||
{ id: TEXT_FILTER_TARGET_MIME_TYPE, name: $localize`File type` },
|
{ id: TEXT_FILTER_TARGET_MIME_TYPE, name: $localize`File type` },
|
||||||
|
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
||||||
{
|
{
|
||||||
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
||||||
name: $localize`Advanced search`,
|
name: $localize`Advanced search`,
|
||||||
},
|
},
|
||||||
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
||||||
|
|||||||
+41
-25
@@ -1,38 +1,22 @@
|
|||||||
<div class="modal-header">
|
<div class="border border-top-0 rounded-bottom p-3">
|
||||||
<h4 class="modal-title">{{ title }}</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (loading()) {
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
|
||||||
<span i18n>Loading share link bundles…</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (!loading() && error()) {
|
@if (!loading() && error()) {
|
||||||
<div class="alert alert-danger mb-0" role="alert">
|
<div class="alert alert-danger mb-0" role="alert">
|
||||||
{{ error() }}
|
{{ error() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!loading() && !error()) {
|
@if (!loading() && !error()) {
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<p class="mb-0 text-muted small">
|
|
||||||
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@if (bundles().length === 0) {
|
@if (bundles().length === 0) {
|
||||||
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||||
}
|
}
|
||||||
@if (bundles().length > 0) {
|
@if (bundles().length > 0) {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle mb-0">
|
<table class="table table-sm align-middle mb-0 bg-body">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" i18n>Created</th>
|
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||||
<th scope="col" i18n>Status</th>
|
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th>
|
||||||
<th scope="col" i18n>Size</th>
|
<th scope="col" i18n>Size</th>
|
||||||
<th scope="col" i18n>Expires</th>
|
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||||
<th scope="col" i18n>Documents</th>
|
<th scope="col" i18n>Documents</th>
|
||||||
<th scope="col" i18n>File version</th>
|
<th scope="col" i18n>File version</th>
|
||||||
<th scope="col" class="text-end" i18n>Actions</th>
|
<th scope="col" class="text-end" i18n>Actions</th>
|
||||||
@@ -96,6 +80,9 @@
|
|||||||
<td>
|
<td>
|
||||||
@if (bundle.expiration) {
|
@if (bundle.expiration) {
|
||||||
{{ bundle.expiration | date: 'short' }}
|
{{ bundle.expiration | date: 'short' }}
|
||||||
|
@if (isExpired(bundle.expiration)) {
|
||||||
|
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (!bundle.expiration) {
|
@if (!bundle.expiration) {
|
||||||
<span i18n>Never</span>
|
<span i18n>Never</span>
|
||||||
@@ -104,6 +91,12 @@
|
|||||||
<td>{{ bundle.document_count }}</td>
|
<td>{{ bundle.document_count }}</td>
|
||||||
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
|
<div class="d-inline-block position-relative">
|
||||||
|
<span
|
||||||
|
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||||
|
[class.show]="copiedSlug() === bundle.slug"
|
||||||
|
i18n
|
||||||
|
>Copied!</span>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -141,16 +134,39 @@
|
|||||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||||
|
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<label class="small text-muted me-2" for="shareLinkBundlePageSize" i18n>Show:</label>
|
||||||
|
<select id="shareLinkBundlePageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||||
|
<option [ngValue]="25">25</option>
|
||||||
|
<option [ngValue]="50">50</option>
|
||||||
|
<option [ngValue]="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||||
|
</div>
|
||||||
|
@if (total() > pageSize) {
|
||||||
|
<ngb-pagination
|
||||||
|
class="mb-0"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[collectionSize]="total()"
|
||||||
|
[page]="page()"
|
||||||
|
[maxSize]="5"
|
||||||
|
(pageChange)="setPage($event)"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Share link bundles pagination"
|
||||||
|
i18n-aria-label
|
||||||
|
></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
|
||||||
</div>
|
|
||||||
+87
-35
@@ -1,6 +1,5 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { FileVersion } from 'src/app/data/share-link'
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
@@ -8,13 +7,15 @@ import {
|
|||||||
ShareLinkBundleStatus,
|
ShareLinkBundleStatus,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
} from 'src/app/data/share-link-bundle'
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
|
import { ShareLinkBundleListComponent } from './share-link-bundle-list.component'
|
||||||
|
|
||||||
class MockShareLinkBundleService {
|
class MockShareLinkBundleService {
|
||||||
listAllBundles = jest.fn()
|
list = jest.fn()
|
||||||
delete = jest.fn()
|
delete = jest.fn()
|
||||||
rebuildBundle = jest.fn()
|
rebuildBundle = jest.fn()
|
||||||
}
|
}
|
||||||
@@ -24,13 +25,12 @@ class MockToastService {
|
|||||||
showError = jest.fn()
|
showError = jest.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ShareLinkBundleManageDialogComponent', () => {
|
describe('ShareLinkBundleListComponent', () => {
|
||||||
let component: ShareLinkBundleManageDialogComponent
|
let component: ShareLinkBundleListComponent
|
||||||
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
let fixture: ComponentFixture<ShareLinkBundleListComponent>
|
||||||
let service: MockShareLinkBundleService
|
let service: MockShareLinkBundleService
|
||||||
let toastService: MockToastService
|
let toastService: MockToastService
|
||||||
let clipboard: Clipboard
|
let clipboard: Clipboard
|
||||||
let activeModal: NgbActiveModal
|
|
||||||
let originalApiBaseUrl: string
|
let originalApiBaseUrl: string
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,26 +38,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
toastService = new MockToastService()
|
toastService = new MockToastService()
|
||||||
originalApiBaseUrl = environment.apiBaseUrl
|
originalApiBaseUrl = environment.apiBaseUrl
|
||||||
|
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(of(true))
|
service.delete.mockReturnValue(of(true))
|
||||||
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ShareLinkBundleManageDialogComponent,
|
ShareLinkBundleListComponent,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
NgbActiveModal,
|
|
||||||
{ provide: ShareLinkBundleService, useValue: service },
|
{ provide: ShareLinkBundleService, useValue: service },
|
||||||
{ provide: ToastService, useValue: toastService },
|
{ provide: ToastService, useValue: toastService },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
fixture = TestBed.createComponent(ShareLinkBundleListComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
clipboard = TestBed.inject(Clipboard)
|
clipboard = TestBed.inject(Clipboard)
|
||||||
activeModal = TestBed.inject(NgbActiveModal)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -84,28 +82,28 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
it('loads bundles on init and polls periodically', () => {
|
it('loads bundles on init and polls periodically', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||||
service.listAllBundles.mockReset()
|
service.list.mockReset()
|
||||||
service.listAllBundles
|
service.list
|
||||||
.mockReturnValueOnce(of(bundles))
|
.mockReturnValueOnce(of({ count: bundles.length, results: bundles }))
|
||||||
.mockReturnValue(of(bundles))
|
.mockReturnValue(of({ count: bundles.length, results: bundles }))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||||
expect(component.bundles()).toEqual(bundles)
|
expect(component.bundles()).toEqual(bundles)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
expect(component.error()).toBeNull()
|
expect(component.error()).toBeNull()
|
||||||
|
|
||||||
jest.advanceTimersByTime(5000)
|
jest.advanceTimersByTime(5000)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles errors when loading bundles', () => {
|
it('handles errors when loading bundles', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
service.listAllBundles.mockReset()
|
service.list.mockReset()
|
||||||
service.listAllBundles
|
service.list
|
||||||
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||||
.mockReturnValue(of([]))
|
.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -114,7 +112,57 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
|
|
||||||
jest.advanceTimersByTime(5000)
|
jest.advanceTimersByTime(5000)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads another page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component.setPage(2)
|
||||||
|
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts bundles and returns to the first page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.onSort({ column: 'status', reverse: false })
|
||||||
|
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'status', false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks expired share link bundles', () => {
|
||||||
|
service.list.mockReturnValue(
|
||||||
|
of({
|
||||||
|
count: 1,
|
||||||
|
results: [sampleBundle({ expiration: '2000-01-01T00:00:00.000Z' })],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a changed page size and reloads from the first page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
const settingsService = TestBed.inject(SettingsService)
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockReturnValueOnce({ share_link_bundles: 25 })
|
||||||
|
const setSpy = jest.spyOn(settingsService, 'set')
|
||||||
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.pageSize = 100
|
||||||
|
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
share_link_bundles: 100,
|
||||||
|
})
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(1, 100, 'created', true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copies bundle links when ready', () => {
|
it('copies bundle links when ready', () => {
|
||||||
@@ -126,16 +174,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
slug: 'ready-slug',
|
slug: 'ready-slug',
|
||||||
status: ShareLinkBundleStatus.Ready,
|
status: ShareLinkBundleStatus.Ready,
|
||||||
})
|
})
|
||||||
|
component.bundles.set([readyBundle])
|
||||||
|
fixture.detectChanges()
|
||||||
component.copy(readyBundle)
|
component.copy(readyBundle)
|
||||||
|
|
||||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||||
component.getShareUrl(readyBundle)
|
component.getShareUrl(readyBundle)
|
||||||
)
|
)
|
||||||
expect(component.copiedSlug()).toBe('ready-slug')
|
expect(component.copiedSlug()).toBe('ready-slug')
|
||||||
expect(toastService.showInfo).toHaveBeenCalled()
|
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.badge.show').textContent
|
||||||
|
).toContain('Copied!')
|
||||||
|
|
||||||
jest.advanceTimersByTime(3000)
|
jest.advanceTimersByTime(3000)
|
||||||
expect(component.copiedSlug()).toBeNull()
|
expect(component.copiedSlug()).toBeNull()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores copy requests for non-ready bundles', () => {
|
it('ignores copy requests for non-ready bundles', () => {
|
||||||
@@ -146,7 +202,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('deletes bundles and refreshes list', () => {
|
it('deletes bundles and refreshes list', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(of(true))
|
service.delete.mockReturnValue(of(true))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -157,12 +213,12 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(toastService.showInfo).toHaveBeenCalledWith(
|
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('deleted.')
|
expect.stringContaining('deleted.')
|
||||||
)
|
)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles delete errors gracefully', () => {
|
it('handles delete errors gracefully', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -174,7 +230,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('retries bundle build and replaces existing entry', () => {
|
it('retries bundle build and replaces existing entry', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||||
service.rebuildBundle.mockReturnValue(of(updated))
|
service.rebuildBundle.mockReturnValue(of(updated))
|
||||||
|
|
||||||
@@ -189,7 +245,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('adds new bundle when retry returns unknown entry', () => {
|
it('adds new bundle when retry returns unknown entry', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.rebuildBundle.mockReturnValue(
|
service.rebuildBundle.mockReturnValue(
|
||||||
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||||
)
|
)
|
||||||
@@ -203,7 +259,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles retry errors', () => {
|
it('handles retry errors', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -213,8 +269,8 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(toastService.showError).toHaveBeenCalled()
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps helpers and closes dialog', () => {
|
it('maps status and file version helpers', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||||
@@ -227,9 +283,5 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
environment.apiBaseUrl = 'https://example.com/api/'
|
environment.apiBaseUrl = 'https://example.com/api/'
|
||||||
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||||
expect(url).toBe('https://example.com/share/sluggy')
|
expect(url).toBe('https://example.com/share/sluggy')
|
||||||
|
|
||||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
|
||||||
component.close()
|
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+75
-14
@@ -1,7 +1,11 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||||
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
NgbPaginationModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||||
import { FileVersion } from 'src/app/data/share-link'
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
@@ -11,42 +15,77 @@ import {
|
|||||||
ShareLinkBundleStatus,
|
ShareLinkBundleStatus,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
} from 'src/app/data/share-link-bundle'
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import {
|
||||||
|
SortEvent,
|
||||||
|
SortableDirective,
|
||||||
|
} from 'src/app/directives/sortable.directive'
|
||||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-share-link-bundle-manage-dialog',
|
selector: 'pngx-share-link-bundle-list',
|
||||||
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
templateUrl: './share-link-bundle-list.component.html',
|
||||||
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
styleUrls: ['./share-link-bundle-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgbPaginationModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
SortableDirective,
|
||||||
FileSizePipe,
|
FileSizePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ShareLinkBundleManageDialogComponent
|
export class ShareLinkBundleListComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
private readonly activeModal = inject(NgbActiveModal)
|
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
private readonly clipboard = inject(Clipboard)
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
|
||||||
title = $localize`Share link bundles`
|
|
||||||
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
||||||
readonly error = signal<string | null>(null)
|
readonly error = signal<string | null>(null)
|
||||||
readonly copiedSlug = signal<string | null>(null)
|
readonly copiedSlug = signal<string | null>(null)
|
||||||
|
readonly total = signal(0)
|
||||||
|
readonly page = signal(1)
|
||||||
|
readonly sortField = signal('created')
|
||||||
|
readonly sortReverse = signal(true)
|
||||||
|
|
||||||
readonly statuses = ShareLinkBundleStatus
|
readonly statuses = ShareLinkBundleStatus
|
||||||
readonly fileVersions = FileVersion
|
readonly fileVersions = FileVersion
|
||||||
|
|
||||||
|
get pageSize(): number {
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)
|
||||||
|
?.share_link_bundles || 25
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
set pageSize(pageSize: number) {
|
||||||
|
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||||
|
share_link_bundles: pageSize,
|
||||||
|
})
|
||||||
|
this.settingsService.storeSettings().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.page.set(1)
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError($localize`Error saving settings`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private readonly refresh$ = new Subject<boolean>()
|
private readonly refresh$ = new Subject<boolean>()
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -57,7 +96,14 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
}
|
}
|
||||||
this.error.set(null)
|
this.error.set(null)
|
||||||
return this.shareLinkBundleService.listAllBundles().pipe(
|
return this.shareLinkBundleService
|
||||||
|
.list(
|
||||||
|
this.page(),
|
||||||
|
this.pageSize,
|
||||||
|
this.sortField(),
|
||||||
|
this.sortReverse()
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
@@ -75,7 +121,8 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
)
|
)
|
||||||
.subscribe((results) => {
|
.subscribe((results) => {
|
||||||
if (results) {
|
if (results) {
|
||||||
this.bundles.set(results)
|
this.bundles.set(results.results)
|
||||||
|
this.total.set(results.count)
|
||||||
this.copiedSlug.set(null)
|
this.copiedSlug.set(null)
|
||||||
}
|
}
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
@@ -98,6 +145,18 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPage(page: number): void {
|
||||||
|
this.page.set(page)
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSort(event: SortEvent): void {
|
||||||
|
this.sortField.set(event.column || 'created')
|
||||||
|
this.sortReverse.set(event.column ? event.reverse : true)
|
||||||
|
this.page.set(1)
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
}
|
||||||
|
|
||||||
copy(bundle: ShareLinkBundleSummary): void {
|
copy(bundle: ShareLinkBundleSummary): void {
|
||||||
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||||
return
|
return
|
||||||
@@ -108,7 +167,6 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.copiedSlug.set(null)
|
this.copiedSlug.set(null)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +175,9 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.shareLinkBundleService.delete(bundle).subscribe({
|
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
if (this.bundles().length === 1 && this.page() > 1) {
|
||||||
|
this.page.update((page) => page - 1)
|
||||||
|
}
|
||||||
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||||
this.triggerRefresh(false)
|
this.triggerRefresh(false)
|
||||||
},
|
},
|
||||||
@@ -153,8 +214,8 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
isExpired(expiration?: string): boolean {
|
||||||
this.activeModal.close()
|
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
<div class="border border-top-0 rounded-bottom p-3">
|
||||||
|
@if (!loading() && error()) {
|
||||||
|
<div class="alert alert-danger mb-0" role="alert">{{ error() }}</div>
|
||||||
|
}
|
||||||
|
@if (!loading() && !error() && links().length === 0) {
|
||||||
|
<p class="mb-0 text-muted fst-italic" i18n>
|
||||||
|
No document share links currently exist.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (!loading() && !error() && links().length > 0) {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0 bg-body">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="fw-normal" pngxSortable="document__title" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Document</th>
|
||||||
|
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||||
|
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||||
|
<th scope="col" i18n>File version</th>
|
||||||
|
<th scope="col" class="text-end" i18n>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (link of links(); track link.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a routerLink="/documents/{{ link.document }}">{{ link.document_title | documentTitle }}</a>
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyDocumentID(link.document)">
|
||||||
|
@if (copiedDocumentID() === link.document) {
|
||||||
|
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||||
|
} @else {
|
||||||
|
ID: {{link.document}}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ link.created | date: 'short' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (link.expiration) {
|
||||||
|
{{ link.expiration | date: 'short' }}
|
||||||
|
@if (isExpired(link.expiration)) {
|
||||||
|
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span i18n>Never</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ fileVersionLabel(link.file_version) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="d-inline-block position-relative">
|
||||||
|
<span
|
||||||
|
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||||
|
[class.show]="copiedID() === link.id"
|
||||||
|
i18n
|
||||||
|
>Copied!</span>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
(click)="copy(link)"
|
||||||
|
title="Copy share link"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
@if (copiedID() === link.id) {
|
||||||
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="clipboard"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy share link</span>
|
||||||
|
</button>
|
||||||
|
<pngx-confirm-button
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ShareLink }"
|
||||||
|
buttonClasses="btn btn-sm btn-outline-danger"
|
||||||
|
(confirm)="delete(link)"
|
||||||
|
iconName="trash"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden" i18n>Delete share link</span>
|
||||||
|
</pngx-confirm-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<label class="small text-muted me-2" for="shareLinkPageSize" i18n>Show:</label>
|
||||||
|
<select id="shareLinkPageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||||
|
<option [ngValue]="25">25</option>
|
||||||
|
<option [ngValue]="50">50</option>
|
||||||
|
<option [ngValue]="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||||
|
</div>
|
||||||
|
@if (total() > pageSize) {
|
||||||
|
<ngb-pagination
|
||||||
|
class="mb-0"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[collectionSize]="total()"
|
||||||
|
[page]="page()"
|
||||||
|
[maxSize]="5"
|
||||||
|
(pageChange)="setPage($event)"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Share links pagination"
|
||||||
|
i18n-aria-label
|
||||||
|
></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { ShareLinkListComponent } from './share-link-list.component'
|
||||||
|
|
||||||
|
describe('ShareLinkListComponent', () => {
|
||||||
|
let component: ShareLinkListComponent
|
||||||
|
let fixture: ComponentFixture<ShareLinkListComponent>
|
||||||
|
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
|
||||||
|
let clipboard: Clipboard
|
||||||
|
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
|
||||||
|
|
||||||
|
const link = {
|
||||||
|
id: 1,
|
||||||
|
document: 42,
|
||||||
|
document_title: 'Test document',
|
||||||
|
slug: 'share-slug',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
expiration: null,
|
||||||
|
file_version: FileVersion.Archive,
|
||||||
|
} as ShareLink
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = {
|
||||||
|
list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
|
||||||
|
delete: jest.fn().mockReturnValue(of(true)),
|
||||||
|
}
|
||||||
|
toastService = {
|
||||||
|
showInfo: jest.fn(),
|
||||||
|
showError: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
ShareLinkListComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
RouterTestingModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: ShareLinkService, useValue: service },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinkListComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
clipboard = TestBed.inject(Clipboard)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads and renders document share links', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||||
|
expect(component.links()).toEqual([link])
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Test document')
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('ID: 42')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads another page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.setPage(2)
|
||||||
|
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts links and returns to the first page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.onSort({ column: 'expiration', reverse: false })
|
||||||
|
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'expiration', false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks expired share links', () => {
|
||||||
|
service.list.mockReturnValue(
|
||||||
|
of({
|
||||||
|
count: 1,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
...link,
|
||||||
|
expiration: '2000-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a changed page size and reloads from the first page', () => {
|
||||||
|
const settingsService = TestBed.inject(SettingsService)
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValueOnce({ share_links: 25 })
|
||||||
|
const setSpy = jest.spyOn(settingsService, 'set')
|
||||||
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.pageSize = 50
|
||||||
|
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
share_links: 50,
|
||||||
|
})
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows local copy feedback without a toast', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component.copy(link)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.copiedID()).toBe(link.id)
|
||||||
|
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
|
||||||
|
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(3000)
|
||||||
|
expect(component.copiedID()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a link and reloads the list', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.delete(link)
|
||||||
|
|
||||||
|
expect(service.delete).toHaveBeenCalledWith(link)
|
||||||
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error when loading fails', () => {
|
||||||
|
service.list.mockReturnValue(throwError(() => new Error('load failed')))
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.error()).toContain('Failed to load share links.')
|
||||||
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { RouterModule } from '@angular/router'
|
||||||
|
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { takeUntil } from 'rxjs'
|
||||||
|
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||||
|
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||||
|
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||||
|
import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
|
import {
|
||||||
|
SortEvent,
|
||||||
|
SortableDirective,
|
||||||
|
} from 'src/app/directives/sortable.directive'
|
||||||
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-share-link-list',
|
||||||
|
templateUrl: './share-link-list.component.html',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
DocumentTitlePipe,
|
||||||
|
FormsModule,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
NgbPaginationModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
RouterModule,
|
||||||
|
SortableDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ShareLinkListComponent
|
||||||
|
extends LoadingComponentWithPermissions
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
private readonly shareLinkService = inject(ShareLinkService)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
|
readonly links = signal<ShareLink[]>([])
|
||||||
|
readonly total = signal(0)
|
||||||
|
readonly page = signal(1)
|
||||||
|
readonly sortField = signal('created')
|
||||||
|
readonly sortReverse = signal(true)
|
||||||
|
readonly copiedID = signal<number | null>(null)
|
||||||
|
readonly copiedDocumentID = signal<number | null>(null)
|
||||||
|
readonly error = signal<string | null>(null)
|
||||||
|
readonly PermissionAction = PermissionAction
|
||||||
|
readonly PermissionType = PermissionType
|
||||||
|
|
||||||
|
get pageSize(): number {
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)?.share_links ||
|
||||||
|
25
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
set pageSize(pageSize: number) {
|
||||||
|
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||||
|
share_links: pageSize,
|
||||||
|
})
|
||||||
|
this.settingsService.storeSettings().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.page.set(1)
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError($localize`Error saving settings`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
this.loading.set(true)
|
||||||
|
this.error.set(null)
|
||||||
|
this.shareLinkService
|
||||||
|
.list(this.page(), this.pageSize, this.sortField(), this.sortReverse())
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (results) => {
|
||||||
|
this.links.set(results.results)
|
||||||
|
this.total.set(results.count)
|
||||||
|
this.loading.set(false)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.loading.set(false)
|
||||||
|
this.error.set($localize`Failed to load share links.`)
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error retrieving share links.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(page: number): void {
|
||||||
|
this.page.set(page)
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
onSort(event: SortEvent): void {
|
||||||
|
this.sortField.set(event.column || 'created')
|
||||||
|
this.sortReverse.set(event.column ? event.reverse : true)
|
||||||
|
this.page.set(1)
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
getShareUrl(link: ShareLink): string {
|
||||||
|
const apiURL = new URL(environment.apiBaseUrl)
|
||||||
|
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||||
|
link.slug
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
fileVersionLabel(version: FileVersion): string {
|
||||||
|
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpired(expiration?: string): boolean {
|
||||||
|
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(link: ShareLink): void {
|
||||||
|
if (this.clipboard.copy(this.getShareUrl(link))) {
|
||||||
|
this.copiedID.set(link.id)
|
||||||
|
setTimeout(() => this.copiedID.set(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(link: ShareLink): void {
|
||||||
|
this.shareLinkService.delete(link).subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (this.links().length === 1 && this.page() > 1) {
|
||||||
|
this.page.update((page) => page - 1)
|
||||||
|
}
|
||||||
|
this.toastService.showInfo($localize`Share link deleted.`)
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error deleting share link.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDocumentID(documentID: number): void {
|
||||||
|
if (this.clipboard.copy(documentID.toString())) {
|
||||||
|
this.copiedDocumentID.set(documentID)
|
||||||
|
setTimeout(() => this.copiedDocumentID.set(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="Share links"
|
||||||
|
i18n-title
|
||||||
|
info="Manage public links to individual documents and document bundles."
|
||||||
|
i18n-info
|
||||||
|
[loading]="loading()"
|
||||||
|
></pngx-page-header>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
ngbNav
|
||||||
|
#nav="ngbNav"
|
||||||
|
class="nav-tabs"
|
||||||
|
[activeId]="activeNavID()"
|
||||||
|
(activeIdChange)="selectTab($event)"
|
||||||
|
>
|
||||||
|
@if (canViewDocumentLinks) {
|
||||||
|
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
|
||||||
|
<button ngbNavLink i18n>Document links</button>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<pngx-share-link-list></pngx-share-link-list>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (canViewBundles) {
|
||||||
|
<li [ngbNavItem]="ShareLinksNavIDs.Bundles">
|
||||||
|
<button ngbNavLink i18n>Bundles</button>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<pngx-share-link-bundle-list></pngx-share-link-bundle-list>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="bg-body" [ngbNavOutlet]="nav"></div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
|
||||||
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
|
||||||
|
|
||||||
|
describe('ShareLinksComponent', () => {
|
||||||
|
let fixture: ComponentFixture<ShareLinksComponent>
|
||||||
|
let permissionsService: PermissionsService
|
||||||
|
let router: Router
|
||||||
|
|
||||||
|
const configure = async (type: string = null) => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
ShareLinksComponent,
|
||||||
|
NgbNavModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
PageHeaderComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
PermissionsService,
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
snapshot: { queryParamMap: convertToParamMap({ type }) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: Router,
|
||||||
|
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ShareLinkBundleService,
|
||||||
|
useValue: {
|
||||||
|
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||||
|
rebuildBundle: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ShareLinkService,
|
||||||
|
useValue: {
|
||||||
|
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: { showInfo: jest.fn(), showError: jest.fn() },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => TestBed.resetTestingModule())
|
||||||
|
|
||||||
|
it('uses the requested bundles tab when permitted', async () => {
|
||||||
|
await configure(ShareLinksNavIDs.Bundles)
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation(
|
||||||
|
(action, type) =>
|
||||||
|
action === PermissionAction.View &&
|
||||||
|
type === PermissionType.ShareLinkBundle
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.activeNavID()).toBe(
|
||||||
|
ShareLinksNavIDs.Bundles
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Document links')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the URL when a tab is selected', async () => {
|
||||||
|
await configure()
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||||
|
fixture.componentInstance.selectTab(ShareLinksNavIDs.Bundles)
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||||
|
relativeTo: TestBed.inject(ActivatedRoute),
|
||||||
|
queryParams: { type: ShareLinksNavIDs.Bundles },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Component, computed, inject, signal, viewChild } from '@angular/core'
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
|
||||||
|
import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
|
||||||
|
|
||||||
|
export enum ShareLinksNavIDs {
|
||||||
|
DocumentLinks = 'documents',
|
||||||
|
Bundles = 'bundles',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-share-links',
|
||||||
|
templateUrl: './share-links.component.html',
|
||||||
|
imports: [
|
||||||
|
NgbNavModule,
|
||||||
|
PageHeaderComponent,
|
||||||
|
ShareLinkBundleListComponent,
|
||||||
|
ShareLinkListComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ShareLinksComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
|
readonly ShareLinksNavIDs = ShareLinksNavIDs
|
||||||
|
readonly activeNavID = signal(this.getInitialNavID())
|
||||||
|
private readonly documentLinks = viewChild(ShareLinkListComponent)
|
||||||
|
private readonly bundles = viewChild(ShareLinkBundleListComponent)
|
||||||
|
readonly loading = computed(() => {
|
||||||
|
const activeList =
|
||||||
|
this.activeNavID() === ShareLinksNavIDs.DocumentLinks
|
||||||
|
? this.documentLinks()
|
||||||
|
: this.bundles()
|
||||||
|
return activeList?.loading() ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
get canViewDocumentLinks(): boolean {
|
||||||
|
return this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get canViewBundles(): boolean {
|
||||||
|
return this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLinkBundle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTab(tab: ShareLinksNavIDs): void {
|
||||||
|
this.activeNavID.set(tab)
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { type: tab },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitialNavID(): ShareLinksNavIDs {
|
||||||
|
const requestedTab = this.route.snapshot.queryParamMap.get('type')
|
||||||
|
if (requestedTab === ShareLinksNavIDs.Bundles && this.canViewBundles) {
|
||||||
|
return ShareLinksNavIDs.Bundles
|
||||||
|
}
|
||||||
|
if (this.canViewDocumentLinks) {
|
||||||
|
return ShareLinksNavIDs.DocumentLinks
|
||||||
|
}
|
||||||
|
return ShareLinksNavIDs.Bundles
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,7 @@ export interface Document extends ObjectWithPermissions {
|
|||||||
|
|
||||||
// Frontend only
|
// Frontend only
|
||||||
__changedFields?: string[]
|
__changedFields?: string[]
|
||||||
|
__selectedVersionId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentVersionInfo {
|
export interface DocumentVersionInfo {
|
||||||
|
|||||||
@@ -353,6 +353,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL',
|
||||||
category: ConfigCategory.AI,
|
category: ConfigCategory.AI,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'llm_embedding_api_key',
|
||||||
|
title: $localize`LLM Embedding API Key`,
|
||||||
|
type: ConfigOptionType.Password,
|
||||||
|
note: $localize`Used for embeddings when set, otherwise LLM API key is used.`,
|
||||||
|
config_key: 'PAPERLESS_AI_LLM_EMBEDDING_API_KEY',
|
||||||
|
category: ConfigCategory.AI,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'llm_embedding_endpoint',
|
key: 'llm_embedding_endpoint',
|
||||||
title: $localize`LLM Embedding Endpoint`,
|
title: $localize`LLM Embedding Endpoint`,
|
||||||
@@ -457,6 +465,7 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
|
llm_embedding_api_key: string
|
||||||
llm_embedding_endpoint: string
|
llm_embedding_endpoint: string
|
||||||
llm_embedding_chunk_size: number
|
llm_embedding_chunk_size: number
|
||||||
llm_context_size: number
|
llm_context_size: number
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum PaperlessTaskType {
|
|||||||
ReprocessDocument = 'reprocess_document',
|
ReprocessDocument = 'reprocess_document',
|
||||||
BuildShareLink = 'build_share_link',
|
BuildShareLink = 'build_share_link',
|
||||||
BulkDelete = 'bulk_delete',
|
BulkDelete = 'bulk_delete',
|
||||||
|
ApplyAiSuggestions = 'apply_ai_suggestions',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PaperlessTaskTriggerSource {
|
export enum PaperlessTaskTriggerSource {
|
||||||
|
|||||||
@@ -26,5 +26,7 @@ export interface ShareLink extends ObjectWithPermissions {
|
|||||||
|
|
||||||
document: number // Document
|
document: number // Document
|
||||||
|
|
||||||
|
document_title?: string
|
||||||
|
|
||||||
file_version: string
|
file_version: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export enum CollapsibleSection {
|
|||||||
ATTRIBUTES = 'attributes',
|
ATTRIBUTES = 'attributes',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum HideableSidebarItemID {
|
||||||
|
Dashboard = 'dashboard',
|
||||||
|
SavedViews = 'saved_views',
|
||||||
|
ShareLinks = 'share_links',
|
||||||
|
Workflows = 'workflows',
|
||||||
|
Mail = 'mail',
|
||||||
|
Documentation = 'documentation',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
|
||||||
|
|
||||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
@@ -56,6 +67,7 @@ export const SETTINGS_KEYS = {
|
|||||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||||
|
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
||||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||||
'general-settings:attributes-sections-collapsed',
|
'general-settings:attributes-sections-collapsed',
|
||||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||||
@@ -127,6 +139,11 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||||
|
type: 'array',
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -230,6 +247,8 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
document_types: 25,
|
document_types: 25,
|
||||||
tags: 25,
|
tags: 25,
|
||||||
storage_paths: 25,
|
storage_paths: 25,
|
||||||
|
share_links: 25,
|
||||||
|
share_link_bundles: 25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should clear all-selected mode when toggling a single document', () => {
|
it('should exclude a toggled document while keeping all-selected mode', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
@@ -592,8 +592,73 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
|
|
||||||
expect(documentListViewService.allSelected).toBeFalsy()
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
|
expect(documentListViewService.excluded).toEqual(new Set([documents[0].id]))
|
||||||
|
expect(documentListViewService.selectedCount).toEqual(documents.length - 1)
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
|
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
|
||||||
|
|
||||||
|
documentListViewService.toggleSelected(documents[0])
|
||||||
|
|
||||||
|
expect(documentListViewService.excluded.size).toEqual(0)
|
||||||
|
expect(documentListViewService.selectedCount).toEqual(documents.length)
|
||||||
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve exclusions across pages', () => {
|
||||||
|
documentListViewService.pageSize = 3
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
req.flush({ count: documents.length, results: documents.slice(0, 3) })
|
||||||
|
|
||||||
|
documentListViewService.selectAll()
|
||||||
|
documentListViewService.toggleSelected(documents[0])
|
||||||
|
documentListViewService.currentPage = 2
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
req.flush({ count: documents.length, results: documents.slice(3, 6) })
|
||||||
|
|
||||||
|
expect(documentListViewService.excluded).toEqual(new Set([documents[0].id]))
|
||||||
|
expect(documentListViewService.selectedCount).toEqual(documents.length - 1)
|
||||||
|
expect(documentListViewService.selected).toEqual(
|
||||||
|
new Set(documents.slice(3, 6).map((document) => document.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
documentListViewService.currentPage = 1
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
req.flush({ count: documents.length, results: documents.slice(0, 3) })
|
||||||
|
|
||||||
|
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
|
||||||
|
expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear exclusions when filters change', () => {
|
||||||
|
documentListViewService.reload()
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
req.flush(full_results)
|
||||||
|
documentListViewService.selectAll()
|
||||||
|
documentListViewService.toggleSelected(documents[0])
|
||||||
|
|
||||||
|
documentListViewService.setFilterRules(filterRules)
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
||||||
|
)
|
||||||
|
req.flush({ count: 3, results: documents.slice(0, 3) })
|
||||||
|
|
||||||
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
|
expect(documentListViewService.excluded.size).toEqual(0)
|
||||||
|
expect(documentListViewService.selectedCount).toEqual(3)
|
||||||
|
|
||||||
|
documentListViewService.setFilterRules([])
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
req.flush(full_results)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should clear all-selected mode when selecting a range', () => {
|
it('should clear all-selected mode when selecting a range', () => {
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export interface ListViewState {
|
|||||||
*/
|
*/
|
||||||
allSelected?: boolean
|
allSelected?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document IDs excluded from the full filtered result set.
|
||||||
|
*/
|
||||||
|
excluded?: Set<number>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page size of the list view.
|
* The page size of the list view.
|
||||||
*/
|
*/
|
||||||
@@ -215,6 +220,7 @@ export class DocumentListViewService {
|
|||||||
filterRules: [],
|
filterRules: [],
|
||||||
selected: new Set<number>(),
|
selected: new Set<number>(),
|
||||||
allSelected: false,
|
allSelected: false,
|
||||||
|
excluded: new Set<number>(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +230,9 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
this.documents?.forEach((doc) => this.selected.add(doc.id))
|
this.documents
|
||||||
|
?.filter((doc) => !this.excluded.has(doc.id))
|
||||||
|
.forEach((doc) => this.selected.add(doc.id))
|
||||||
|
|
||||||
if (!this.collectionSize) {
|
if (!this.collectionSize) {
|
||||||
this.selectNone()
|
this.selectNone()
|
||||||
@@ -491,14 +499,23 @@ export class DocumentListViewService {
|
|||||||
return this.activeListViewState.allSelected ?? false
|
return this.activeListViewState.allSelected ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get excluded(): Set<number> {
|
||||||
|
this.trackState()
|
||||||
|
if (!this.activeListViewState.excluded) {
|
||||||
|
this.activeListViewState.excluded = new Set<number>()
|
||||||
|
}
|
||||||
|
return this.activeListViewState.excluded
|
||||||
|
}
|
||||||
|
|
||||||
get selectedCount(): number {
|
get selectedCount(): number {
|
||||||
return this.allSelected
|
if (!this.allSelected || this.collectionSize == null) {
|
||||||
? (this.collectionSize ?? this.selected.size)
|
return this.selected.size
|
||||||
: this.selected.size
|
}
|
||||||
|
return Math.max(0, this.collectionSize - this.excluded.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasSelection(): boolean {
|
get hasSelection(): boolean {
|
||||||
return this.allSelected || this.selected.size > 0
|
return this.selectedCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
setSort(field: string, reverse: boolean) {
|
setSort(field: string, reverse: boolean) {
|
||||||
@@ -663,12 +680,14 @@ export class DocumentListViewService {
|
|||||||
selectNone() {
|
selectNone() {
|
||||||
this.activeListViewState.allSelected = false
|
this.activeListViewState.allSelected = false
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
|
this.excluded.clear()
|
||||||
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
|
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
|
||||||
this.markChanged()
|
this.markChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
reduceSelectionToFilter() {
|
reduceSelectionToFilter() {
|
||||||
if (this.allSelected) {
|
if (this.allSelected) {
|
||||||
|
this.excluded.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,6 +707,7 @@ export class DocumentListViewService {
|
|||||||
|
|
||||||
selectAll() {
|
selectAll() {
|
||||||
this.activeListViewState.allSelected = true
|
this.activeListViewState.allSelected = true
|
||||||
|
this.excluded.clear()
|
||||||
this.syncSelectedToCurrentPage()
|
this.syncSelectedToCurrentPage()
|
||||||
this.markChanged()
|
this.markChanged()
|
||||||
}
|
}
|
||||||
@@ -695,6 +715,7 @@ export class DocumentListViewService {
|
|||||||
selectPage() {
|
selectPage() {
|
||||||
this.activeListViewState.allSelected = false
|
this.activeListViewState.allSelected = false
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
|
this.excluded.clear()
|
||||||
this.documents.forEach((doc) => {
|
this.documents.forEach((doc) => {
|
||||||
this.selected.add(doc.id)
|
this.selected.add(doc.id)
|
||||||
})
|
})
|
||||||
@@ -702,15 +723,23 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSelected(d: Document) {
|
isSelected(d: Document) {
|
||||||
return this.allSelected || this.selected.has(d.id)
|
return this.allSelected ? !this.excluded.has(d.id) : this.selected.has(d.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(d: Document): void {
|
toggleSelected(d: Document): void {
|
||||||
if (this.allSelected) {
|
if (this.allSelected) {
|
||||||
this.activeListViewState.allSelected = false
|
if (this.excluded.has(d.id)) {
|
||||||
|
this.excluded.delete(d.id)
|
||||||
|
this.selected.add(d.id)
|
||||||
|
} else {
|
||||||
|
this.excluded.add(d.id)
|
||||||
|
this.selected.delete(d.id)
|
||||||
|
}
|
||||||
|
} else if (this.selected.has(d.id)) {
|
||||||
|
this.selected.delete(d.id)
|
||||||
|
} else {
|
||||||
|
this.selected.add(d.id)
|
||||||
}
|
}
|
||||||
if (this.selected.has(d.id)) this.selected.delete(d.id)
|
|
||||||
else this.selected.add(d.id)
|
|
||||||
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
|
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
|
||||||
this.lastRangeSelectionToIndex = null
|
this.lastRangeSelectionToIndex = null
|
||||||
this.markChanged()
|
this.markChanged()
|
||||||
@@ -719,6 +748,7 @@ export class DocumentListViewService {
|
|||||||
selectRangeTo(d: Document) {
|
selectRangeTo(d: Document) {
|
||||||
if (this.allSelected) {
|
if (this.allSelected) {
|
||||||
this.activeListViewState.allSelected = false
|
this.activeListViewState.allSelected = false
|
||||||
|
this.excluded.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.rangeSelectionAnchorIndex !== null) {
|
if (this.rangeSelectionAnchorIndex !== null) {
|
||||||
|
|||||||
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
|
|||||||
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should refresh documents in place and keep unsaved edits', () => {
|
||||||
|
const openDoc = { ...documents[0] }
|
||||||
|
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
|
||||||
|
openDoc.title = 'Unsaved title'
|
||||||
|
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
|
||||||
|
|
||||||
|
openDocumentsService.refreshDocument(openDoc.id)
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
|
||||||
|
)
|
||||||
|
.flush({ ...documents[0], tags: [4] })
|
||||||
|
|
||||||
|
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
|
||||||
|
expect(refreshed).toBe(openDoc)
|
||||||
|
expect(refreshed.title).toEqual('Unsaved title')
|
||||||
|
expect(refreshed.tags).toEqual([4])
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle error on refresh documents', () => {
|
it('should handle error on refresh documents', () => {
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
openDocumentsService.openDocument(documents[1]).subscribe()
|
openDocumentsService.openDocument(documents[1]).subscribe()
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
this.documentService.get(id).subscribe({
|
this.documentService.get(id).subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
this.openDocuments[index] = doc
|
const openDoc = this.openDocuments.find((d) => d.id == id)
|
||||||
|
if (!openDoc) return
|
||||||
|
const unsavedEdits = Object.fromEntries(
|
||||||
|
(openDoc.__changedFields ?? []).map((field) => [
|
||||||
|
field,
|
||||||
|
openDoc[field],
|
||||||
|
])
|
||||||
|
)
|
||||||
|
Object.assign(openDoc, doc, unsavedEdits)
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe(`DocumentService`, () => {
|
|||||||
|
|
||||||
it('should call appropriate api endpoint for getting selection data', () => {
|
it('should call appropriate api endpoint for getting selection data', () => {
|
||||||
const ids = [documents[0].id]
|
const ids = [documents[0].id]
|
||||||
subscription = service.getSelectionData(ids).subscribe()
|
subscription = service.getSelectionData({ documents: ids }).subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}${endpoint}/selection_data/`
|
`${environment.apiBaseUrl}${endpoint}/selection_data/`
|
||||||
)
|
)
|
||||||
@@ -185,6 +185,20 @@ describe(`DocumentService`, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should get selection data with all, filters, and exclusions', () => {
|
||||||
|
const selection = {
|
||||||
|
all: true,
|
||||||
|
filters: { title__icontains: 'apple' },
|
||||||
|
excluded_documents: [2, 3],
|
||||||
|
}
|
||||||
|
subscription = service.getSelectionData(selection).subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}${endpoint}/selection_data/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('POST')
|
||||||
|
expect(req.request.body).toEqual(selection)
|
||||||
|
})
|
||||||
|
|
||||||
it('should call appropriate api endpoint for getting suggestions', () => {
|
it('should call appropriate api endpoint for getting suggestions', () => {
|
||||||
subscription = service.getSuggestions(documents[0].id).subscribe()
|
subscription = service.getSuggestions(documents[0].id).subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
@@ -240,7 +254,7 @@ describe(`DocumentService`, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
|
it('should call appropriate api endpoint for bulk edit with all, filters, and exclusions', () => {
|
||||||
const method = 'modify_tags'
|
const method = 'modify_tags'
|
||||||
const parameters = {
|
const parameters = {
|
||||||
add_tags: [15],
|
add_tags: [15],
|
||||||
@@ -249,6 +263,7 @@ describe(`DocumentService`, () => {
|
|||||||
const selection = {
|
const selection = {
|
||||||
all: true,
|
all: true,
|
||||||
filters: { title__icontains: 'apple' },
|
filters: { title__icontains: 'apple' },
|
||||||
|
excluded_documents: [2, 3],
|
||||||
}
|
}
|
||||||
subscription = service.bulkEdit(selection, method, parameters).subscribe()
|
subscription = service.bulkEdit(selection, method, parameters).subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
@@ -258,6 +273,7 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
all: true,
|
all: true,
|
||||||
filters: { title__icontains: 'apple' },
|
filters: { title__icontains: 'apple' },
|
||||||
|
excluded_documents: [2, 3],
|
||||||
method,
|
method,
|
||||||
parameters,
|
parameters,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface DocumentSelectionQuery {
|
|||||||
documents?: number[]
|
documents?: number[]
|
||||||
all?: boolean
|
all?: boolean
|
||||||
filters?: { [key: string]: any }
|
filters?: { [key: string]: any }
|
||||||
|
excluded_documents?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -407,10 +408,12 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectionData(ids: number[]): Observable<SelectionData> {
|
getSelectionData(
|
||||||
|
selection: DocumentSelectionQuery
|
||||||
|
): Observable<SelectionData> {
|
||||||
return this.http.post<SelectionData>(
|
return this.http.post<SelectionData>(
|
||||||
this.getResourceUrl(null, 'selection_data'),
|
this.getResourceUrl(null, 'selection_data'),
|
||||||
{ documents: ids }
|
selection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,13 +48,4 @@ describe('ShareLinkBundleService', () => {
|
|||||||
expect(req.request.body).toEqual({})
|
expect(req.request.body).toEqual({})
|
||||||
req.flush({})
|
req.flush({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lists bundles with expected parameters', () => {
|
|
||||||
subscription = service.listAllBundles().subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toBe('GET')
|
|
||||||
req.flush({ results: [] })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { map } from 'rxjs/operators'
|
|
||||||
import {
|
import {
|
||||||
ShareLinkBundleCreatePayload,
|
ShareLinkBundleCreatePayload,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
@@ -32,10 +31,4 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
|
|||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
|
|
||||||
return this.list(1, 1000, 'created', true).pipe(
|
|
||||||
map((response) => response.results)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import { CustomFieldDataType } from '../data/custom-field'
|
|||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
import {
|
||||||
|
HideableSidebarItemID,
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
UiSettings,
|
||||||
|
} from '../data/ui-settings'
|
||||||
import { PermissionsService } from './permissions.service'
|
import { PermissionsService } from './permissions.service'
|
||||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
@@ -230,6 +234,35 @@ describe('SettingsService', () => {
|
|||||||
expect(notesEnabled()).toBeFalsy()
|
expect(notesEnabled()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updates sidebar item visibility', () => {
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
|
.flush(ui_settings)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||||
|
).toBe(false)
|
||||||
|
|
||||||
|
settingsService.updateSidebarItemVisibility(
|
||||||
|
HideableSidebarItemID.Workflows,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||||
|
).toBe(true)
|
||||||
|
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
|
||||||
|
|
||||||
|
settingsService.updateSidebarItemVisibility(
|
||||||
|
HideableSidebarItemID.Workflows,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('updates setting signals when settings are reinitialized', () => {
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
|||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import {
|
import {
|
||||||
|
HideableSidebarItemID,
|
||||||
PAPERLESS_GREEN_HEX,
|
PAPERLESS_GREEN_HEX,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
@@ -313,6 +314,18 @@ export class SettingsService {
|
|||||||
readonly globalDropzoneEnabled = signal(true)
|
readonly globalDropzoneEnabled = signal(true)
|
||||||
readonly globalDropzoneActive = signal(false)
|
readonly globalDropzoneActive = signal(false)
|
||||||
readonly organizingSidebarSavedViews = signal(false)
|
readonly organizingSidebarSavedViews = signal(false)
|
||||||
|
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
readonly organizingSidebarItems = computed(
|
||||||
|
() => this.sidebarHiddenItemsEditing() !== null
|
||||||
|
)
|
||||||
|
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
|
||||||
|
HideableSidebarItemID[]
|
||||||
|
>()
|
||||||
|
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
|
||||||
|
)
|
||||||
|
|
||||||
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
||||||
DEFAULT_DISPLAY_FIELDS
|
DEFAULT_DISPLAY_FIELDS
|
||||||
@@ -749,6 +762,29 @@ export class SettingsService {
|
|||||||
return this.storeSettings()
|
return this.storeSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
|
||||||
|
return (
|
||||||
|
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
||||||
|
).includes(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSidebarItemVisibility(
|
||||||
|
item: HideableSidebarItemID,
|
||||||
|
visible: boolean
|
||||||
|
): void {
|
||||||
|
const hiddenItems = new Set(
|
||||||
|
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
||||||
|
)
|
||||||
|
if (visible) {
|
||||||
|
hiddenItems.delete(item)
|
||||||
|
} else {
|
||||||
|
hiddenItems.add(item)
|
||||||
|
}
|
||||||
|
const updatedHiddenItems = [...hiddenItems]
|
||||||
|
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
|
||||||
|
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
|
||||||
|
}
|
||||||
|
|
||||||
updateSavedViewsVisibility(
|
updateSavedViewsVisibility(
|
||||||
dashboardVisibleViewIds: number[],
|
dashboardVisibleViewIds: number[],
|
||||||
sidebarVisibleViewIds: number[]
|
sidebarVisibleViewIds: number[]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const environment = {
|
|||||||
apiVersion: '10', // match src/paperless/settings.py
|
apiVersion: '10', // match src/paperless/settings.py
|
||||||
appTitle: DEFAULT_APP_TITLE,
|
appTitle: DEFAULT_APP_TITLE,
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '3.1.3',
|
version: '3.2.1',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1206
-802
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1263
-859
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
+1202
-798
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user