Compare commits

..

1 Commits

Author SHA1 Message Date
shamoon
337092f345 Fix: handle versioned PaperlessTasks response 2025-03-08 16:38:12 -08:00
148 changed files with 79305 additions and 103191 deletions

View File

@ -149,7 +149,7 @@ RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \
&& apt-get install --yes --quiet ${BUILD_PACKAGES} && apt-get install --yes --quiet ${BUILD_PACKAGES}
RUN set -eux \ RUN set -eux \
&& npm update -g pnpm && npm update npm -g
# add users, setup scripts # add users, setup scripts
# Mount the compiled frontend to expected location # Mount the compiled frontend to expected location

View File

@ -33,7 +33,7 @@
"label": "Start: Frontend Angular", "label": "Start: Frontend Angular",
"description": "Start the Frontend Angular Dev Server", "description": "Start the Frontend Angular Dev Server",
"type": "shell", "type": "shell",
"command": "pnpm start", "command": "npm start",
"isBackground": true, "isBackground": true,
"options": { "options": {
"cwd": "${workspaceFolder}/src-ui" "cwd": "${workspaceFolder}/src-ui"
@ -173,8 +173,8 @@
}, },
{ {
"label": "Maintenance: Install Frontend Dependencies", "label": "Maintenance: Install Frontend Dependencies",
"description": "Install frontend (pnpm) dependencies", "description": "Install frontend (npm) dependencies",
"type": "pnpm", "type": "npm",
"script": "install", "script": "install",
"path": "src-ui", "path": "src-ui",
"group": "clean", "group": "clean",
@ -185,7 +185,7 @@
"description": "Clean install frontend dependencies and build the frontend for production", "description": "Clean install frontend dependencies and build the frontend for production",
"label": "Maintenance: Compile frontend for production", "label": "Maintenance: Compile frontend for production",
"type": "shell", "type": "shell",
"command": "pnpm install && ./node_modules/.bin/ng build --configuration production", "command": "npm ci && ./node_modules/.bin/ng build --configuration production",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,

View File

@ -1,15 +1,14 @@
# Please see the documentation for all configuration options: # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2 version: 2
# Required for uv support for now # Required for uv support for now
enable-beta-ecosystems: true enable-beta-ecosystems: true
updates: updates:
# Enable version updates for pnpm # Enable version updates for npm
- package-ecosystem: "npm" - package-ecosystem: "npm"
target-branch: "dev" target-branch: "dev"
# Look for `pnpm-lock.yaml` file in the `/src-ui` directory # Look for `package.json` and `lock` files in the `/src-ui` directory
directory: "/src-ui" directory: "/src-ui"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
schedule: schedule:
@ -90,50 +89,3 @@ updates:
- "major" - "major"
- "minor" - "minor"
- "patch" - "patch"
# Update Dockerfile in root directory
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
reviewers:
- "paperless-ngx/ci-cd"
labels:
- "ci-cd"
- "dependencies"
commit-message:
prefix: "docker"
include: "scope"
# Update Docker Compose files in docker/compose directory
- package-ecosystem: "docker-compose"
directory: "/docker/compose/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
reviewers:
- "paperless-ngx/ci-cd"
labels:
- "ci-cd"
- "dependencies"
commit-message:
prefix: "docker-compose"
include: "scope"
groups:
# Individual groups for each image
gotenberg:
patterns:
- "docker.io/gotenberg/gotenberg*"
tika:
patterns:
- "docker.io/apache/tika*"
redis:
patterns:
- "docker.io/library/redis*"
mariadb:
patterns:
- "docker.io/library/mariadb*"
postgres:
patterns:
- "docker.io/library/postgres*"

View File

@ -190,33 +190,29 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- -
name: Use Node.js 20 name: Use Node.js 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'pnpm' cache: 'npm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/package-lock.json'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.pnpm-store ~/.npm
~/.cache ~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
- -
name: Install dependencies name: Install dependencies
if: steps.cache-frontend-deps.outputs.cache-hit != 'true' if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
run: cd src-ui && pnpm install run: cd src-ui && npm ci
- -
name: Install Playwright name: Install Playwright
if: steps.cache-frontend-deps.outputs.cache-hit != 'true' if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
run: cd src-ui && pnpm playwright install --with-deps run: cd src-ui && npx playwright install --with-deps
tests-frontend: tests-frontend:
name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
@ -231,36 +227,32 @@ jobs:
shard-count: [4] shard-count: [4]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- -
name: Use Node.js 20 name: Use Node.js 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'pnpm' cache: 'npm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/package-lock.json'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.pnpm-store ~/.npm
~/.cache ~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }} key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
- name: Re-link Angular cli - name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli run: cd src-ui && npm link @angular/cli
- -
name: Linting checks name: Linting checks
run: cd src-ui && pnpm run lint run: cd src-ui && npm run lint
- -
name: Run Jest unit tests name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- -
name: Run Playwright e2e tests name: Run Playwright e2e tests
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
- -
name: Upload frontend test results to Codecov name: Upload frontend test results to Codecov
uses: codecov/test-results-action@v1 uses: codecov/test-results-action@v1
@ -284,35 +276,30 @@ jobs:
- tests-frontend - tests-frontend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
-
name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- -
name: Use Node.js 20 name: Use Node.js 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'pnpm' cache: 'npm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/package-lock.json'
- -
name: Cache frontend dependencies name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.pnpm-store ~/.npm
~/.cache ~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
- -
name: Re-link Angular cli name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli run: cd src-ui && npm link @angular/cli
- -
name: Build frontend and upload analysis name: Build frontend and upload analysis
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && pnpm run build --configuration=production run: cd src-ui && ng build --configuration=production
build-docker-image: build-docker-image:
name: Build Docker image for ${{ github.ref_name }} name: Build Docker image for ${{ github.ref_name }}
@ -521,7 +508,8 @@ jobs:
requirements.txt \ requirements.txt \
LICENSE \ LICENSE \
README.md \ README.md \
paperless.conf.example paperless.conf.example \
webserver.py
do do
cp --verbose ${file_name} dist/paperless-ngx/ cp --verbose ${file_name} dist/paperless-ngx/
done done

View File

@ -32,7 +32,7 @@ repos:
rev: v2.4.0 rev: v2.4.0
hooks: hooks:
- id: codespell - id: codespell
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)" exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
exclude_types: exclude_types:
- pofile - pofile
- json - json

View File

