Compare commits

..
Author SHA1 Message Date
Trenton Holmes aad897f16a Updates mypy and pyrefly and their baseline files 2026-09-07 20:21:46 -07:00
289 changed files with 72665 additions and 104618 deletions
+1
View File
@@ -81,6 +81,7 @@ 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"
+7 -5
View File
@@ -12,9 +12,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
DEFAULT_UV_VERSION: "0.12.x" DEFAULT_UV_VERSION: "0.12.x"
# Match the Docker image: nltk refuses to read hardlinked data files, such as NLTK_DATA: "/usr/share/nltk_data"
# the copy bundled with llama-index when uv links packages from its cache
UV_LINK_MODE: copy
permissions: {} permissions: {}
jobs: jobs:
changes: changes:
@@ -102,7 +100,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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -127,8 +125,12 @@ 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: |
@@ -176,7 +178,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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+2 -2
View File
@@ -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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
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 }}
+6 -6
View File
@@ -81,7 +81,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
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
+3 -3
View File
@@ -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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
if: always() if: always()
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+2 -2
View File
@@ -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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+1 -1
View File
@@ -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@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2 uses: crowdin/github-action@e4a6c1338b4063c77d46a81875265f9e8bd76f95 # v3.0.0
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
'You are welcome to open a new issue that describes the problem you observed in your own words.' 'You are welcome to open a new issue that describes the problem you observed in your own words.'
: 'This issue was automatically closed because it was not opened using our bug report form. ' + : 'This issue was automatically closed because it was not opened using our bug report form. ' +
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' + 'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` + `If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n' +
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' + 'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`; `contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
+2 -23
View File
@@ -25,10 +25,6 @@ jobs:
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
# instead of racing it. Still runs if that job fails, so labeling is not lost.
needs: Anti-slop
if: ${{ !cancelled() }}
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
@@ -103,25 +99,8 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
const user = context.payload.pull_request.user.login; const pr = context.payload.pull_request;
const user = pr.user.login;
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
if (pr.state === 'closed') {
core.info('Skipping comment: PR is already closed');
return;
}
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
if (labels.includes('ai')) {
core.info('Skipping comment: PR is labeled ai');
return;
}
const { data: members } = await github.rest.orgs.listMembers({ const { data: members } = await github.rest.orgs.listMembers({
org: 'paperless-ngx', org: 'paperless-ngx',
+2 -2
View File
@@ -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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
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
+758 -221
View File
File diff suppressed because it is too large Load Diff
+4780 -4396
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -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.16-python3.14-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.12.9-python3.14-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -199,6 +199,10 @@ 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 \
+2 -2
View File
@@ -4,7 +4,7 @@
# correct networking for the tests # correct networking for the tests
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.37 image: docker.io/gotenberg/gotenberg:8.36
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.6-alpine image: docker.io/nginx:1.31.5-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.37 image: docker.io/gotenberg/gotenberg:8.36
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.37 image: docker.io/gotenberg/gotenberg:8.36
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.37 image: docker.io/gotenberg/gotenberg:8.36
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.
@@ -1,58 +0,0 @@
#!/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"
@@ -1 +0,0 @@
oneshot
@@ -1 +0,0 @@
/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run
+1 -2
View File
@@ -521,8 +521,7 @@ 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, schema fingerprint and search language all match). Safe to run on every version and search language match). Safe to run on every startup or upgrade.
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.
+16 -28
View File
@@ -413,12 +413,18 @@ details.
Defaults to `PAPERLESS_DATA_DIR/log/`. Defaults to `PAPERLESS_DATA_DIR/log/`.
#### ~~[`PAPERLESS_NLTK_DIR`](#PAPERLESS_NLTK_DIR)~~ {#PAPERLESS_NLTK_DIR} #### [`PAPERLESS_NLTK_DIR=<path>`](#PAPERLESS_NLTK_DIR) {#PAPERLESS_NLTK_DIR}
!!! failure "Removed in v3.2" : This is where paperless will search for the data required for NLTK
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.
Removed and ignored. Any previously downloaded NLTK data folder can be Previously, the location defaulted to `PAPERLESS_DATA_DIR/nltk`.
deleted. Unless you are using this in a bare metal install or other setup,
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}
@@ -1184,31 +1190,15 @@ for details on how to set it.
Defaults to UTC. Defaults to UTC.
#### ~~[`PAPERLESS_ENABLE_NLTK`](#PAPERLESS_ENABLE_NLTK)~~ {#PAPERLESS_ENABLE_NLTK} #### [`PAPERLESS_ENABLE_NLTK=<bool>`](#PAPERLESS_ENABLE_NLTK) {#PAPERLESS_ENABLE_NLTK}
!!! failure "Removed in v3.2" : Enables or disables the advanced natural language processing
used during automatic classification. If disabled, paperless will
still perform some basic text pre-processing before matching.
Removed and ignored. Automatic classification always removes stop words : See also `PAPERLESS_NLTK_DIR`.
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.
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD} Defaults to true, enabling the feature.
: 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}
@@ -1279,8 +1269,6 @@ 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}
+8
View File
@@ -430,6 +430,11 @@ 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).
@@ -645,6 +650,9 @@ 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).
+38 -85
View File
@@ -927,105 +927,52 @@ 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.
#### Combining terms Matching documents with logical expressions:
``` ```
shopname AND (product1 OR product2) shopname AND (product1 OR product2)
invoice NOT draft
"quick brown fox"
``` ```
- `AND`, `OR` and `NOT` must be written in capitals. Parentheses group terms. Matching specific tags, correspondents or types:
- 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:"acme corp" correspondent:university certificate
tag:bills,unpaid
asn:[50 to 150]
checksum:9f86d081*
``` ```
| Field | Searches | Matching dates:
| ------------------------- | ---------------------------------------- |
| `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
```
added:yesterday
modified:"previous month"
created:[2005 to 2009] created:[2005 to 2009]
added:[-1 week to now] added:yesterday
modified:today
``` ```
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`. Matching inexact words:
Other supported forms: ```
produ*name
```
| Example | Matches | Quotes | Matching natural date keywords:
| ------------------------------------------------------- | ------------------------------ | -------- |
| `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"
```
!!! warning Supported date keywords: `today`, `yesterday`, `previous week`,
`this month`, `previous month`, `this year`, `previous year`,
`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. `previous quarter`.
#### Searching custom fields #### Searching custom fields
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: Custom field names and values are included in the full-text index, but they
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
@@ -1036,9 +983,10 @@ 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, 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`: Because separators are stripped during indexing, individual parts of formatted
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
@@ -1047,11 +995,14 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
!!! note !!! note
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. Custom date fields do not support relative date syntax (e.g. `[now to 2 weeks]`).
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 a plain search without a field name does not look at them. Use the advanced search syntax to search by note author or content: Notes are included in the full-text index, but they are not searched by a
plain, unqualified query. Use the advanced search syntax to search by note
author or content:
``` ```
notes.user:alice notes.user:alice
@@ -1059,13 +1010,15 @@ notes.note:reminder
notes.user:alice notes.note:insurance notes.user:alice notes.note:insurance
``` ```
The bare `notes:` prefix is shorthand for `notes.note:`. All of these constructs can be combined as you see fit. If you want to
learn more about the query language used by paperless, see the
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. [Tantivy query language documentation](https://docs.rs/tantivy/latest/tantivy/query/struct.QueryParser.html).
!!! note !!! note
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. Fuzzy (approximate) matching can be enabled by setting
[`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
+6 -6
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.2.0" version = "3.1.3"
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"
@@ -39,7 +39,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.10", "drf-spectacular-sidecar>=2026.7.1,<2026.9",
"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,6 +55,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>=17.7,<17.12", "ocrmypdf>=17.7,<17.12",
"openai>=2.48", "openai>=2.48",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
@@ -73,10 +74,9 @@ 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,<2.15", "torch~=2.13.0",
"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 +109,7 @@ lint = [
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker>=40.36,<40.39", "faker>=40.36,<40.38",
"imagehash", "imagehash",
"pytest~=9.1.1", "pytest~=9.1.1",
"pytest-cov~=7.1.0", "pytest-cov~=7.1.0",
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
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,reprot" ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
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\
+549 -813
View File
File diff suppressed because it is too large Load Diff
+28 -28
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.2.0", "version": "3.1.3",
"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.6", "@angular/cdk": "^22.1.4",
"@angular/common": "~22.1.6", "@angular/common": "~22.1.3",
"@angular/compiler": "~22.1.6", "@angular/compiler": "~22.1.3",
"@angular/core": "~22.1.6", "@angular/core": "~22.1.3",
"@angular/forms": "~22.1.6", "@angular/forms": "~22.1.3",
"@angular/localize": "~22.1.6", "@angular/localize": "~22.1.3",
"@angular/platform-browser": "~22.1.6", "@angular/platform-browser": "~22.1.3",
"@angular/router": "~22.1.6", "@angular/router": "~22.1.3",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~24.1.1", "@ng-select/ng-select": "~24.0.2",
"@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.3.289", "pdfjs-dist": "^6.2.108",
"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.8", "@angular-devkit/core": "^22.1.6",
"@angular-devkit/schematics": "^22.1.8", "@angular-devkit/schematics": "^22.1.6",
"@angular-eslint/builder": "22.5.0", "@angular-eslint/builder": "22.1.0",
"@angular-eslint/eslint-plugin": "22.5.0", "@angular-eslint/eslint-plugin": "22.1.0",
"@angular-eslint/eslint-plugin-template": "22.5.0", "@angular-eslint/eslint-plugin-template": "22.1.0",
"@angular-eslint/schematics": "22.5.0", "@angular-eslint/schematics": "22.1.0",
"@angular-eslint/template-parser": "22.5.0", "@angular-eslint/template-parser": "22.1.0",
"@angular/build": "22.1.8", "@angular/build": "22.1.6",
"@angular/cli": "22.1.8", "@angular/cli": "22.1.6",
"@angular/compiler-cli": "~22.1.6", "@angular/compiler-cli": "~22.1.3",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.5.0", "@types/node": "^26.4.0",
"@typescript-eslint/eslint-plugin": "^8.70.0", "@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.70.0", "@typescript-eslint/parser": "^8.68.0",
"@typescript-eslint/utils": "^8.70.0", "@typescript-eslint/utils": "^8.68.0",
"eslint": "^10.10.0", "eslint": "^10.9.1",
"jest": "30.5.1", "jest": "30.4.2",
"jest-environment-jsdom": "^30.5.1", "jest-environment-jsdom": "^30.4.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",
+1053 -1302
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -14,7 +14,6 @@ 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'
@@ -311,24 +310,6 @@ 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',
},
},
], ],
}, },
@@ -23,30 +23,17 @@
<div class="col"> <div class="col">
<div class="card bg-light"> <div class="card bg-light">
<div class="card-body"> <div class="card-body">
<div class="card-title d-flex align-items-center flex-wrap"> <div class="card-title d-flex align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
{{option.title}} {{option.title}}
</h6> </h6>
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer"> <a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
<i-bs name="info-circle"></i-bs> <i-bs name="info-circle"></i-bs>
</a> </a>
@if (isExternallyConfigured(option.config_key)) {
@if (isSet(option.key)) {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
} @else {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
}
}
@if (isSet(option.key)) { @if (isSet(option.key)) {
@if (isExternallyConfigured(option.config_key)) { <button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)"> <i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container> </button>
</button>
} @else {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
} }
</div> </div>
<div class="mb-n3"> <div class="mb-n3">
@@ -163,19 +163,6 @@ describe('ConfigComponent', () => {
expect(component.configForm.get('barcodes_enabled').value).toBeNull() expect(component.configForm.get('barcodes_enabled').value).toBeNull()
}) })
it('should identify externally configured options', () => {
component.externallyConfiguredVariables = new Set([
'PAPERLESS_OCR_LANGUAGE',
])
expect(
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
).toBeTruthy()
expect(
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
).toBeFalsy()
})
it('should group options into sections within a category, or not', () => { it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR) const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR]) expect(sections).toEqual([null, ConfigSection.RemoteOCR])
@@ -69,7 +69,6 @@ export class ConfigComponent
public configForm = new FormGroup({}) public configForm = new FormGroup({})
public errors = {} public errors = {}
public externallyConfiguredVariables = new Set<string>()
get optionCategories(): string[] { get optionCategories(): string[] {
return Object.values(ConfigCategory) return Object.values(ConfigCategory)
@@ -153,9 +152,6 @@ export class ConfigComponent
} }
private initialize(config: PaperlessConfig) { private initialize(config: PaperlessConfig) {
this.externallyConfiguredVariables = new Set(
config.externally_configured_variables ?? []
)
if (!this.store) { if (!this.store) {
this.store = new BehaviorSubject(config) this.store = new BehaviorSubject(config)
@@ -166,9 +162,7 @@ export class ConfigComponent
this.configForm.patchValue(state, { emitEvent: false }) this.configForm.patchValue(state, { emitEvent: false })
}) })
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), { this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
excludeKeys: ['externally_configured_variables'],
})
} }
this.configForm.patchValue(config) this.configForm.patchValue(config)
@@ -233,10 +227,6 @@ export class ConfigComponent
return this.configForm.get(key).value != null return this.configForm.get(key).value != null
} }
public isExternallyConfigured(configKey: string): boolean {
return this.externallyConfiguredVariables.has(configKey)
}
public resetOption(key: string) { public resetOption(key: string) {
this.configForm.get(key).setValue(null) this.configForm.get(key).setValue(null)
} }
@@ -112,22 +112,6 @@
<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 { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings' import { 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,45 +209,6 @@ 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')
@@ -288,7 +249,6 @@ 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')
@@ -307,10 +267,7 @@ 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(34) expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
// succeed // succeed
storeSpy.mockReturnValueOnce(of(true)) storeSpy.mockReturnValueOnce(of(true))
@@ -39,12 +39,7 @@ import {
SystemStatus, SystemStatus,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
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'
@@ -107,15 +102,6 @@ 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',
@@ -163,7 +149,6 @@ 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),
@@ -201,7 +186,6 @@ 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()
@@ -219,10 +203,6 @@ 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()
@@ -250,10 +230,6 @@ 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()
@@ -303,21 +279,14 @@ 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) {
navID = SettingsNavIDs[navIDKey] this.activeNavID.set(SettingsNavIDs[navIDKey])
} }
} }
this.activeNavID.set(navID)
this.settings.sidebarHiddenItemsEditing.set(
navID === SettingsNavIDs.General
? [...this.settingsForm.controls.sidebarHiddenItems.value]
: null
)
}) })
} }
@@ -341,7 +310,6 @@ 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(
@@ -468,12 +436,6 @@ 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)
@@ -482,18 +444,8 @@ 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() {
@@ -521,10 +473,6 @@ 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
@@ -684,11 +632,6 @@ 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,10 +99,6 @@ 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,15 +86,12 @@
} }
<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 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()"> <li class="nav-item app-link">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" 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"
@@ -240,50 +237,29 @@
</div> </div>
</li> </li>
} }
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" 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>
@if (canManageShareLinks) { <li class="nav-item app-link"
<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" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" 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 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.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" <a class="nav-link" 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"
@@ -346,16 +322,13 @@
</a> </a>
</li> </li>
} }
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro"> <li class="nav-item mt-2" tourAnchor="tour.outro">
<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()" <a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
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 { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings' import { 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,87 +287,6 @@ 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,7 +7,6 @@ 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,
@@ -22,11 +21,7 @@ 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 { import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
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'
@@ -53,7 +48,6 @@ 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'
@@ -82,8 +76,6 @@ const SCROLL_THRESHOLD = 16
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
DragDropModule, DragDropModule,
TourNgBootstrap, TourNgBootstrap,
FormsModule,
SwitchComponent,
], ],
}) })
export class AppFrameComponent export class AppFrameComponent
@@ -106,7 +98,6 @@ 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
) )
@@ -204,10 +195,6 @@ 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()
@@ -234,19 +221,6 @@ 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]="!compact"> <div class="mb-3">
<div [class.row]="!compact"> <div class="row">
@if (!horizontal && !compact) { @if (!horizontal) {
<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" [attr.aria-label]="compact ? title : null"> <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (horizontal && !compact) { @if (horizontal) {
<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,14 +48,4 @@ 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,9 +25,6 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
@Input() @Input()
showUnsetNote: boolean = false showUnsetNote: boolean = false
@Input()
compact: boolean = false
constructor() { constructor() {
super() super()
} }
@@ -1,22 +1,38 @@
<div class="border border-top-0 rounded-bottom p-3"> <div class="modal-header">
<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 bg-body"> <table class="table table-sm align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th> <th scope="col" i18n>Created</th>
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th> <th scope="col" i18n>Status</th>
<th scope="col" i18n>Size</th> <th scope="col" i18n>Size</th>
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th> <th scope="col" 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>
@@ -80,9 +96,6 @@
<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>
@@ -91,49 +104,42 @@
<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"> <div class="btn-group btn-group-sm">
<span <button
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap" type="button"
[class.show]="copiedSlug() === bundle.slug" class="btn btn-outline-primary"
i18n [disabled]="bundle.status !== statuses.Ready"
>Copied!</span> (click)="copy(bundle)"
<div class="btn-group btn-group-sm"> title="Copy share link"
i18n-title
>
@if (copiedSlug() === bundle.slug) {
<i-bs name="clipboard-check"></i-bs>
}
@if (copiedSlug() !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
@if (bundle.status === statuses.Failed) {
<button <button
type="button" type="button"
class="btn btn-outline-primary" class="btn btn-outline-warning"
[disabled]="bundle.status !== statuses.Ready"
(click)="copy(bundle)"
title="Copy share link"
i18n-title
>
@if (copiedSlug() === bundle.slug) {
<i-bs name="clipboard-check"></i-bs>
}
@if (copiedSlug() !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
@if (bundle.status === statuses.Failed) {
<button
type="button"
class="btn btn-outline-warning"
[disabled]="loading()"
(click)="retry(bundle)"
>
<i-bs name="arrow-clockwise"></i-bs>
<span class="visually-hidden" i18n>Retry</span>
</button>
}
<pngx-confirm-button
buttonClasses="btn btn-sm btn-outline-danger"
[disabled]="loading()" [disabled]="loading()"
(confirm)="delete(bundle)" (click)="retry(bundle)"
iconName="trash"
> >
<span class="visually-hidden" i18n>Delete share link bundle</span> <i-bs name="arrow-clockwise"></i-bs>
</pngx-confirm-button> <span class="visually-hidden" i18n>Retry</span>
</div> </button>
}
<pngx-confirm-button
buttonClasses="btn btn-sm btn-outline-danger"
[disabled]="loading()"
(confirm)="delete(bundle)"
iconName="trash"
>
<span class="visually-hidden" i18n>Delete share link bundle</span>
</pngx-confirm-button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -141,32 +147,10 @@
</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>
@@ -1,5 +1,6 @@
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'
@@ -7,15 +8,13 @@ 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 { ShareLinkBundleListComponent } from './share-link-bundle-list.component' import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
class MockShareLinkBundleService { class MockShareLinkBundleService {
list = jest.fn() listAllBundles = jest.fn()
delete = jest.fn() delete = jest.fn()
rebuildBundle = jest.fn() rebuildBundle = jest.fn()
} }
@@ -25,12 +24,13 @@ class MockToastService {
showError = jest.fn() showError = jest.fn()
} }
describe('ShareLinkBundleListComponent', () => { describe('ShareLinkBundleManageDialogComponent', () => {
let component: ShareLinkBundleListComponent let component: ShareLinkBundleManageDialogComponent
let fixture: ComponentFixture<ShareLinkBundleListComponent> let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
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,24 +38,26 @@ describe('ShareLinkBundleListComponent', () => {
toastService = new MockToastService() toastService = new MockToastService()
originalApiBaseUrl = environment.apiBaseUrl originalApiBaseUrl = environment.apiBaseUrl
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
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: [
ShareLinkBundleListComponent, ShareLinkBundleManageDialogComponent,
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(ShareLinkBundleListComponent) fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
component = fixture.componentInstance component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
activeModal = TestBed.inject(NgbActiveModal)
}) })
afterEach(() => { afterEach(() => {
@@ -82,28 +84,28 @@ describe('ShareLinkBundleListComponent', () => {
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.list.mockReset() service.listAllBundles.mockReset()
service.list service.listAllBundles
.mockReturnValueOnce(of({ count: bundles.length, results: bundles })) .mockReturnValueOnce(of(bundles))
.mockReturnValue(of({ count: bundles.length, results: bundles })) .mockReturnValue(of(bundles))
fixture.detectChanges() fixture.detectChanges()
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true) expect(service.listAllBundles).toHaveBeenCalledTimes(1)
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.list).toHaveBeenCalledTimes(2) expect(service.listAllBundles).toHaveBeenCalledTimes(2)
}) })
it('handles errors when loading bundles', () => { it('handles errors when loading bundles', () => {
jest.useFakeTimers() jest.useFakeTimers()
service.list.mockReset() service.listAllBundles.mockReset()
service.list service.listAllBundles
.mockReturnValueOnce(throwError(() => new Error('load fail'))) .mockReturnValueOnce(throwError(() => new Error('load fail')))
.mockReturnValue(of({ count: 0, results: [] })) .mockReturnValue(of([]))
fixture.detectChanges() fixture.detectChanges()
@@ -112,57 +114,7 @@ describe('ShareLinkBundleListComponent', () => {
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
jest.advanceTimersByTime(5000) jest.advanceTimersByTime(5000)
expect(service.list).toHaveBeenCalledTimes(2) expect(service.listAllBundles).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', () => {
@@ -174,24 +126,16 @@ describe('ShareLinkBundleListComponent', () => {
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).not.toHaveBeenCalled() expect(toastService.showInfo).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', () => {
@@ -202,7 +146,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('deletes bundles and refreshes list', () => { it('deletes bundles and refreshes list', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(of(true)) service.delete.mockReturnValue(of(true))
fixture.detectChanges() fixture.detectChanges()
@@ -213,12 +157,12 @@ describe('ShareLinkBundleListComponent', () => {
expect(toastService.showInfo).toHaveBeenCalledWith( expect(toastService.showInfo).toHaveBeenCalledWith(
expect.stringContaining('deleted.') expect.stringContaining('deleted.')
) )
expect(service.list).toHaveBeenCalledTimes(2) expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
}) })
it('handles delete errors gracefully', () => { it('handles delete errors gracefully', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(throwError(() => new Error('delete fail'))) service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
fixture.detectChanges() fixture.detectChanges()
@@ -230,7 +174,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('retries bundle build and replaces existing entry', () => { it('retries bundle build and replaces existing entry', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready }) const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
service.rebuildBundle.mockReturnValue(of(updated)) service.rebuildBundle.mockReturnValue(of(updated))
@@ -245,7 +189,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('adds new bundle when retry returns unknown entry', () => { it('adds new bundle when retry returns unknown entry', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue( service.rebuildBundle.mockReturnValue(
of(sampleBundle({ id: 99, slug: 'new-slug' })) of(sampleBundle({ id: 99, slug: 'new-slug' }))
) )
@@ -259,7 +203,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('handles retry errors', () => { it('handles retry errors', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail'))) service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
fixture.detectChanges() fixture.detectChanges()
@@ -269,8 +213,8 @@ describe('ShareLinkBundleListComponent', () => {
expect(toastService.showError).toHaveBeenCalled() expect(toastService.showError).toHaveBeenCalled()
}) })
it('maps status and file version helpers', () => { it('maps helpers and closes dialog', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
fixture.detectChanges() fixture.detectChanges()
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
@@ -283,5 +227,9 @@ describe('ShareLinkBundleListComponent', () => {
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()
}) })
}) })
@@ -1,11 +1,7 @@
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 { FormsModule } from '@angular/forms' import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
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'
@@ -15,77 +11,42 @@ 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 { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component' import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
@Component({ @Component({
selector: 'pngx-share-link-bundle-list', selector: 'pngx-share-link-bundle-manage-dialog',
templateUrl: './share-link-bundle-list.component.html', templateUrl: './share-link-bundle-manage-dialog.component.html',
styleUrls: ['./share-link-bundle-list.component.scss'], styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
imports: [ imports: [
ConfirmButtonComponent, ConfirmButtonComponent,
CommonModule, CommonModule,
FormsModule,
NgbPaginationModule,
NgbPopoverModule, NgbPopoverModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
SortableDirective,
FileSizePipe, FileSizePipe,
], ],
}) })
export class ShareLinkBundleListComponent export class ShareLinkBundleManageDialogComponent
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 {
@@ -96,33 +57,25 @@ export class ShareLinkBundleListComponent
this.loading.set(true) this.loading.set(true)
} }
this.error.set(null) this.error.set(null)
return this.shareLinkBundleService return this.shareLinkBundleService.listAllBundles().pipe(
.list( catchError((error) => {
this.page(), if (!silent) {
this.pageSize, this.loading.set(false)
this.sortField(), }
this.sortReverse() this.error.set($localize`Failed to load share link bundles.`)
) this.toastService.showError(
.pipe( $localize`Error retrieving share link bundles.`,
catchError((error) => { error
if (!silent) { )
this.loading.set(false) return of(null)
} })
this.error.set($localize`Failed to load share link bundles.`) )
this.toastService.showError(
$localize`Error retrieving share link bundles.`,
error
)
return of(null)
})
)
}), }),
takeUntil(this.unsubscribeNotifier) takeUntil(this.unsubscribeNotifier)
) )
.subscribe((results) => { .subscribe((results) => {
if (results) { if (results) {
this.bundles.set(results.results) this.bundles.set(results)
this.total.set(results.count)
this.copiedSlug.set(null) this.copiedSlug.set(null)
} }
this.loading.set(false) this.loading.set(false)
@@ -145,18 +98,6 @@ export class ShareLinkBundleListComponent
}` }`
} }
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
@@ -167,6 +108,7 @@ export class ShareLinkBundleListComponent
setTimeout(() => { setTimeout(() => {
this.copiedSlug.set(null) this.copiedSlug.set(null)
}, 3000) }, 3000)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
} }
} }
@@ -175,9 +117,6 @@ export class ShareLinkBundleListComponent
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)
}, },
@@ -214,8 +153,8 @@ export class ShareLinkBundleListComponent
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
} }
isExpired(expiration?: string): boolean { close(): void {
return !!expiration && Date.parse(expiration) <= Date.now() this.activeModal.close()
} }
private replaceBundle(updated: ShareLinkBundleSummary): void { private replaceBundle(updated: ShareLinkBundleSummary): void {
@@ -28,9 +28,8 @@ 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, DocumentVersionInfo } from 'src/app/data/document' import { Document } 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,
@@ -101,18 +100,13 @@ 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,
@@ -2051,208 +2045,6 @@ 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 { 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 { 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 { 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,7 +304,6 @@ 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
@@ -418,8 +417,7 @@ 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) => {
@@ -535,8 +533,7 @@ 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()),
@@ -598,13 +595,6 @@ 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)
@@ -652,14 +642,7 @@ 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)
}
}, },
}) })
} }
@@ -920,11 +903,9 @@ export class DocumentDetailComponent
updateComponent(doc: Document) { updateComponent(doc: Document) {
this.document.set(doc) this.document.set(doc)
// Load the selected version, or default to API first (newest) // Default selected version is the newest version, which the API returns first
const versions = doc.versions ?? [] const versions = doc.versions ?? []
const selectedVersion = this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
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()
@@ -959,12 +940,8 @@ 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, keepContentEdits: boolean = false) { selectVersion(versionId: number) {
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(
@@ -986,20 +963,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 ?? ''
if (keepContentEdits) { this.document().content = content
this.store.next({ ...this.store.value, content }) this.documentForm.patchValue(
} else { {
// Update in-place and avoid the debounce wait content,
this.store.value.content = content },
this.documentForm.patchValue({ content }) {
this.documentForm.get('content').markAsPristine() emitEvent: false,
} }
)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
@@ -1014,8 +991,7 @@ 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()),
@@ -1029,39 +1005,7 @@ export class DocumentDetailComponent
} }
onVersionSelected(versionId: number) { onVersionSelected(versionId: number) {
if (versionId === this.selectedVersionId()) return this.selectVersion(versionId)
// 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)
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[]) {
@@ -1289,7 +1233,7 @@ export class DocumentDetailComponent
return changes return changes
} }
save(close: boolean = false, savedCallback: () => void = null) { save(close: boolean = false) {
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
@@ -1322,7 +1266,6 @@ 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)
@@ -7,9 +7,8 @@ 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 { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } 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'
@@ -47,6 +46,7 @@ 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,7 +82,6 @@ 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({
@@ -168,14 +167,11 @@ 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(),
], ],
@@ -193,7 +189,6 @@ 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
@@ -392,42 +387,6 @@ 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()
@@ -496,47 +455,6 @@ 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
@@ -578,19 +496,16 @@ 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])) .mockReturnValue(new Set([3, 4]))
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(24) .mockReturnValue(25)
jest jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions') .spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true) .mockReturnValue(true)
@@ -609,7 +524,6 @@ 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: [] },
}) })
@@ -1910,9 +1824,9 @@ describe('BulkEditorComponent', () => {
}, },
} }
const openSpy = jest const openSpy = jest.spyOn(modalService, 'open')
.spyOn(modalService, 'open') openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
.mockReturnValueOnce(modalRef as NgbModalRef) openSpy.mockReturnValueOnce({} as NgbModalRef)
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce( ;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
of({ id: 42 }) of({ id: 42 })
) )
@@ -1946,9 +1860,11 @@ describe('BulkEditorComponent', () => {
dialogInstance.onOpenManage() dialogInstance.onOpenManage()
expect(modalRef.close).toHaveBeenCalled() expect(modalRef.close).toHaveBeenCalled()
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], { expect(openSpy).toHaveBeenNthCalledWith(
queryParams: { type: 'bundles' }, 2,
}) ShareLinkBundleManageDialogComponent,
expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
openSpy.mockRestore() openSpy.mockRestore()
}) })
@@ -2001,10 +1917,13 @@ describe('BulkEditorComponent', () => {
openSpy.mockRestore() openSpy.mockRestore()
}) })
it('should navigate to share link bundle management', () => { it('should open share link bundle management dialog', () => {
const openSpy = jest.spyOn(modalService, 'open')
component.manageShareLinkBundles() component.manageShareLinkBundles()
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], { expect(openSpy).toHaveBeenCalledWith(
queryParams: { type: 'bundles' }, ShareLinkBundleManageDialogComponent,
}) expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
openSpy.mockRestore()
}) })
}) })
@@ -12,7 +12,6 @@ import {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { Router } from '@angular/router'
import { import {
NgbDropdownModule, NgbDropdownModule,
NgbModal, NgbModal,
@@ -70,6 +69,7 @@ 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,7 +104,6 @@ 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()
@@ -361,7 +360,6 @@ 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),
} }
} }
@@ -375,8 +373,7 @@ export class BulkEditorComponent
} }
openTagsDropdown() { openTagsDropdown() {
// If none excluded, use the selection data already available in the list view, otherwise fetch if (this.list.allSelected) {
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)
@@ -384,7 +381,7 @@ export class BulkEditorComponent
} }
this.documentService this.documentService
.getSelectionData(this.getSelectionQuery()) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.tagDocumentCounts.set(s.selected_tags) this.tagDocumentCounts.set(s.selected_tags)
@@ -393,7 +390,7 @@ export class BulkEditorComponent
} }
openDocumentTypeDropdown() { openDocumentTypeDropdown() {
if (this.list.allSelected && this.list.excluded.size === 0) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.documentTypeDocumentCounts.set( this.documentTypeDocumentCounts.set(
selectionData?.selected_document_types ?? [] selectionData?.selected_document_types ?? []
@@ -406,7 +403,7 @@ export class BulkEditorComponent
} }
this.documentService this.documentService
.getSelectionData(this.getSelectionQuery()) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.documentTypeDocumentCounts.set(s.selected_document_types) this.documentTypeDocumentCounts.set(s.selected_document_types)
@@ -418,7 +415,7 @@ export class BulkEditorComponent
} }
openCorrespondentDropdown() { openCorrespondentDropdown() {
if (this.list.allSelected && this.list.excluded.size === 0) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.correspondentDocumentCounts.set( this.correspondentDocumentCounts.set(
selectionData?.selected_correspondents ?? [] selectionData?.selected_correspondents ?? []
@@ -431,7 +428,7 @@ export class BulkEditorComponent
} }
this.documentService this.documentService
.getSelectionData(this.getSelectionQuery()) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.correspondentDocumentCounts.set(s.selected_correspondents) this.correspondentDocumentCounts.set(s.selected_correspondents)
@@ -443,7 +440,7 @@ export class BulkEditorComponent
} }
openStoragePathDropdown() { openStoragePathDropdown() {
if (this.list.allSelected && this.list.excluded.size === 0) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.storagePathDocumentCounts.set( this.storagePathDocumentCounts.set(
selectionData?.selected_storage_paths ?? [] selectionData?.selected_storage_paths ?? []
@@ -456,7 +453,7 @@ export class BulkEditorComponent
} }
this.documentService this.documentService
.getSelectionData(this.getSelectionQuery()) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.storagePathDocumentCounts.set(s.selected_storage_paths) this.storagePathDocumentCounts.set(s.selected_storage_paths)
@@ -468,7 +465,7 @@ export class BulkEditorComponent
} }
openCustomFieldsDropdown() { openCustomFieldsDropdown() {
if (this.list.allSelected && this.list.excluded.size === 0) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.customFieldDocumentCounts.set( this.customFieldDocumentCounts.set(
selectionData?.selected_custom_fields ?? [] selectionData?.selected_custom_fields ?? []
@@ -481,7 +478,7 @@ export class BulkEditorComponent
} }
this.documentService this.documentService
.getSelectionData(this.getSelectionQuery()) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.customFieldDocumentCounts.set(s.selected_custom_fields) this.customFieldDocumentCounts.set(s.selected_custom_fields)
@@ -1138,8 +1135,9 @@ export class BulkEditorComponent
} }
manageShareLinkBundles() { manageShareLinkBundles() {
void this.router.navigate(['/share-links'], { this.modalService.open(ShareLinkBundleManageDialogComponent, {
queryParams: { type: 'bundles' }, backdrop: 'static',
size: 'lg',
}) })
} }
@@ -1423,16 +1423,11 @@ 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', () => {
clickTextFilterTarget('Duplicates') const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[5]
textFieldTargetDropdown.triggerEventHandler('click')
fixture.detectChanges() fixture.detectChanges()
expect(component.textFilterTarget).toEqual('duplicates') expect(component.textFilterTarget).toEqual('duplicates')
@@ -1458,7 +1453,10 @@ 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'))
clickTextFilterTarget('Advanced search') const textFieldTargetDropdown = fixture.debugElement.queryAll(
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')
@@ -1927,7 +1925,10 @@ 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'))
clickTextFilterTarget('Advanced search') const textFieldTargetDropdown = fixture.debugElement.queryAll(
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 = {
@@ -1,110 +0,0 @@
<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>
@@ -1,155 +0,0 @@
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()
})
})
@@ -1,172 +0,0 @@
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)
}
}
}
@@ -1,34 +0,0 @@
<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>
@@ -1,102 +0,0 @@
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',
})
})
})
@@ -1,78 +0,0 @@
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
}
}
-1
View File
@@ -167,7 +167,6 @@ export interface Document extends ObjectWithPermissions {
// Frontend only // Frontend only
__changedFields?: string[] __changedFields?: string[]
__selectedVersionId?: number
} }
export interface DocumentVersionInfo { export interface DocumentVersionInfo {
-1
View File
@@ -422,7 +422,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
] ]
export interface PaperlessConfig extends ObjectWithId { export interface PaperlessConfig extends ObjectWithId {
externally_configured_variables: string[]
output_type: OutputTypeConfig output_type: OutputTypeConfig
pages: number pages: number
language: string language: string
-1
View File
@@ -12,7 +12,6 @@ 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 {
-2
View File
@@ -26,7 +26,5 @@ export interface ShareLink extends ObjectWithPermissions {
document: number // Document document: number // Document
document_title?: string
file_version: string file_version: string
} }
-19
View File
@@ -24,17 +24,6 @@ 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 = {
@@ -67,7 +56,6 @@ 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',
@@ -139,11 +127,6 @@ 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',
@@ -247,8 +230,6 @@ 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 exclude a toggled document while keeping all-selected mode', () => { it('should clear all-selected mode when toggling a single document', () => {
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,73 +592,8 @@ describe('DocumentListViewService', () => {
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.allSelected).toBeTruthy() expect(documentListViewService.allSelected).toBeFalsy()
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,11 +85,6 @@ 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.
*/ */
@@ -220,7 +215,6 @@ export class DocumentListViewService {
filterRules: [], filterRules: [],
selected: new Set<number>(), selected: new Set<number>(),
allSelected: false, allSelected: false,
excluded: new Set<number>(),
} }
} }
@@ -230,9 +224,7 @@ export class DocumentListViewService {
} }
this.selected.clear() this.selected.clear()
this.documents this.documents?.forEach((doc) => this.selected.add(doc.id))
?.filter((doc) => !this.excluded.has(doc.id))
.forEach((doc) => this.selected.add(doc.id))
if (!this.collectionSize) { if (!this.collectionSize) {
this.selectNone() this.selectNone()
@@ -499,23 +491,14 @@ 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 {
if (!this.allSelected || this.collectionSize == null) { return this.allSelected
return this.selected.size ? (this.collectionSize ?? this.selected.size)
} : this.selected.size
return Math.max(0, this.collectionSize - this.excluded.size)
} }
get hasSelection(): boolean { get hasSelection(): boolean {
return this.selectedCount > 0 return this.allSelected || this.selected.size > 0
} }
setSort(field: string, reverse: boolean) { setSort(field: string, reverse: boolean) {
@@ -680,14 +663,12 @@ 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
} }
@@ -707,7 +688,6 @@ export class DocumentListViewService {
selectAll() { selectAll() {
this.activeListViewState.allSelected = true this.activeListViewState.allSelected = true
this.excluded.clear()
this.syncSelectedToCurrentPage() this.syncSelectedToCurrentPage()
this.markChanged() this.markChanged()
} }
@@ -715,7 +695,6 @@ 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)
}) })
@@ -723,23 +702,15 @@ export class DocumentListViewService {
} }
isSelected(d: Document) { isSelected(d: Document) {
return this.allSelected ? !this.excluded.has(d.id) : this.selected.has(d.id) return this.allSelected || this.selected.has(d.id)
} }
toggleSelected(d: Document): void { toggleSelected(d: Document): void {
if (this.allSelected) { if (this.allSelected) {
if (this.excluded.has(d.id)) { this.activeListViewState.allSelected = false
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()
@@ -748,7 +719,6 @@ 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,25 +221,6 @@ 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,15 +50,7 @@ export class OpenDocumentsService {
if (index > -1) { if (index > -1) {
this.documentService.get(id).subscribe({ this.documentService.get(id).subscribe({
next: (doc) => { next: (doc) => {
const openDoc = this.openDocuments.find((d) => d.id == id) this.openDocuments[index] = doc
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({ documents: ids }).subscribe() subscription = service.getSelectionData(ids).subscribe()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/selection_data/` `${environment.apiBaseUrl}${endpoint}/selection_data/`
) )
@@ -185,20 +185,6 @@ 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(
@@ -254,7 +240,7 @@ describe(`DocumentService`, () => {
}) })
}) })
it('should call appropriate api endpoint for bulk edit with all, filters, and exclusions', () => { it('should call appropriate api endpoint for bulk edit with all and filters', () => {
const method = 'modify_tags' const method = 'modify_tags'
const parameters = { const parameters = {
add_tags: [15], add_tags: [15],
@@ -263,7 +249,6 @@ 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(
@@ -273,7 +258,6 @@ 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,7 +72,6 @@ export interface DocumentSelectionQuery {
documents?: number[] documents?: number[]
all?: boolean all?: boolean
filters?: { [key: string]: any } filters?: { [key: string]: any }
excluded_documents?: number[]
} }
@Injectable({ @Injectable({
@@ -408,12 +407,10 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}) })
} }
getSelectionData( getSelectionData(ids: number[]): Observable<SelectionData> {
selection: DocumentSelectionQuery
): Observable<SelectionData> {
return this.http.post<SelectionData>( return this.http.post<SelectionData>(
this.getResourceUrl(null, 'selection_data'), this.getResourceUrl(null, 'selection_data'),
selection { documents: ids }
) )
} }
@@ -48,4 +48,13 @@ 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,5 +1,6 @@
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,
@@ -31,4 +32,10 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
{} {}
) )
} }
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
return this.list(1, 1000, 'created', true).pipe(
map((response) => response.results)
)
}
} }
@@ -14,11 +14,7 @@ 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 { import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
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'
@@ -234,35 +230,6 @@ 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,7 +24,6 @@ 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,
@@ -314,18 +313,6 @@ 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
@@ -762,29 +749,6 @@ 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[]
+1 -1
View File
@@ -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.2.0', version: '3.1.3',
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/',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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