@ -4,17 +4,15 @@
# Stage: compile-frontend # Stage: compile-frontend
# Purpose: Compiles the frontend # Purpose: Compiles the frontend
# Notes: # Notes:
# - Does PNPM stuff with Typescript and such # - Does NPM stuff with Typescript and such
FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend
COPY ./src-ui /src/src-ui COPY ./src-ui /src/src-ui
WORKDIR /src/src-ui WORKDIR /src/src-ui
RUN set -eux \ RUN set -eux \
&& npm update -g pnpm \ && npm update npm -g \
&& npm install -g corepack@latest \ && npm ci
&& corepack enable \
&& pnpm install
ARG PNGX_TAG_VERSION= ARG PNGX_TAG_VERSION=
# Add the tag to the environment file if its a tagged dev build # Add the tag to the environment file if its a tagged dev build
@ -32,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.6.5-python3.12-bookworm-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.6.3-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@ -192,6 +190,11 @@ RUN set -eux \
&& rm --force --verbose *.deb \ && rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* && rm --recursive --force --verbose /var/lib/apt/lists/*
# Copy webserver config
# Changes very infrequently
WORKDIR /usr/src/paperless/
COPY --chown=1000:1000 webserver.py /usr/src/paperless/webserver.py
WORKDIR /usr/src/paperless/src/ WORKDIR /usr/src/paperless/src/
# Python dependencies # Python dependencies

View File

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

View File

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

View File

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

View File

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

View File

@ -413,3 +413,11 @@ Initial API version.
list of strings. When creating or updating a custom field value of a list of strings. When creating or updating a custom field value of a
document for a select type custom field, the value should be the `id` of document for a select type custom field, the value should be the `id` of
the option whereas previously was the index of the option. the option whereas previously was the index of the option.
#### Version 8
- PaperlessTask objects now have a `task_name` field which replaces the old
`type` field. The `type` field is now used to represent the way the task
was created. Additionally, the tasks endpoint now returns different types
of tasks other than simply 'file' tasks. See the API schema for more
information.

View File

@ -140,7 +140,7 @@ To build the front end once use this command:
```bash ```bash
# src-ui/ # src-ui/
$ pnpm install $ npm install
$ ng build --configuration production $ ng build --configuration production
``` ```
@ -176,7 +176,7 @@ To add a new development package `uv add --dev <package>`
## Front end development ## Front end development
The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and
`pnpm`. `npm`.
!!! note !!! note
@ -185,7 +185,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
1. Install the Angular CLI. You might need sudo privileges to perform this command: 1. Install the Angular CLI. You might need sudo privileges to perform this command:
```bash ```bash
pnpm install -g @angular/cli npm install -g @angular/cli
``` ```
2. Make sure that it's on your path. 2. Make sure that it's on your path.
@ -193,7 +193,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
3. Install all necessary modules: 3. Install all necessary modules:
```bash ```bash
pnpm install npm install
``` ```
4. You can launch a development server by running: 4. You can launch a development server by running:
@ -207,7 +207,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
restart it. restart it.
By default, the development server is available on `http://localhost:4200/` and is configured to access the API at By default, the development server is available on `http://localhost:4200/` and is configured to access the API at
`http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts and CORS are in place so that the front end behaves exactly as in production. `http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts, CORS and X-Frame-Options are in place so that the front end behaves exactly as in production.
### Testing and code style ### Testing and code style

View File

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

View File

@ -37,7 +37,7 @@ dependencies = [
"djangorestframework~=3.15", "djangorestframework~=3.15",
"djangorestframework-guardian~=0.3.0", "djangorestframework-guardian~=0.3.0",
"drf-spectacular~=0.28", "drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2025.3.1", "drf-spectacular-sidecar~=2025.2.1",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.17.0", "filelock~=3.17.0",
"flower~=2.0.1", "flower~=2.0.1",
@ -48,7 +48,7 @@ dependencies = [
"jinja2~=3.1.5", "jinja2~=3.1.5",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"nltk~=3.9.1", "nltk~=3.9.1",
"ocrmypdf~=16.10.0", "ocrmypdf~=16.9.0",
"pathvalidate~=3.2.3", "pathvalidate~=3.2.3",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
"python-dateutil~=2.9.0", "python-dateutil~=2.9.0",
@ -73,12 +73,12 @@ optional-dependencies.mariadb = [
"mysqlclient~=2.2.7", "mysqlclient~=2.2.7",
] ]
optional-dependencies.postgres = [ optional-dependencies.postgres = [
"psycopg[c]==3.2.5", "psycopg[c]==3.2.4",
# Direct dependency for proper resolution of the pre-built wheels # Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.2.5", "psycopg-c==3.2.4",
] ]
optional-dependencies.webserver = [ optional-dependencies.webserver = [
"granian~=2.0.1", "granian~=1.7.6",
] ]
[dependency-groups] [dependency-groups]
@ -343,8 +343,8 @@ environments = [
[tool.uv.sources] [tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image # Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [ psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.5/psycopg_c-3.2.5-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" }, { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.5/psycopg_c-3.2.5-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" }, { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
] ]
zxing-cpp = [ zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" }, { url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },

View File

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

View File

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

View File

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

View File

@ -7,9 +7,7 @@ module.exports = {
'abstract-name-filter-service', 'abstract-name-filter-service',
'abstract-paperless-service', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [ transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
`<rootDir>/node_modules/.pnpm/(?!.*\\.mjs$|lodash-es)`,
],
moduleNameMapper: { moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1', '^src/(.*)': '<rootDir>/src/$1',
}, },

File diff suppressed because it is too large Load Diff

19419
src-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@
"name": "paperless-ui", "name": "paperless-ui",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
@ -12,17 +11,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^19.2.2", "@angular/cdk": "^19.2.1",
"@angular/common": "~19.2.1", "@angular/common": "~19.2.0",
"@angular/compiler": "~19.2.1", "@angular/compiler": "~19.2.0",
"@angular/core": "~19.2.1", "@angular/core": "~19.2.0",
"@angular/forms": "~19.2.1", "@angular/forms": "~19.2.0",
"@angular/localize": "~19.2.1", "@angular/localize": "~19.2.0",
"@angular/platform-browser": "~19.2.1", "@angular/platform-browser": "~19.2.0",
"@angular/platform-browser-dynamic": "~19.2.1", "@angular/platform-browser-dynamic": "~19.2.0",
"@angular/router": "~19.2.1", "@angular/router": "~19.2.0",
"@ng-bootstrap/ng-bootstrap": "^18.0.0", "@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ng-select/ng-select": "^14.2.3", "@ng-select/ng-select": "^14.2.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.3", "bootstrap": "^5.3.3",
@ -44,24 +43,24 @@
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^19.0.0", "@angular-builders/custom-webpack": "^19.0.0",
"@angular-builders/jest": "^19.0.0", "@angular-builders/jest": "^19.0.0",
"@angular-devkit/build-angular": "^19.2.1", "@angular-devkit/build-angular": "^19.0.4",
"@angular-devkit/core": "^19.2.1", "@angular-devkit/core": "^19.2.0",
"@angular-devkit/schematics": "^19.2.1", "@angular-devkit/schematics": "^19.2.0",
"@angular-eslint/builder": "19.2.1", "@angular-eslint/builder": "19.2.0",
"@angular-eslint/eslint-plugin": "19.2.1", "@angular-eslint/eslint-plugin": "19.2.0",
"@angular-eslint/eslint-plugin-template": "19.2.1", "@angular-eslint/eslint-plugin-template": "19.2.0",
"@angular-eslint/schematics": "19.2.1", "@angular-eslint/schematics": "19.2.0",
"@angular-eslint/template-parser": "19.2.1", "@angular-eslint/template-parser": "19.2.0",
"@angular/cli": "~19.2.1", "@angular/cli": "~19.2.0",
"@angular/compiler-cli": "~19.2.1", "@angular/compiler-cli": "~19.2.0",
"@codecov/webpack-plugin": "^1.9.0", "@codecov/webpack-plugin": "^1.9.0",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.0",
"@typescript-eslint/utils": "^8.26.1", "@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.22.0", "eslint": "^9.21.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
@ -72,14 +71,5 @@
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"canvas",
"esbuild",
"lmdb",
"msgpackr-extract"
]
},
"typings": "./src/typings.d.ts" "typings": "./src/typings.d.ts"
} }

View File

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

12447
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -62,7 +62,6 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailAddress = '' this.emailAddress = ''
this.emailSubject = '' this.emailSubject = ''
this.emailMessage = '' this.emailMessage = ''
this.close()
this.toastService.showInfo($localize`Email sent`) this.toastService.showInfo($localize`Email sent`)
}, },
error: (e) => { error: (e) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -824,18 +824,11 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
this.networkActive = false this.networkActive = false
const canEdit = if (!this.userCanEdit) {
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
this.document
)
if (!canEdit) {
// document was 'given away'
this.openDocumentService.setDirty(this.document, false)
this.toastService.showInfo( this.toastService.showInfo(
$localize`Document "${this.document.title}" saved successfully.` $localize`Document "${this.document.title}" saved successfully.`
) )
this.close() close && this.close()
} else { } else {
this.error = error.error this.error = error.error
this.toastService.showError( this.toastService.showError(

View File

@ -20,8 +20,10 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
[createRef]="createTag.bind(this)" [createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()" (opened)="openTagsDropdown()"
@ -34,6 +36,7 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
@ -48,6 +51,7 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
@ -62,6 +66,7 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
@ -76,8 +81,10 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[disabled]="!userCanEditAll" [disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
[createRef]="createCustomField.bind(this)" [createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()" (opened)="openCustomFieldsDropdown()"

View File

@ -1150,10 +1150,10 @@ describe('BulkEditorComponent', () => {
it('should not attempt to retrieve objects if user does not have permissions', () => { it('should not attempt to retrieve objects if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.tagSelectionModel.items.length).toEqual(0) expect(component.tags).toBeUndefined()
expect(component.correspondentSelectionModel.items.length).toEqual(0) expect(component.correspondents).toBeUndefined()
expect(component.documentTypeSelectionModel.items.length).toEqual(0) expect(component.documentTypes).toBeUndefined()
expect(component.storagePathsSelectionModel.items.length).toEqual(0) expect(component.storagePaths).toBeUndefined()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`) httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/` `${environment.apiBaseUrl}documents/correspondents/`
@ -1204,9 +1204,7 @@ describe('BulkEditorComponent', () => {
expect(tagListAllSpy).toHaveBeenCalled() expect(tagListAllSpy).toHaveBeenCalled()
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id) expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
expect(component.tagSelectionModel.items).toEqual( expect(component.tags).toEqual(tags.results)
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
)
}) })
it('should support create new correspondent', () => { it('should support create new correspondent', () => {
@ -1253,9 +1251,7 @@ describe('BulkEditorComponent', () => {
expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith( expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
newCorrespondent.id newCorrespondent.id
) )
expect(component.correspondentSelectionModel.items).toEqual( expect(component.correspondents).toEqual(correspondents.results)
[{ id: null, name: 'Not assigned' }].concat(correspondents.results as any)
)
}) })
it('should support create new document type', () => { it('should support create new document type', () => {
@ -1299,9 +1295,7 @@ describe('BulkEditorComponent', () => {
expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith( expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
newDocumentType.id newDocumentType.id
) )
expect(component.documentTypeSelectionModel.items).toEqual( expect(component.documentTypes).toEqual(documentTypes.results)
[{ id: null, name: 'Not assigned' }].concat(documentTypes.results as any)
)
}) })
it('should support create new storage path', () => { it('should support create new storage path', () => {
@ -1345,9 +1339,7 @@ describe('BulkEditorComponent', () => {
expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith( expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
newStoragePath.id newStoragePath.id
) )
expect(component.storagePathsSelectionModel.items).toEqual( expect(component.storagePaths).toEqual(storagePaths.results)
[{ id: null, name: 'Not assigned' }].concat(storagePaths.results as any)
)
}) })
it('should support create new custom field', () => { it('should support create new custom field', () => {
@ -1399,9 +1391,7 @@ describe('BulkEditorComponent', () => {
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith( expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
newCustomField.id newCustomField.id
) )
expect(component.customFieldsSelectionModel.items).toEqual( expect(component.customFields).toEqual(customFields.results)
[{ id: null, name: 'Not assigned' }].concat(customFields.results as any)
)
}) })
it('should open the bulk edit custom field values dialog with correct parameters', () => { it('should open the bulk edit custom field values dialog with correct parameters', () => {
@ -1426,17 +1416,17 @@ describe('BulkEditorComponent', () => {
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError') const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
const listReloadSpy = jest.spyOn(documentListViewService, 'reload') const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
component.customFieldsSelectionModel.items = [ component.customFields = [
{ id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String }, { id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String },
{ id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String }, { id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String },
] as any ]
component.setCustomFieldValues({ component.setCustomFieldValues({
itemsToAdd: [{ id: 1 }, { id: 2 }], itemsToAdd: [{ id: 1 }, { id: 2 }],
itemsToRemove: [1], itemsToRemove: [1],
} as any) } as any)
expect(modal.componentInstance.customFields.length).toEqual(2) expect(modal.componentInstance.customFields).toEqual(component.customFields)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2]) expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.documents).toEqual([3, 4]) expect(modal.componentInstance.documents).toEqual([3, 4])

View File

@ -14,8 +14,12 @@ import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Subject, switchMap, takeUntil } from 'rxjs' import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { 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 { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@ -71,11 +75,17 @@ export class BulkEditorComponent
extends ComponentWithPermissions extends ComponentWithPermissions
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
tagSelectionModel = new FilterableDropdownSelectionModel(true) tags: Tag[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
customFields: CustomField[]
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true) customFieldsSelectionModel = new FilterableDropdownSelectionModel()
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[] documentTypeDocumentCounts: SelectionDataItem[]
@ -166,7 +176,7 @@ export class BulkEditorComponent
this.tagService this.tagService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.tagSelectionModel.items = result.results)) .subscribe((result) => (this.tags = result.results))
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -177,9 +187,7 @@ export class BulkEditorComponent
this.correspondentService this.correspondentService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe( .subscribe((result) => (this.correspondents = result.results))
(result) => (this.correspondentSelectionModel.items = result.results)
)
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -190,9 +198,7 @@ export class BulkEditorComponent
this.documentTypeService this.documentTypeService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe( .subscribe((result) => (this.documentTypes = result.results))
(result) => (this.documentTypeSelectionModel.items = result.results)
)
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -203,9 +209,7 @@ export class BulkEditorComponent
this.storagePathService this.storagePathService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe( .subscribe((result) => (this.storagePaths = result.results))
(result) => (this.storagePathsSelectionModel.items = result.results)
)
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -216,9 +220,7 @@ export class BulkEditorComponent
this.customFieldService this.customFieldService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe( .subscribe((result) => (this.customFields = result.results))
(result) => (this.customFieldsSelectionModel.items = result.results)
)
} }
this.downloadForm this.downloadForm
@ -649,7 +651,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newTag, tags }) => { .subscribe(({ newTag, tags }) => {
this.tagSelectionModel.items = tags.results this.tags = tags.results
this.tagSelectionModel.toggle(newTag.id) this.tagSelectionModel.toggle(newTag.id)
}) })
} }
@ -672,7 +674,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCorrespondent, correspondents }) => { .subscribe(({ newCorrespondent, correspondents }) => {
this.correspondentSelectionModel.items = correspondents.results this.correspondents = correspondents.results
this.correspondentSelectionModel.toggle(newCorrespondent.id) this.correspondentSelectionModel.toggle(newCorrespondent.id)
}) })
} }
@ -693,7 +695,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newDocumentType, documentTypes }) => { .subscribe(({ newDocumentType, documentTypes }) => {
this.documentTypeSelectionModel.items = documentTypes.results this.documentTypes = documentTypes.results
this.documentTypeSelectionModel.toggle(newDocumentType.id) this.documentTypeSelectionModel.toggle(newDocumentType.id)
}) })
} }
@ -714,7 +716,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newStoragePath, storagePaths }) => { .subscribe(({ newStoragePath, storagePaths }) => {
this.storagePathsSelectionModel.items = storagePaths.results this.storagePaths = storagePaths.results
this.storagePathsSelectionModel.toggle(newStoragePath.id) this.storagePathsSelectionModel.toggle(newStoragePath.id)
}) })
} }
@ -735,7 +737,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCustomField, customFields }) => { .subscribe(({ newCustomField, customFields }) => {
this.customFieldsSelectionModel.items = customFields.results this.customFields = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id) this.customFieldsSelectionModel.toggle(newCustomField.id)
}) })
} }
@ -873,9 +875,7 @@ export class BulkEditorComponent
}) })
const dialog = const dialog =
modal.componentInstance as CustomFieldsBulkEditDialogComponent modal.componentInstance as CustomFieldsBulkEditDialogComponent
dialog.customFields = ( dialog.customFields = this.customFields
this.customFieldsSelectionModel.items as CustomField[]
).filter((f) => f.id !== null)
dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map( dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map(
(item) => item.id (item) => item.id
) )

View File

@ -310,8 +310,8 @@
</div> </div>
} }
@if (activeDisplayFields.includes(DisplayField.TAGS)) { @if (activeDisplayFields.includes(DisplayField.TAGS)) {
@for (tagID of d.tags; track tagID) { @for (tagID of d.tags; track t) {
<pngx-tag [tagID]="tagID" class="ms-1 fs-6" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(tagID);$event.stopPropagation()"></pngx-tag> <pngx-tag [tagID]="tagID" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(tagID);$event.stopPropagation()"></pngx-tag>
} }
} }
</td> </td>

View File

@ -35,9 +35,11 @@
<div class="col-auto"> <div class="col-auto">
<div class="d-flex flex-wrap gap-3"> <div class="d-flex flex-wrap gap-3">
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tagSelectionModel.items.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tags.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Tags" icon="tag-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[manyToOne]="true"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()" (opened)="onTagsDropdownOpen()"
@ -46,9 +48,10 @@
[disabled]="disabled" [disabled]="disabled"
shortcutKey="t"></pngx-filterable-dropdown> shortcutKey="t"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondentSelectionModel.items.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondents.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Correspondent" icon="person-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel" [(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onCorrespondentDropdownOpen()" (opened)="onCorrespondentDropdownOpen()"
@ -57,9 +60,10 @@
[disabled]="disabled" [disabled]="disabled"
shortcutKey="y"></pngx-filterable-dropdown> shortcutKey="y"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypeSelectionModel.items.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypes.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Document type" icon="file-earmark-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel" [(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()" (opened)="onDocumentTypeDropdownOpen()"
@ -68,9 +72,10 @@
[disabled]="disabled" [disabled]="disabled"
shortcutKey="u"></pngx-filterable-dropdown> shortcutKey="u"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePathSelectionModel.items.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePaths.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Storage path" icon="folder-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[(selectionModel)]="storagePathSelectionModel" [(selectionModel)]="storagePathSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onStoragePathDropdownOpen()" (opened)="onStoragePathDropdownOpen()"

View File

@ -69,7 +69,6 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_TITLE, FILTER_TITLE,
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
@ -672,6 +671,9 @@ describe('FilterEditorComponent', () => {
value: '12', value: '12',
}, },
] ]
expect(component.correspondentSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.correspondentSelectionModel.intersection).toEqual( expect(component.correspondentSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -679,19 +681,6 @@ describe('FilterEditorComponent', () => {
correspondents[0], correspondents[0],
]) ])
component.toggleCorrespondent(12) // coverage component.toggleCorrespondent(12) // coverage
component.filterRules = [
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.correspondentSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.correspondentSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
})) }))
it('should ingest filter rules for has any of correspondents', fakeAsync(() => { it('should ingest filter rules for has any of correspondents', fakeAsync(() => {
@ -765,6 +754,9 @@ describe('FilterEditorComponent', () => {
value: '22', value: '22',
}, },
] ]
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.documentTypeSelectionModel.intersection).toEqual( expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -772,19 +764,6 @@ describe('FilterEditorComponent', () => {
document_types[0], document_types[0],
]) ])
component.toggleDocumentType(22) // coverage component.toggleDocumentType(22) // coverage
component.filterRules = [
{
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.documentTypeSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
})) }))
it('should ingest filter rules for has any of document types', fakeAsync(() => { it('should ingest filter rules for has any of document types', fakeAsync(() => {
@ -801,6 +780,9 @@ describe('FilterEditorComponent', () => {
value: '23', value: '23',
}, },
] ]
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.documentTypeSelectionModel.intersection).toEqual( expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -855,6 +837,9 @@ describe('FilterEditorComponent', () => {
value: '32', value: '32',
}, },
] ]
expect(component.storagePathSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.storagePathSelectionModel.intersection).toEqual( expect(component.storagePathSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -862,19 +847,6 @@ describe('FilterEditorComponent', () => {
storage_paths[0], storage_paths[0],
]) ])
component.toggleStoragePath(32) // coverage component.toggleStoragePath(32) // coverage
component.filterRules = [
{
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.storagePathSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.storagePathSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
})) }))
it('should ingest filter rules for has any of storage paths', fakeAsync(() => { it('should ingest filter rules for has any of storage paths', fakeAsync(() => {
@ -1426,19 +1398,6 @@ describe('FilterEditorComponent', () => {
value: null, value: null,
}, },
]) ])
const excludeButton = correspondentsFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
})) }))
it('should convert user input to correct filter rules on document type selections', fakeAsync(() => { it('should convert user input to correct filter rules on document type selections', fakeAsync(() => {
@ -1496,19 +1455,6 @@ describe('FilterEditorComponent', () => {
value: null, value: null,
}, },
]) ])
const excludeButton = docTypesFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
})) }))
it('should convert user input to correct filter rules on storage path selections', fakeAsync(() => { it('should convert user input to correct filter rules on storage path selections', fakeAsync(() => {
@ -1566,19 +1512,6 @@ describe('FilterEditorComponent', () => {
value: null, value: null,
}, },
]) ])
const excludeButton = storagePathsFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
})) }))
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => { it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {

View File

@ -26,12 +26,14 @@ import {
switchMap, switchMap,
takeUntil, takeUntil,
} from 'rxjs/operators' } from 'rxjs/operators'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { import {
CustomFieldQueryLogicalOperator, CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator, CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query' } from 'src/app/data/custom-field-query'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { import {
FILTER_ADDED_AFTER, FILTER_ADDED_AFTER,
@ -73,8 +75,9 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_TITLE, FILTER_TITLE,
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { import {
PermissionAction, PermissionAction,
PermissionType, PermissionType,
@ -248,9 +251,7 @@ export class FilterEditorComponent
case FILTER_HAS_CORRESPONDENT_ANY: case FILTER_HAS_CORRESPONDENT_ANY:
if (rule.value) { if (rule.value) {
return $localize`Correspondent: ${ return $localize`Correspondent: ${
this.correspondentSelectionModel.items.find( this.correspondents.find((c) => c.id == +rule.value)?.name
(c) => c.id == +rule.value
)?.name
}` }`
} else { } else {
return $localize`Without correspondent` return $localize`Without correspondent`
@ -260,9 +261,7 @@ export class FilterEditorComponent
case FILTER_HAS_DOCUMENT_TYPE_ANY: case FILTER_HAS_DOCUMENT_TYPE_ANY:
if (rule.value) { if (rule.value) {
return $localize`Document type: ${ return $localize`Document type: ${
this.documentTypeSelectionModel.items.find( this.documentTypes.find((dt) => dt.id == +rule.value)?.name
(dt) => dt.id == +rule.value
)?.name
}` }`
} else { } else {
return $localize`Without document type` return $localize`Without document type`
@ -272,9 +271,7 @@ export class FilterEditorComponent
case FILTER_HAS_STORAGE_PATH_ANY: case FILTER_HAS_STORAGE_PATH_ANY:
if (rule.value) { if (rule.value) {
return $localize`Storage path: ${ return $localize`Storage path: ${
this.storagePathSelectionModel.items.find( this.storagePaths.find((sp) => sp.id == +rule.value)?.name
(sp) => sp.id == +rule.value
)?.name
}` }`
} else { } else {
return $localize`Without storage path` return $localize`Without storage path`
@ -282,7 +279,7 @@ export class FilterEditorComponent
case FILTER_HAS_TAGS_ALL: case FILTER_HAS_TAGS_ALL:
return $localize`Tag: ${ return $localize`Tag: ${
this.tagSelectionModel.items.find((t) => t.id == +rule.value)?.name this.tags.find((t) => t.id == +rule.value)?.name
}` }`
case FILTER_HAS_ANY_TAG: case FILTER_HAS_ANY_TAG:
@ -329,6 +326,10 @@ export class FilterEditorComponent
@ViewChild('textFilterInput') @ViewChild('textFilterInput')
textFilterInput: ElementRef textFilterInput: ElementRef
tags: Tag[] = []
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
customFields: CustomField[] = [] customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
@ -369,7 +370,7 @@ export class FilterEditorComponent
) )
} }
tagSelectionModel = new FilterableDropdownSelectionModel(true) tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel() storagePathSelectionModel = new FilterableDropdownSelectionModel()
@ -550,19 +551,6 @@ export class FilterEditorComponent
) )
break break
case FILTER_CORRESPONDENT: case FILTER_CORRESPONDENT:
this.correspondentSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.correspondentSelectionModel.set(
rule.value ? +rule.value : null,
this.correspondentSelectionModel.intersection ==
Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_CORRESPONDENT_ANY: case FILTER_HAS_CORRESPONDENT_ANY:
this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or
this.correspondentSelectionModel.intersection = Intersection.Include this.correspondentSelectionModel.intersection = Intersection.Include
@ -581,18 +569,6 @@ export class FilterEditorComponent
) )
break break
case FILTER_DOCUMENT_TYPE: case FILTER_DOCUMENT_TYPE:
this.documentTypeSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null,
this.documentTypeSelectionModel.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_DOCUMENT_TYPE_ANY: case FILTER_HAS_DOCUMENT_TYPE_ANY:
this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or
this.documentTypeSelectionModel.intersection = Intersection.Include this.documentTypeSelectionModel.intersection = Intersection.Include
@ -611,18 +587,6 @@ export class FilterEditorComponent
) )
break break
case FILTER_STORAGE_PATH: case FILTER_STORAGE_PATH:
this.storagePathSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
this.storagePathSelectionModel.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_STORAGE_PATH_ANY: case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
this.storagePathSelectionModel.intersection = Intersection.Include this.storagePathSelectionModel.intersection = Intersection.Include
@ -845,21 +809,9 @@ export class FilterEditorComponent
}) })
}) })
} }
if ( if (this.correspondentSelectionModel.isNoneSelected()) {
this.correspondentSelectionModel.isNoneSelected() &&
this.correspondentSelectionModel.intersection == Intersection.Include
) {
filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null }) filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null })
} else { } else {
if (
this.correspondentSelectionModel.isNoneSelected() &&
this.correspondentSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.correspondentSelectionModel this.correspondentSelectionModel
.getSelectedItems() .getSelectedItems()
.forEach((correspondent) => { .forEach((correspondent) => {
@ -870,7 +822,6 @@ export class FilterEditorComponent
}) })
this.correspondentSelectionModel this.correspondentSelectionModel
.getExcludedItems() .getExcludedItems()
.filter((correspondent) => correspondent.id > 0)
.forEach((correspondent) => { .forEach((correspondent) => {
filterRules.push({ filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT, rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
@ -878,21 +829,9 @@ export class FilterEditorComponent
}) })
}) })
} }
if ( if (this.documentTypeSelectionModel.isNoneSelected()) {
this.documentTypeSelectionModel.isNoneSelected() &&
this.documentTypeSelectionModel.intersection === Intersection.Include
) {
filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null }) filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null })
} else { } else {
if (
this.documentTypeSelectionModel.isNoneSelected() &&
this.documentTypeSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.documentTypeSelectionModel this.documentTypeSelectionModel
.getSelectedItems() .getSelectedItems()
.forEach((documentType) => { .forEach((documentType) => {
@ -903,7 +842,6 @@ export class FilterEditorComponent
}) })
this.documentTypeSelectionModel this.documentTypeSelectionModel
.getExcludedItems() .getExcludedItems()
.filter((documentType) => documentType.id > 0)
.forEach((documentType) => { .forEach((documentType) => {
filterRules.push({ filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE, rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
@ -911,21 +849,9 @@ export class FilterEditorComponent
}) })
}) })
} }
if ( if (this.storagePathSelectionModel.isNoneSelected()) {
this.storagePathSelectionModel.isNoneSelected() &&
this.storagePathSelectionModel.intersection == Intersection.Include
) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null }) filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else { } else {
if (
this.storagePathSelectionModel.isNoneSelected() &&
this.storagePathSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.storagePathSelectionModel this.storagePathSelectionModel
.getSelectedItems() .getSelectedItems()
.forEach((storagePath) => { .forEach((storagePath) => {
@ -936,7 +862,6 @@ export class FilterEditorComponent
}) })
this.storagePathSelectionModel this.storagePathSelectionModel
.getExcludedItems() .getExcludedItems()
.filter((storagePath) => storagePath.id > 0)
.forEach((storagePath) => { .forEach((storagePath) => {
filterRules.push({ filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH, rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
@ -947,7 +872,7 @@ export class FilterEditorComponent
let queries = this.customFieldQueriesModel.queries.map((query) => let queries = this.customFieldQueriesModel.queries.map((query) =>
query.serialize() query.serialize()
) )
if (queries.length > 0 && this.customFieldQueriesModel.isValid()) { if (queries.length > 0) {
filterRules.push({ filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS_QUERY, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify(queries[0]), value: JSON.stringify(queries[0]),
@ -1137,7 +1062,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.tagService.listAll().subscribe((result) => { this.tagService.listAll().subscribe((result) => {
this.tagSelectionModel.items = result.results this.tags = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@ -1149,7 +1074,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.correspondentService.listAll().subscribe((result) => { this.correspondentService.listAll().subscribe((result) => {
this.correspondentSelectionModel.items = result.results this.correspondents = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@ -1161,7 +1086,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.documentTypeService.listAll().subscribe((result) => { this.documentTypeService.listAll().subscribe((result) => {
this.documentTypeSelectionModel.items = result.results this.documentTypes = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@ -1173,7 +1098,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.storagePathService.listAll().subscribe((result) => { this.storagePathService.listAll().subscribe((result) => {
this.storagePathSelectionModel.items = result.results this.storagePaths = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }

View File

@ -1,7 +1,5 @@
import { DataType } from './datatype' import { DataType } from './datatype'
export const NEGATIVE_NULL_FILTER_VALUE = -1
// These correspond to src/documents/models.py and changes here require a DB migration (and vice versa) // These correspond to src/documents/models.py and changes here require a DB migration (and vice versa)
export const FILTER_TITLE = 0 export const FILTER_TITLE = 0
export const FILTER_CONTENT = 1 export const FILTER_CONTENT = 1

View File

@ -45,8 +45,7 @@ describe('CustomDatePipe', () => {
if (now.getMonth() === 0) { if (now.getMonth() === 0) {
notNow.setFullYear(now.getFullYear() - 1) notNow.setFullYear(now.getFullYear() - 1)
} }
// weird options are for february... expect(['Last month', '4 weeks ago']).toContain(
expect(['Last month', '4 weeks ago', '3 weeks ago']).toContain(
datePipe.transform(notNow, 'relative') datePipe.transform(notNow, 'relative')
) )
expect(datePipe.transform(now, 'relative')).toEqual('Just now') expect(datePipe.transform(now, 'relative')).toEqual('Just now')

View File

@ -29,7 +29,7 @@ describe('ComponentRouterService', () => {
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url', url: 'test-url',
data: { componentName: 'TestComponent' }, component: { name: 'TestComponent' },
} as any) } as any)
) )
@ -41,13 +41,13 @@ describe('ComponentRouterService', () => {
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url-1', url: 'test-url-1',
data: { componentName: 'TestComponent' }, component: { name: 'TestComponent' },
} as any) } as any)
) )
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url-2', url: 'test-url-2',
data: { componentName: 'TestComponent' }, component: { name: 'TestComponent' },
} as any) } as any)
) )
@ -59,13 +59,13 @@ describe('ComponentRouterService', () => {
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url-1', url: 'test-url-1',
data: { componentName: 'TestComponent1' }, component: { name: 'TestComponent1' },
} as any) } as any)
) )
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url-2', url: 'test-url-2',
data: { componentName: 'TestComponent2' }, component: { name: 'TestComponent2' },
} as any) } as any)
) )
@ -76,13 +76,13 @@ describe('ComponentRouterService', () => {
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url-1', url: 'test-url-1',
data: { componentName: 'TestComponent' }, component: { name: 'TestComponent' },
} as any) } as any)
) )
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url-2', url: 'test-url-2',
data: { componentName: 'TestComponent' }, component: { name: 'TestComponent' },
} as any) } as any)
) )
@ -93,7 +93,7 @@ describe('ComponentRouterService', () => {
eventsSubject.next( eventsSubject.next(
new ActivationStart({ new ActivationStart({
url: 'test-url', url: 'test-url',
data: { componentName: 'TestComponent' }, component: { name: 'TestComponent' },
} as any) } as any)
) )

View File

@ -17,11 +17,11 @@ export class ComponentRouterService {
.subscribe((event: ActivationStart) => { .subscribe((event: ActivationStart) => {
if ( if (
this.componentHistory[this.componentHistory.length - 1] !== this.componentHistory[this.componentHistory.length - 1] !==
event.snapshot.data.componentName && event.snapshot.component.name &&
!EXCLUDE_COMPONENTS.includes(event.snapshot.data.componentName) !EXCLUDE_COMPONENTS.includes(event.snapshot.component.name)
) { ) {
this.history.push(event.snapshot.url.toString()) this.history.push(event.snapshot.url.toString())
this.componentHistory.push(event.snapshot.data.componentName) this.componentHistory.push(event.snapshot.component.name)
} else { } else {
// Update the URL of the current component in case the same component was loaded via a different URL // Update the URL of the current component in case the same component was loaded via a different URL
this.history[this.history.length - 1] = event.snapshot.url.toString() this.history[this.history.length - 1] = event.snapshot.url.toString()

View File

@ -602,6 +602,7 @@ export class SettingsService {
) )
} catch (error) { } catch (error) {
this.toastService.showError(errorMessage) this.toastService.showError(errorMessage)
console.log(error)
} }
this.storeSettings() this.storeSettings()
@ -613,6 +614,7 @@ export class SettingsService {
}, },
error: (e) => { error: (e) => {
this.toastService.showError(errorMessage) this.toastService.showError(errorMessage)
console.log(e)
}, },
}) })
} }
@ -634,6 +636,7 @@ export class SettingsService {
this.toastService.showError( this.toastService.showError(
'Error migrating update checking setting' 'Error migrating update checking setting'
) )
console.log(e)
}, },
}) })
} }

View File

@ -8,7 +8,6 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
NEGATIVE_NULL_FILTER_VALUE,
} from '../data/filter-rule-type' } from '../data/filter-rule-type'
import { import {
filterRulesFromQueryParams, filterRulesFromQueryParams,
@ -98,16 +97,6 @@ describe('QueryParams Utils', () => {
correspondent__isnull: 1, correspondent__isnull: 1,
}) })
params = queryParamsFromFilterRules([
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
expect(params).toEqual({
correspondent__isnull: 0,
})
params = queryParamsFromFilterRules([ params = queryParamsFromFilterRules([
{ {
rule_type: FILTER_HAS_ANY_TAG, rule_type: FILTER_HAS_ANY_TAG,

View File

@ -10,7 +10,6 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_RULE_TYPES, FILTER_RULE_TYPES,
FilterRuleType, FilterRuleType,
NEGATIVE_NULL_FILTER_VALUE,
} from '../data/filter-rule-type' } from '../data/filter-rule-type'
import { ListViewState } from '../services/document-list-view.service' import { ListViewState } from '../services/document-list-view.service'
@ -114,10 +113,6 @@ export function filterRulesFromQueryParams(
rt.isnull_filtervar == filterQueryParamName rt.isnull_filtervar == filterQueryParamName
) )
const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName
const nullRuleValue =
queryParams.get(filterQueryParamName) == '1'
? null
: NEGATIVE_NULL_FILTER_VALUE.toString()
const valueURIComponent: string = queryParams.get(filterQueryParamName) const valueURIComponent: string = queryParams.get(filterQueryParamName)
const filterQueryParamValues: string[] = rule_type.multi const filterQueryParamValues: string[] = rule_type.multi
? valueURIComponent.split(',') ? valueURIComponent.split(',')
@ -130,7 +125,7 @@ export function filterRulesFromQueryParams(
val = val.replace('1', 'true').replace('0', 'false') val = val.replace('1', 'true').replace('0', 'false')
return { return {
rule_type: rule_type.id, rule_type: rule_type.id,
value: isNullRuleType ? nullRuleValue : val, value: isNullRuleType ? null : val,
} }
}) })
) )
@ -148,11 +143,6 @@ export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type) let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (ruleType.isnull_filtervar && rule.value == null) { if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1 params[ruleType.isnull_filtervar] = 1
} else if (
ruleType.isnull_filtervar &&
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
) {
params[ruleType.isnull_filtervar] = 0
} else if (ruleType.multi) { } else if (ruleType.multi) {
params[ruleType.filtervar] = params[ruleType.filtervar] params[ruleType.filtervar] = params[ruleType.filtervar]
? params[ruleType.filtervar] + ',' + rule.value ? params[ruleType.filtervar] + ',' + rule.value

View File

@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '7', apiVersion: '8',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '2.14.7', version: '2.14.7',
webSocketHost: window.location.host, webSocketHost: window.location.host,

View File

@ -5,7 +5,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'http://localhost:8000/api/', apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '7', apiVersion: '8',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: 'DEVELOPMENT', version: 'DEVELOPMENT',
webSocketHost: 'localhost:8000', webSocketHost: 'localhost:8000',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

@ -24,7 +24,7 @@
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef --pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
--pngx-bg-disabled: #f7f7f7; --pngx-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3; --pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 340px; --pngx-toast-max-width: 360px;
--bs-info: var(--pngx-bg-alt2); --bs-info: var(--pngx-bg-alt2);
--bs-info-rgb: 233, 236, 239; --bs-info-rgb: 233, 236, 239;
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {

View File

@ -294,9 +294,9 @@ class Command(BaseCommand):
inotify = INotify() inotify = INotify()
inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY
if recursive: if recursive:
inotify.add_watch_recursive(directory, inotify_flags) descriptor = inotify.add_watch_recursive(directory, inotify_flags)
else: else:
inotify.add_watch(directory, inotify_flags) descriptor = inotify.add_watch(directory, inotify_flags)
inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY
inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000 inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000
@ -305,55 +305,55 @@ class Command(BaseCommand):
notified_files = {} notified_files = {}
try: while not finished:
while not finished: try:
try: for event in inotify.read(timeout=timeout_ms):
for event in inotify.read(timeout=timeout_ms): path = inotify.get_path(event.wd) if recursive else directory
path = inotify.get_path(event.wd) if recursive else directory filepath = os.path.join(path, event.name)
filepath = os.path.join(path, event.name) if flags.MODIFY in flags.from_mask(event.mask):
if flags.MODIFY in flags.from_mask(event.mask): notified_files.pop(filepath, None)
notified_files.pop(filepath, None)
else:
notified_files[filepath] = monotonic()
# Check the files against the timeout
still_waiting = {}
# last_event_time is time of the last inotify event for this file
for filepath, last_event_time in notified_files.items():
# Current time - last time over the configured timeout
waited_long_enough = (
monotonic() - last_event_time
) > inotify_debounce_secs
# Also make sure the file exists still, some scanners might write a
# temporary file first
file_still_exists = os.path.exists(filepath) and os.path.isfile(
filepath,
)
if waited_long_enough and file_still_exists:
_consume(filepath)
elif file_still_exists:
still_waiting[filepath] = last_event_time
# These files are still waiting to hit the timeout
notified_files = still_waiting
# If files are waiting, need to exit read() to check them
# Otherwise, go back to infinite sleep time, but only if not testing
if len(notified_files) > 0:
timeout_ms = inotify_debounce_ms
elif is_testing:
timeout_ms = self.testing_timeout_ms
else: else:
timeout_ms = None notified_files[filepath] = monotonic()
if self.stop_flag.is_set(): # Check the files against the timeout
logger.debug("Finishing because event is set") still_waiting = {}
finished = True # last_event_time is time of the last inotify event for this file
for filepath, last_event_time in notified_files.items():
# Current time - last time over the configured timeout
waited_long_enough = (
monotonic() - last_event_time
) > inotify_debounce_secs
except KeyboardInterrupt: # Also make sure the file exists still, some scanners might write a
logger.info("Received SIGINT, stopping inotify") # temporary file first
file_still_exists = os.path.exists(filepath) and os.path.isfile(
filepath,
)
if waited_long_enough and file_still_exists:
_consume(filepath)
elif file_still_exists:
still_waiting[filepath] = last_event_time
# These files are still waiting to hit the timeout
notified_files = still_waiting
# If files are waiting, need to exit read() to check them
# Otherwise, go back to infinite sleep time, but only if not testing
if len(notified_files) > 0:
timeout_ms = inotify_debounce_ms
elif is_testing:
timeout_ms = self.testing_timeout_ms
else:
timeout_ms = None
if self.stop_flag.is_set():
logger.debug("Finishing because event is set")
finished = True finished = True
finally:
inotify.close() except KeyboardInterrupt:
logger.info("Received SIGINT, stopping inotify")
finished = True
inotify.rm_watch(descriptor)
inotify.close()

View File

@ -70,59 +70,57 @@ def set_permissions_for_object(permissions: list[str], object, *, merge: bool =
for action in permissions: for action in permissions:
permission = f"{action}_{object.__class__.__name__.lower()}" permission = f"{action}_{object.__class__.__name__.lower()}"
if "users" in permissions[action]: # users
# users users_to_add = User.objects.filter(id__in=permissions[action]["users"])
users_to_add = User.objects.filter(id__in=permissions[action]["users"]) users_to_remove = (
users_to_remove = ( get_users_with_perms(
get_users_with_perms( object,
object, only_with_perms_in=[permission],
only_with_perms_in=[permission], with_group_users=False,
with_group_users=False,
)
if not merge
else User.objects.none()
) )
if len(users_to_add) > 0 and len(users_to_remove) > 0: if not merge
users_to_remove = users_to_remove.exclude(id__in=users_to_add) else User.objects.none()
if len(users_to_remove) > 0: )
for user in users_to_remove: if len(users_to_add) > 0 and len(users_to_remove) > 0:
remove_perm(permission, user, object) users_to_remove = users_to_remove.exclude(id__in=users_to_add)
if len(users_to_add) > 0: if len(users_to_remove) > 0:
for user in users_to_add: for user in users_to_remove:
assign_perm(permission, user, object) remove_perm(permission, user, object)
if action == "change": if len(users_to_add) > 0:
# change gives view too for user in users_to_add:
assign_perm( assign_perm(permission, user, object)
f"view_{object.__class__.__name__.lower()}", if action == "change":
user, # change gives view too
object, assign_perm(
) f"view_{object.__class__.__name__.lower()}",
if "groups" in permissions[action]: user,
# groups object,
groups_to_add = Group.objects.filter(id__in=permissions[action]["groups"]) )
groups_to_remove = ( # groups
get_groups_with_only_permission( groups_to_add = Group.objects.filter(id__in=permissions[action]["groups"])
object, groups_to_remove = (
permission, get_groups_with_only_permission(
) object,
if not merge permission,
else Group.objects.none()
) )
if len(groups_to_add) > 0 and len(groups_to_remove) > 0: if not merge
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add) else Group.objects.none()
if len(groups_to_remove) > 0: )
for group in groups_to_remove: if len(groups_to_add) > 0 and len(groups_to_remove) > 0:
remove_perm(permission, group, object) groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
if len(groups_to_add) > 0: if len(groups_to_remove) > 0:
for group in groups_to_add: for group in groups_to_remove:
assign_perm(permission, group, object) remove_perm(permission, group, object)
if action == "change": if len(groups_to_add) > 0:
# change gives view too for group in groups_to_add:
assign_perm( assign_perm(permission, group, object)
f"view_{object.__class__.__name__.lower()}", if action == "change":
group, # change gives view too
object, assign_perm(
) f"view_{object.__class__.__name__.lower()}",
group,
object,
)
def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet: def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet:

View File

@ -43,7 +43,6 @@ from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import MatchingModel from documents.models import MatchingModel
from documents.models import Note
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import SavedView from documents.models import SavedView
from documents.models import SavedViewFilterRule from documents.models import SavedViewFilterRule
@ -160,24 +159,24 @@ class SetPermissionsMixin:
def validate_set_permissions(self, set_permissions=None): def validate_set_permissions(self, set_permissions=None):
permissions_dict = { permissions_dict = {
"view": {}, "view": {
"change": {}, "users": User.objects.none(),
"groups": Group.objects.none(),
},
"change": {
"users": User.objects.none(),
"groups": Group.objects.none(),
},
} }
if set_permissions is not None: if set_permissions is not None:
for action in ["view", "change"]: for action, _ in permissions_dict.items():
if action in set_permissions: if action in set_permissions:
if "users" in set_permissions[action]: users = set_permissions[action]["users"]
users = set_permissions[action]["users"] permissions_dict[action]["users"] = self._validate_user_ids(users)
permissions_dict[action]["users"] = self._validate_user_ids( groups = set_permissions[action]["groups"]
users, permissions_dict[action]["groups"] = self._validate_group_ids(
) groups,
if "groups" in set_permissions[action]: )
groups = set_permissions[action]["groups"]
permissions_dict[action]["groups"] = self._validate_group_ids(
groups,
)
else:
del permissions_dict[action]
return permissions_dict return permissions_dict
def _set_permissions(self, permissions, object): def _set_permissions(self, permissions, object):
@ -422,6 +421,15 @@ class OwnedObjectListSerializer(serializers.ListSerializer):
return super().to_representation(documents) return super().to_representation(documents)
class GetAPIVersionMixin:
def get_api_version(self):
return int(
self.context.get("request").version
if self.context.get("request")
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
)
class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer): class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer):
last_correspondence = serializers.DateTimeField(read_only=True, required=False) last_correspondence = serializers.DateTimeField(read_only=True, required=False)
@ -718,7 +726,7 @@ class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
return {self.field_name: data} return {self.field_name: data}
class CustomFieldInstanceSerializer(serializers.ModelSerializer): class CustomFieldInstanceSerializer(serializers.ModelSerializer, GetAPIVersionMixin):
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all()) field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True) value = ReadWriteSerializerMethodField(allow_null=True)
@ -810,13 +818,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
return data return data
def get_api_version(self):
return int(
self.context.get("request").version
if self.context.get("request")
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
)
def to_internal_value(self, data): def to_internal_value(self, data):
ret = super().to_internal_value(data) ret = super().to_internal_value(data)
@ -862,22 +863,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
] ]
class BasicUserSerializer(serializers.ModelSerializer):
# Different than paperless.serializers.UserSerializer
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name"]
class NotesSerializer(serializers.ModelSerializer):
user = BasicUserSerializer(read_only=True)
class Meta:
model = Note
fields = ["id", "note", "created", "user"]
ordering = ["-created"]
class DocumentSerializer( class DocumentSerializer(
OwnedObjectSerializer, OwnedObjectSerializer,
NestedUpdateMixin, NestedUpdateMixin,
@ -893,8 +878,6 @@ class DocumentSerializer(
created_date = serializers.DateField(required=False) created_date = serializers.DateField(required=False)
page_count = SerializerMethodField() page_count = SerializerMethodField()
notes = NotesSerializer(many=True, required=False, read_only=True)
custom_fields = CustomFieldInstanceSerializer( custom_fields = CustomFieldInstanceSerializer(
many=True, many=True,
allow_null=False, allow_null=False,
@ -1728,7 +1711,7 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
return ui_settings return ui_settings
class TasksViewSerializer(OwnedObjectSerializer): class TasksViewSerializer(OwnedObjectSerializer, GetAPIVersionMixin):
class Meta: class Meta:
model = PaperlessTask model = PaperlessTask
fields = ( fields = (
@ -1771,6 +1754,13 @@ class TasksViewSerializer(OwnedObjectSerializer):
return result return result
def to_representation(self, instance: PaperlessTask):
result = super().to_representation(instance)
if self.get_api_version() < 8:
# Older versions only returned file tasks (filtering handled in view) and had different naming scheme
result["type"] = "file"
return result
class RunTaskViewSerializer(serializers.Serializer): class RunTaskViewSerializer(serializers.Serializer):
task_name = serializers.ChoiceField( task_name = serializers.ChoiceField(

View File

@ -1162,7 +1162,7 @@ def run_workflows(
) as f: ) as f:
files = { files = {
"file": ( "file": (
filename, document.original_filename,
f.read(), f.read(),
document.mime_type, document.mime_type,
), ),

View File

@ -6,7 +6,7 @@
{% endblock head_title %} {% endblock head_title %}
{% block form_top_content %} {% block form_top_content %}
<h4>{% translate "Account inactive." %}</h4> <h4>{% translate "Account inactve." %}</h4>
{% endblock form_top_content %} {% endblock form_top_content %}
{% block form_content %} {% block form_content %}

View File

@ -2170,10 +2170,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
GIVEN: GIVEN:
- A document with a single note - A document with a single note
WHEN: WHEN:
- API request for document
- API request for document notes is made - API request for document notes is made
THEN: THEN:
- Note is included in the document response
- The associated note is returned - The associated note is returned
""" """
doc = Document.objects.create( doc = Document.objects.create(
@ -2187,18 +2185,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
user=self.user, user=self.user,
) )
response = self.client.get(
f"/api/documents/{doc.pk}/",
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
resp_data = response.json()
self.assertEqual(len(resp_data["notes"]), 1)
self.assertEqual(resp_data["notes"][0]["note"], note.note)
self.assertEqual(resp_data["notes"][0]["user"]["username"], self.user.username)
response = self.client.get( response = self.client.get(
f"/api/documents/{doc.pk}/notes/", f"/api/documents/{doc.pk}/notes/",
format="json", format="json",

View File

@ -395,52 +395,6 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
self.assertTrue(checker.has_perm("view_document", doc)) self.assertTrue(checker.has_perm("view_document", doc))
self.assertIn("view_document", get_perms(group1, doc)) self.assertIn("view_document", get_perms(group1, doc))
def test_patch_doesnt_remove_permissions(self):
"""
GIVEN:
- existing document with permissions set
WHEN:
- PATCH API request to update doc that is not json
THEN:
- Object permissions are not removed
"""
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document",
)
user1 = User.objects.create_superuser(username="user1")
user2 = User.objects.create(username="user2")
group1 = Group.objects.create(name="group1")
doc.owner = user1
doc.save()
assign_perm("view_document", user2, doc)
assign_perm("change_document", user2, doc)
assign_perm("view_document", group1, doc)
assign_perm("change_document", group1, doc)
self.client.force_authenticate(user1)
response = self.client.patch(
f"/api/documents/{doc.id}/",
{
"archive_serial_number": "123",
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
doc = Document.objects.get(pk=doc.id)
self.assertEqual(doc.owner, user1)
from guardian.core import ObjectPermissionChecker
checker = ObjectPermissionChecker(user2)
self.assertTrue(checker.has_perm("view_document", doc))
self.assertIn("view_document", get_perms(group1, doc))
self.assertTrue(checker.has_perm("change_document", doc))
self.assertIn("change_document", get_perms(group1, doc))
def test_dynamic_permissions_fields(self): def test_dynamic_permissions_fields(self):
user1 = User.objects.create_user(username="user1") user1 = User.objects.create_user(username="user1")
user1.user_permissions.add(*Permission.objects.filter(codename="view_document")) user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))

View File

@ -370,3 +370,45 @@ class TestTasks(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
mock_check_sanity.assert_not_called() mock_check_sanity.assert_not_called()
def test_handle_older_api_version(self):
"""
GIVEN:
- A request from the API with version < 8
WHEN:
- Tasks are requested
THEN:
- Only consume file tasks are returned and the type is 'file'
"""
task1 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_one.pdf",
task_name=PaperlessTask.TaskName.CONSUME_FILE,
)
task2 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_name=PaperlessTask.TaskName.CHECK_SANITY,
type=PaperlessTask.TaskType.AUTO,
)
response = self.client.get(
self.ENDPOINT,
headers={"Accept": "application/json; version=7"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["task_id"], task1.task_id)
self.assertEqual(response.data[0]["type"], "file")
response = self.client.get(
self.ENDPOINT,
headers={"Accept": "application/json; version=8"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]["task_id"], task2.task_id)
self.assertEqual(response.data[0]["type"], PaperlessTask.TaskType.AUTO)

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