mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-23 20:34:56 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4bc8d721c | ||
|
|
f549e1818f |
@@ -129,8 +129,8 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Run lint
|
||||
run: cd src-ui && pnpm run lint
|
||||
unit-tests:
|
||||
@@ -168,8 +168,8 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Run Jest unit tests
|
||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
- name: Upload test results to Codecov
|
||||
@@ -223,18 +223,15 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --no-frozen-lockfile
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Run Playwright E2E tests
|
||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
bundle-analysis:
|
||||
name: Bundle Analysis
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
needs: [changes, unit-tests, e2e-tests]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
environment: bundle-analysis
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -260,21 +257,19 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Build and analyze
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Build
|
||||
run: cd src-ui && pnpm run build --configuration=production
|
||||
gate:
|
||||
name: Frontend CI Gate
|
||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
|
||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
|
||||
if: always()
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Check gate
|
||||
env:
|
||||
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
|
||||
BUILD_RESULT: ${{ needs['frontend-build'].result }}
|
||||
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
||||
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
||||
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
||||
@@ -306,8 +301,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
|
||||
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
|
||||
if [[ "${BUILD_RESULT}" != "success" ]]; then
|
||||
echo "::error::Frontend build job result: ${BUILD_RESULT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ jobs:
|
||||
check-name: 'Merge and Push Manifest'
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
wait-interval: 60
|
||||
checks-discovery-timeout: 1800
|
||||
build-release:
|
||||
name: Build Release
|
||||
needs: wait-for-docker
|
||||
|
||||
@@ -61,10 +61,7 @@ jobs:
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install frontend dependencies
|
||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
||||
run: cd src-ui && pnpm install
|
||||
- name: Re-link Angular cli
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Generate frontend translation strings
|
||||
run: |
|
||||
cd src-ui
|
||||
|
||||
@@ -38,7 +38,7 @@ repos:
|
||||
- json
|
||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: 'v3.9.4'
|
||||
rev: 'v3.8.4'
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or:
|
||||
@@ -46,16 +46,16 @@ repos:
|
||||
- ts
|
||||
- markdown
|
||||
additional_dependencies:
|
||||
- prettier@3.9.4
|
||||
- prettier@3.8.3
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.20
|
||||
rev: v0.15.17
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.25.1"
|
||||
rev: "v2.24.1"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
additional_dependencies: [tomli]
|
||||
|
||||
+1
-5
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
@@ -238,10 +238,6 @@ RUN set -eux \
|
||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
|
||||
&& chmod 1777 /var/cache/fontconfig \
|
||||
&& echo "Making /run world-writable for rootless operation" \
|
||||
&& chmod 1777 /run \
|
||||
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
|
||||
&& chmod u-s /command/s6-overlay-suexec \
|
||||
&& echo "Collecting static files" \
|
||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
|
||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.9
|
||||
image: docker.io/greenmail/standalone:2.1.8
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.31.2-alpine
|
||||
image: docker.io/nginx:1.31.1-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
+1
-926
File diff suppressed because it is too large
Load Diff
+9
-11
@@ -1159,21 +1159,19 @@ still perform some basic text pre-processing before matching.
|
||||
|
||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||
|
||||
: Specifies which language Paperless should use when parsing dates from documents.
|
||||
Specifies which language Paperless should use when parsing dates from documents.
|
||||
|
||||
: This should be a language code supported by the dateparser library,
|
||||
for example: "en", or a combination such as "en+de".
|
||||
Locales are also supported (e.g., "en-AU").
|
||||
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
|
||||
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
|
||||
|
||||
: Set this to match the languages in which most of your documents are written.
|
||||
This should be a language code supported by the dateparser library,
|
||||
for example: "en", or a combination such as "en+de".
|
||||
Locales are also supported (e.g., "en-AU").
|
||||
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
|
||||
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
|
||||
|
||||
Set this to match the languages in which most of your documents are written.
|
||||
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
|
||||
|
||||
!!! note
|
||||
|
||||
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
|
||||
!!! note
|
||||
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
|
||||
|
||||
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never
|
||||
has no effect for documents handled by the remote parser — the archive is produced
|
||||
unconditionally by the remote engine.
|
||||
|
||||
## Search Index (Whoosh -> Tantivy)
|
||||
# Search Index (Whoosh -> Tantivy)
|
||||
|
||||
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
|
||||
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
|
||||
@@ -326,11 +326,3 @@ behind a reverse proxy may need to set
|
||||
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
|
||||
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
|
||||
or both, to avoid `403 Forbidden` errors on login.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
|
||||
|
||||
### Action Required
|
||||
|
||||
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
|
||||
|
||||
+3
-3
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
|
||||
|
||||
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||
|
||||
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported:
|
||||
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
|
||||
|
||||
```yaml
|
||||
webserver:
|
||||
@@ -157,8 +157,8 @@ Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for
|
||||
Some file systems, such as NFS network shares, don't support file system
|
||||
notifications with `inotify`. When the consumption directory is on such a
|
||||
file system, Paperless-ngx will not pick up new files with the default
|
||||
configuration. Set [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL)
|
||||
to a positive number to enable polling and disable native filesystem notifications.
|
||||
configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
|
||||
to enable polling and disable inotify. See [here](configuration.md#polling).
|
||||
|
||||
## Bare Metal Install {#bare_metal}
|
||||
|
||||
|
||||
@@ -1,839 +0,0 @@
|
||||
# Export Zip Compression Control Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add `--zip-compression {stored,deflated,bzip2,lzma,zstd}` and `--zip-compression-level N` flags to `document_exporter`, threaded into `ZipExportSink`, with import-side safety for codecs the running Python can't read.
|
||||
|
||||
**Architecture:** A new pure-data module `documents/export/compression.py` owns the method↔constant map, per-method level bounds, the runtime availability probe, and a compress-type readability check. `ZipExportSink` gains `compression`/`compresslevel` constructor params. The command validates flags up front (fail-fast `CommandError`) and constructs the sink; the importer pre-checks entry compress types before extracting.
|
||||
|
||||
**Tech Stack:** Python ≥3.11 (zstd only on 3.14+), `zipfile`, `compression.zstd` (PEP 784), pytest + pytest-mock + factory-boy. Backend tests run on the Linux VM (Python 3.11 — zstd positive tests are `skipif`-guarded); `ruff` runs locally.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-16-export-zip-compression-design.md`
|
||||
|
||||
**PREREQUISITE:** The base refactor `docs/superpowers/plans/2026-06-16-export-sink-architecture.md` MUST be merged first. This plan assumes `src/documents/export/sinks.py` exists with `ZipExportSink(target, zip_name, *, delete=False)` opening its `ZipFile` in `_open()`.
|
||||
|
||||
---
|
||||
|
||||
## Verified facts (CPython 3.14.3, via `uv run --python 3.14 --no-project`)
|
||||
|
||||
- Constants: `ZIP_STORED=0`, `ZIP_DEFLATED=8`, `ZIP_BZIP2=12`, `ZIP_LZMA=14`, `ZIP_ZSTANDARD=93` (zstd added 3.14; absent on < 3.14).
|
||||
- `ZipFile(file, "w", compression=…, compresslevel=…)` applies both as the default for every `write`/`writestr` — no per-entry args needed (verified).
|
||||
- Level bounds: `deflated` 0–9, `bzip2` 1–9, `lzma`/`stored` ignore level, `zstd` -131072…22 (`compression.zstd.CompressionParameter.compression_level.bounds() == (-131072, 22)`).
|
||||
- An invalid level fails at the **first write** (`ValueError: Invalid initialization option` / `compresslevel must be between 1 and 9`), plus GC-time `AttributeError` noise on close — hence up-front validation.
|
||||
- zstd is backed by `compression.zstd`; `zipfile` raises `RuntimeError` if it's unavailable.
|
||||
|
||||
## Conventions for every task
|
||||
|
||||
- **Run backend tests on the VM:** `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "<targets>"` (never locally).
|
||||
- **Lint locally:** `ruff check <paths> && ruff format <paths>` (global ruff, not `uv run`).
|
||||
- **Tests are pytest-style:** classes, `@pytest.mark.django_db` on the class only where DB is needed (the `compression.py` and sink tests need no DB), factory-boy, `mocker`, `parametrize`, full type annotations.
|
||||
- The VM runs Python 3.11, so **zstd positive tests must be `@pytest.mark.skipif(...)`-guarded**; they will simply not run there. zstd _rejection_ tests (the < 3.14 path) DO run on the VM.
|
||||
|
||||
## File structure
|
||||
|
||||
- **Create** `src/documents/export/compression.py` — method map, CLI choices, level bounds, `compression_available()`, `level_error()`, `compress_type_readable()`, `unreadable_method_names()`. Pure, no Django.
|
||||
- **Create** `src/documents/tests/export/test_compression.py` — unit tests for the above.
|
||||
- **Modify** `src/documents/export/sinks.py` — `ZipExportSink.__init__` gains `compression`/`compresslevel`; `_open()` passes them to `ZipFile`.
|
||||
- **Modify** `src/documents/tests/export/test_sinks.py` — assert the chosen `compress_type` is applied.
|
||||
- **Modify** `src/documents/management/commands/document_exporter.py` — add the two CLI flags, up-front validation, and pass resolved values to `ZipExportSink`.
|
||||
- **Modify** `src/documents/tests/test_management_exporter.py` — flag validation + default-unchanged tests.
|
||||
- **Modify** `src/documents/management/commands/document_importer.py` — pre-extract compress-type check.
|
||||
- **Modify** `src/documents/tests/test_management_importer.py` — unsupported-codec → `CommandError`.
|
||||
- **Modify** `docs/administration.md` — document both flags + zstd portability caveat.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `documents/export/compression.py` (pure compression policy)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/documents/export/compression.py`
|
||||
- Test: `src/documents/tests/export/test_compression.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `src/documents/tests/export/test_compression.py`:
|
||||
|
||||
```python
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from documents.export import compression
|
||||
|
||||
|
||||
class TestCompressionMethods:
|
||||
def test_choices_always_include_zstd(self) -> None:
|
||||
# zstd is offered regardless of runtime; availability is checked separately
|
||||
assert compression.COMPRESSION_CHOICES == (
|
||||
"stored",
|
||||
"deflated",
|
||||
"bzip2",
|
||||
"lzma",
|
||||
"zstd",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "constant"),
|
||||
[
|
||||
("stored", zipfile.ZIP_STORED),
|
||||
("deflated", zipfile.ZIP_DEFLATED),
|
||||
("bzip2", zipfile.ZIP_BZIP2),
|
||||
("lzma", zipfile.ZIP_LZMA),
|
||||
],
|
||||
)
|
||||
def test_method_maps_to_zipfile_constant(self, name: str, constant: int) -> None:
|
||||
assert compression.COMPRESSION_METHODS[name] == constant
|
||||
|
||||
def test_stored_and_deflated_always_available(self) -> None:
|
||||
assert compression.compression_available("stored")
|
||||
assert compression.compression_available("deflated")
|
||||
|
||||
def test_zstd_availability_tracks_runtime(self) -> None:
|
||||
expected: bool = sys.version_info >= (3, 14)
|
||||
assert compression.compression_available("zstd") == expected
|
||||
|
||||
|
||||
class TestLevelError:
|
||||
@pytest.mark.parametrize(
|
||||
("method", "level"),
|
||||
[
|
||||
("deflated", 0),
|
||||
("deflated", 9),
|
||||
("bzip2", 1),
|
||||
("bzip2", 9),
|
||||
("deflated", None),
|
||||
("stored", None),
|
||||
],
|
||||
)
|
||||
def test_valid_levels_return_none(self, method: str, level: int | None) -> None:
|
||||
assert compression.level_error(method, level) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "level"),
|
||||
[
|
||||
("deflated", 10),
|
||||
("deflated", -1),
|
||||
("bzip2", 0),
|
||||
("bzip2", 10),
|
||||
],
|
||||
)
|
||||
def test_out_of_range_levels_return_message(
|
||||
self,
|
||||
method: str,
|
||||
level: int,
|
||||
) -> None:
|
||||
msg: str | None = compression.level_error(method, level)
|
||||
assert msg is not None
|
||||
assert "between" in msg
|
||||
|
||||
@pytest.mark.parametrize("method", ["stored", "lzma"])
|
||||
def test_level_on_levelless_method_is_rejected(self, method: str) -> None:
|
||||
msg: str | None = compression.level_error(method, 5)
|
||||
assert msg is not None
|
||||
assert "no effect" in msg
|
||||
|
||||
|
||||
class TestCompressTypeReadable:
|
||||
@pytest.mark.parametrize("ct", [zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED])
|
||||
def test_stored_and_deflated_always_readable(self, ct: int) -> None:
|
||||
assert compression.compress_type_readable(ct)
|
||||
|
||||
def test_zstd_compress_type_readability_tracks_runtime(self) -> None:
|
||||
# 93 = ZIP_ZSTANDARD; 20 = legacy zstd method id (read-only)
|
||||
expected: bool = sys.version_info >= (3, 14)
|
||||
assert compression.compress_type_readable(93) == expected
|
||||
assert compression.compress_type_readable(20) == expected
|
||||
|
||||
def test_unknown_compress_type_is_unreadable(self) -> None:
|
||||
assert not compression.compress_type_readable(9999)
|
||||
|
||||
def test_unreadable_method_names_lists_methods(self) -> None:
|
||||
# An unknown method id maps to no name and is reported generically.
|
||||
names: set[str] = compression.unreadable_method_names({9999})
|
||||
assert names == {"method 9999"}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_compression.py -v"`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'documents.export.compression'`.
|
||||
|
||||
- [ ] **Step 3: Implement `compression.py`**
|
||||
|
||||
Create `src/documents/export/compression.py`:
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import zipfile
|
||||
|
||||
# ZIP_ZSTANDARD exists only on Python 3.14+ (PEP 784). None elsewhere.
|
||||
ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None)
|
||||
|
||||
# CLI choices are fixed across runtimes so argparse never hides zstd; runtime
|
||||
# availability is enforced separately in compression_available().
|
||||
COMPRESSION_CHOICES: tuple[str, ...] = (
|
||||
"stored",
|
||||
"deflated",
|
||||
"bzip2",
|
||||
"lzma",
|
||||
"zstd",
|
||||
)
|
||||
|
||||
# Method name -> zipfile compression constant (zstd only when supported).
|
||||
COMPRESSION_METHODS: dict[str, int] = {
|
||||
"stored": zipfile.ZIP_STORED,
|
||||
"deflated": zipfile.ZIP_DEFLATED,
|
||||
"bzip2": zipfile.ZIP_BZIP2,
|
||||
"lzma": zipfile.ZIP_LZMA,
|
||||
}
|
||||
if ZSTD is not None:
|
||||
COMPRESSION_METHODS["zstd"] = ZSTD
|
||||
|
||||
# Inclusive (min, max) level bounds per method; None => level not applicable.
|
||||
# Verified on CPython 3.14.3.
|
||||
LEVEL_BOUNDS: dict[str, tuple[int, int] | None] = {
|
||||
"stored": None,
|
||||
"deflated": (0, 9),
|
||||
"bzip2": (1, 9),
|
||||
"lzma": None,
|
||||
"zstd": (-131072, 22),
|
||||
}
|
||||
|
||||
# zipfile compress_type id -> method name. 93 = current zstd id, 20 = legacy
|
||||
# zstd id that zipfile can still read.
|
||||
_COMPRESS_TYPE_TO_METHOD: dict[int, str] = {
|
||||
zipfile.ZIP_STORED: "stored",
|
||||
zipfile.ZIP_DEFLATED: "deflated",
|
||||
zipfile.ZIP_BZIP2: "bzip2",
|
||||
zipfile.ZIP_LZMA: "lzma",
|
||||
93: "zstd",
|
||||
20: "zstd",
|
||||
}
|
||||
|
||||
|
||||
def compression_available(method: str) -> bool:
|
||||
"""Whether the running interpreter can actually use the given method."""
|
||||
if method in ("stored", "deflated"):
|
||||
# zlib is a hard CPython dependency; stored needs nothing.
|
||||
return True
|
||||
if method == "bzip2":
|
||||
return _module_importable("bz2")
|
||||
if method == "lzma":
|
||||
return _module_importable("lzma")
|
||||
if method == "zstd":
|
||||
return ZSTD is not None and _module_importable("compression.zstd")
|
||||
return False
|
||||
|
||||
|
||||
def _module_importable(name: str) -> bool:
|
||||
try:
|
||||
importlib.import_module(name)
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def level_error(method: str, level: int | None) -> str | None:
|
||||
"""Return a human message if (method, level) is invalid, else None."""
|
||||
if level is None:
|
||||
return None
|
||||
bounds = LEVEL_BOUNDS[method]
|
||||
if bounds is None:
|
||||
return f"--zip-compression-level has no effect for '{method}'"
|
||||
low, high = bounds
|
||||
if not (low <= level <= high):
|
||||
return (
|
||||
f"--zip-compression-level for '{method}' must be between "
|
||||
f"{low} and {high}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def compress_type_readable(compress_type: int) -> bool:
|
||||
"""Whether this interpreter can decompress an entry of the given type."""
|
||||
method = _COMPRESS_TYPE_TO_METHOD.get(compress_type)
|
||||
if method is None:
|
||||
return False
|
||||
return compression_available(method)
|
||||
|
||||
|
||||
def unreadable_method_names(compress_types: set[int]) -> set[str]:
|
||||
"""Map a set of compress_type ids to human method names for error messages."""
|
||||
names: set[str] = set()
|
||||
for ct in compress_types:
|
||||
names.add(_COMPRESS_TYPE_TO_METHOD.get(ct, f"method {ct}"))
|
||||
return names
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_compression.py -v"`
|
||||
Expected: PASS (on the 3.11 VM, `test_zstd_availability_tracks_runtime` and `test_zstd_compress_type_readability_tracks_runtime` assert `False`).
|
||||
|
||||
- [ ] **Step 5: Lint**
|
||||
|
||||
Run: `ruff check src/documents/export/compression.py src/documents/tests/export/test_compression.py && ruff format src/documents/export/compression.py src/documents/tests/export/test_compression.py`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/documents/export/compression.py src/documents/tests/export/test_compression.py
|
||||
git commit -m "Feature: add export compression policy module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `ZipExportSink` accepts compression method + level
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/documents/export/sinks.py`
|
||||
- Test: `src/documents/tests/export/test_sinks.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `src/documents/tests/export/test_sinks.py` (the top-of-file block already imports `zipfile`, `Path`, `pytest`, `ZipExportSink`, `StreamingManifestWriter` from the base-refactor plan):
|
||||
|
||||
```python
|
||||
class TestZipExportSinkCompression:
|
||||
@pytest.fixture()
|
||||
def source_file(self, tmp_path: Path) -> Path:
|
||||
src: Path = tmp_path / "src" / "doc.pdf"
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_bytes(b"PDF-CONTENT" * 100)
|
||||
return src
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "constant"),
|
||||
[
|
||||
("stored", zipfile.ZIP_STORED),
|
||||
("deflated", zipfile.ZIP_DEFLATED),
|
||||
("bzip2", zipfile.ZIP_BZIP2),
|
||||
("lzma", zipfile.ZIP_LZMA),
|
||||
],
|
||||
)
|
||||
def test_compression_method_is_applied_to_file_entries(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
method: str,
|
||||
constant: int,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with ZipExportSink(
|
||||
target,
|
||||
"export",
|
||||
delete=False,
|
||||
compression=constant,
|
||||
) as sink:
|
||||
sink.add_file(source_file, "doc.pdf")
|
||||
with zipfile.ZipFile(target / "export.zip") as zf:
|
||||
info = zf.getinfo("doc.pdf")
|
||||
assert info.compress_type == constant
|
||||
|
||||
def test_compressing_method_beats_stored(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
# Robust size invariant: a compressing method must be <= stored on
|
||||
# compressible content (avoids flaky level-9-vs-level-1 comparisons).
|
||||
sizes: dict[str, int] = {}
|
||||
for name, constant in (("stored", zipfile.ZIP_STORED), ("deflated", zipfile.ZIP_DEFLATED)):
|
||||
target: Path = tmp_path / name
|
||||
target.mkdir()
|
||||
with ZipExportSink(target, "export", delete=False, compression=constant) as sink:
|
||||
sink.add_file(source_file, "doc.pdf")
|
||||
sizes[name] = (target / "export.zip").stat().st_size
|
||||
assert sizes["deflated"] <= sizes["stored"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_sinks.py::TestZipExportSinkCompression -v"`
|
||||
Expected: FAIL with `TypeError: __init__() got an unexpected keyword argument 'compression'`.
|
||||
|
||||
- [ ] **Step 3: Add the params to `ZipExportSink`**
|
||||
|
||||
In `src/documents/export/sinks.py`, change `ZipExportSink.__init__` to accept the new keyword-only params and store them, and pass them in `_open()`:
|
||||
|
||||
```python
|
||||
def __init__(
|
||||
self,
|
||||
target: Path,
|
||||
zip_name: str,
|
||||
*,
|
||||
delete: bool = False,
|
||||
compression: int = zipfile.ZIP_DEFLATED,
|
||||
compresslevel: int | None = None,
|
||||
) -> None:
|
||||
self._target = target.resolve()
|
||||
self._zip_path = (self._target / zip_name).with_suffix(".zip")
|
||||
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
|
||||
self._delete = delete
|
||||
self._compression = compression
|
||||
self._compresslevel = compresslevel
|
||||
self._zip: zipfile.ZipFile | None = None
|
||||
self._dirs: set[str] = set()
|
||||
self._pending_manifest: tuple[Path, str] | None = None
|
||||
self._stream_open = False
|
||||
```
|
||||
|
||||
And in `_open()`:
|
||||
|
||||
```python
|
||||
def _open(self) -> None:
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self._zip = zipfile.ZipFile(
|
||||
self._tmp_path,
|
||||
"w",
|
||||
compression=self._compression,
|
||||
compresslevel=self._compresslevel,
|
||||
allowZip64=True,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_sinks.py -v"`
|
||||
Expected: PASS (all sink tests, including the four method params and the size invariant). `bzip2`/`lzma` are present on the VM's CPython, so those params pass.
|
||||
|
||||
- [ ] **Step 5: Lint**
|
||||
|
||||
Run: `ruff check src/documents/export/sinks.py && ruff format src/documents/export/sinks.py`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/documents/export/sinks.py src/documents/tests/export/test_sinks.py
|
||||
git commit -m "Feature: ZipExportSink accepts compression method and level"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire CLI flags + validation into `document_exporter`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/documents/management/commands/document_exporter.py`
|
||||
- Test: `src/documents/tests/test_management_exporter.py`
|
||||
|
||||
- [ ] **Step 1: Add the argparse flags**
|
||||
|
||||
In `document_exporter.py`, add the import near the other `documents.export` import:
|
||||
|
||||
```python
|
||||
from documents.export.compression import COMPRESSION_CHOICES
|
||||
from documents.export.compression import COMPRESSION_METHODS
|
||||
from documents.export.compression import compression_available
|
||||
from documents.export.compression import level_error
|
||||
from documents.export.compression import ZSTD
|
||||
```
|
||||
|
||||
In `add_arguments`, after the `--zip-name` argument, add:
|
||||
|
||||
```python
|
||||
parser.add_argument(
|
||||
"--zip-compression",
|
||||
choices=COMPRESSION_CHOICES,
|
||||
default=None,
|
||||
help=(
|
||||
"Compression method for the export zip (requires --zip). "
|
||||
"Default: deflated. 'zstd' requires Python 3.14+ on both the "
|
||||
"exporting and importing machine."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--zip-compression-level",
|
||||
type=int,
|
||||
default=None,
|
||||
help=(
|
||||
"Compression level for the export zip (requires --zip). "
|
||||
"deflated: 0-9, bzip2: 1-9, zstd: -131072..22; ignored for "
|
||||
"stored/lzma."
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Read + validate the flags in `handle()`**
|
||||
|
||||
In `handle()`, after the existing `--compare-*` + `--zip` guard, add the compression flag handling. Insert before the sink construction:
|
||||
|
||||
```python
|
||||
zip_compression: str | None = options["zip_compression"]
|
||||
zip_compression_level: int | None = options["zip_compression_level"]
|
||||
|
||||
if not self.zip_export and (
|
||||
zip_compression is not None or zip_compression_level is not None
|
||||
):
|
||||
raise CommandError(
|
||||
"--zip-compression and --zip-compression-level require --zip",
|
||||
)
|
||||
|
||||
compression_method = zip_compression or "deflated"
|
||||
if self.zip_export:
|
||||
if not compression_available(compression_method):
|
||||
if compression_method == "zstd" and ZSTD is None:
|
||||
raise CommandError(
|
||||
"zstd compression requires Python 3.14 or newer",
|
||||
)
|
||||
raise CommandError(
|
||||
f"Compression method '{compression_method}' is not "
|
||||
f"available on this Python runtime",
|
||||
)
|
||||
level_msg = level_error(compression_method, zip_compression_level)
|
||||
if level_msg is not None:
|
||||
raise CommandError(level_msg)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass the resolved values into `ZipExportSink`**
|
||||
|
||||
Change the `ZipExportSink(...)` construction in `handle()` to:
|
||||
|
||||
```python
|
||||
if self.zip_export:
|
||||
sink = ZipExportSink(
|
||||
self.target,
|
||||
options["zip_name"],
|
||||
delete=self.delete,
|
||||
compression=COMPRESSION_METHODS[compression_method],
|
||||
compresslevel=zip_compression_level,
|
||||
)
|
||||
else:
|
||||
sink = DirectoryExportSink(
|
||||
self.target,
|
||||
compare_checksums=self.compare_checksums,
|
||||
compare_json=self.compare_json,
|
||||
delete=self.delete,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the command-level tests**
|
||||
|
||||
Add to the `TestExportImport` class in `src/documents/tests/test_management_exporter.py` (imports `call_command`, `CommandError`, `ZipFile`, `timezone` already present):
|
||||
|
||||
```python
|
||||
def test_compression_flags_require_zip(self) -> None:
|
||||
for args in (
|
||||
["--zip-compression", "lzma"],
|
||||
["--zip-compression-level", "5"],
|
||||
):
|
||||
with self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
*args,
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
def test_zip_compression_level_out_of_range_raises(self) -> None:
|
||||
with self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"deflated",
|
||||
"--zip-compression-level",
|
||||
"99",
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
def test_zip_compression_level_rejected_for_stored(self) -> None:
|
||||
with self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"stored",
|
||||
"--zip-compression-level",
|
||||
"5",
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
def test_zip_lzma_compression_round_trips(self) -> None:
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"lzma",
|
||||
skip_checks=True,
|
||||
)
|
||||
expected = str(
|
||||
self.target / f"export-{timezone.localdate().isoformat()}.zip",
|
||||
)
|
||||
self.assertIsFile(expected)
|
||||
with ZipFile(expected) as zip_file:
|
||||
info = zip_file.getinfo("manifest.json")
|
||||
# manifest.json carries the chosen method; deflated is the default
|
||||
self.assertEqual(info.compress_type, 14) # ZIP_LZMA
|
||||
|
||||
def test_default_zip_uses_deflate(self) -> None:
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
skip_checks=True,
|
||||
)
|
||||
expected = str(
|
||||
self.target / f"export-{timezone.localdate().isoformat()}.zip",
|
||||
)
|
||||
with ZipFile(expected) as zip_file:
|
||||
info = zip_file.getinfo("manifest.json")
|
||||
self.assertEqual(info.compress_type, 8) # ZIP_DEFLATED
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_exporter.py -v"`
|
||||
Expected: PASS — the new tests plus all existing exporter tests stay green.
|
||||
|
||||
- [ ] **Step 6: Lint**
|
||||
|
||||
Run: `ruff check src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py && ruff format src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py
|
||||
git commit -m "Feature: add --zip-compression and --zip-compression-level flags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Importer pre-check for unreadable codecs
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/documents/management/commands/document_importer.py`
|
||||
- Test: `src/documents/tests/test_management_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
The importer test file `src/documents/tests/test_management_importer.py` is
|
||||
`TestCase`-style (`class TestCommandImport(... TestCase)`, `self.assertRaises`,
|
||||
`DirectoriesMixin` gives `self.dirs.scratch_dir`). Match that style. Add this
|
||||
method to `TestCommandImport`. It builds a valid zip and patches the readability
|
||||
probe so the check fires deterministically on any runtime:
|
||||
|
||||
```python
|
||||
def test_import_rejects_unreadable_compression(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zip archive with an entry whose compression this Python can't read
|
||||
WHEN:
|
||||
- Import is attempted
|
||||
THEN:
|
||||
- A CommandError naming the issue is raised, before extraction
|
||||
"""
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
|
||||
archive = Path(self.dirs.scratch_dir) / "export.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("manifest.json", "[]")
|
||||
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_importer.compress_type_readable",
|
||||
return_value=False,
|
||||
):
|
||||
with self.assertRaises(CommandError) as e:
|
||||
call_command(
|
||||
"document_importer",
|
||||
str(archive),
|
||||
"--no-progress-bar",
|
||||
skip_checks=True,
|
||||
)
|
||||
self.assertIn("compression", str(e.exception))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_importer.py -k unreadable_compression -v"`
|
||||
Expected: FAIL — no pre-check exists yet, so the import proceeds (or fails with a different error).
|
||||
|
||||
- [ ] **Step 3: Implement the pre-check**
|
||||
|
||||
In `document_importer.py`, add the import:
|
||||
|
||||
```python
|
||||
from documents.export.compression import compress_type_readable
|
||||
from documents.export.compression import unreadable_method_names
|
||||
```
|
||||
|
||||
Find the zip-handling block (around `document_importer.py:453`):
|
||||
|
||||
```python
|
||||
with ZipFile(self.source) as zf:
|
||||
zf.extractall(tmp_dir)
|
||||
```
|
||||
|
||||
Replace it with a pre-check before extraction:
|
||||
|
||||
```python
|
||||
with ZipFile(self.source) as zf:
|
||||
unsupported = {
|
||||
info.compress_type
|
||||
for info in zf.infolist()
|
||||
if not compress_type_readable(info.compress_type)
|
||||
}
|
||||
if unsupported:
|
||||
names = ", ".join(sorted(unreadable_method_names(unsupported)))
|
||||
raise CommandError(
|
||||
f"This archive uses compression this Python cannot "
|
||||
f"read ({names}). zstd archives require Python 3.14+.",
|
||||
)
|
||||
zf.extractall(tmp_dir)
|
||||
```
|
||||
|
||||
Confirm `CommandError` is imported in `document_importer.py` (it is used elsewhere; if not, add `from django.core.management.base import CommandError`).
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_importer.py -v"`
|
||||
Expected: PASS — the new test plus all existing importer tests (normal deflated/stored archives still import).
|
||||
|
||||
- [ ] **Step 5: Lint**
|
||||
|
||||
Run: `ruff check src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py && ruff format src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py
|
||||
git commit -m "Feature: importer rejects archives with unreadable compression"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Document the flags
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/administration.md`
|
||||
|
||||
- [ ] **Step 1: Add the flags to the option list**
|
||||
|
||||
In `docs/administration.md`, update the usage block (around line 257) to include the new flags:
|
||||
|
||||
```
|
||||
document_exporter target [-c] [-d] [-f] [-na] [-nt] [-p] [-sm] [-z]
|
||||
|
||||
optional arguments:
|
||||
-c, --compare-checksums
|
||||
-cj, --compare-json
|
||||
-d, --delete
|
||||
-f, --use-filename-format
|
||||
-na, --no-archive
|
||||
-nt, --no-thumbnail
|
||||
-p, --use-folder-prefix
|
||||
-sm, --split-manifest
|
||||
-z, --zip
|
||||
-zn, --zip-name
|
||||
--zip-compression
|
||||
--zip-compression-level
|
||||
--data-only
|
||||
--no-progress-bar
|
||||
--passphrase
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the prose**
|
||||
|
||||
After the `-z`/`--zip` paragraph (around line 330), add:
|
||||
|
||||
```markdown
|
||||
The compression method for the zip can be set with `--zip-compression`
|
||||
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
|
||||
`--zip-compression-level` (deflated: 0–9, bzip2: 1–9, zstd: -131072–22; ignored
|
||||
for `stored` and `lzma`). Both options require `--zip`.
|
||||
|
||||
!!! warning
|
||||
|
||||
`zstd` compression requires Python 3.14 or newer on **both** the machine
|
||||
creating the export and any machine importing it. An archive compressed with
|
||||
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
|
||||
imported on a runtime that lacks the codec; the importer will refuse it with
|
||||
a clear error. The default `deflated` is universally readable.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the docs build is not broken (lint markdown)**
|
||||
|
||||
Run: `ruff check docs/ 2>/dev/null; echo "docs are markdown; rely on prettier pre-commit"`
|
||||
(No code to test. The prettier pre-commit hook will reformat on commit.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/administration.md
|
||||
git commit -m "Docs: document --zip-compression and --zip-compression-level"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full backend suites on the VM**
|
||||
|
||||
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/ src/documents/tests/test_management_exporter.py src/documents/tests/test_management_importer.py -v"`
|
||||
Expected: PASS, no failures.
|
||||
|
||||
- [ ] **Step 2: Spot-check the zstd happy path on Python 3.14 (cannot run under Django on the 3.11 VM)**
|
||||
|
||||
The zstd positive round-trip can't run in the 3.11 test env. Confirm the policy module behaves on a real 3.14 interpreter with a standalone check (no Django needed):
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run --python 3.14 --no-project python -c "import sys; sys.path.insert(0,'src'); import django; print('skip')" 2>/dev/null || \
|
||||
uv run --python 3.14 --no-project python -c "
|
||||
import zipfile, io
|
||||
from compression.zstd import CompressionParameter as CP
|
||||
print('zstd const', zipfile.ZIP_ZSTANDARD, 'bounds', CP.compression_level.bounds())
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf,'w',compression=zipfile.ZIP_ZSTANDARD,compresslevel=19) as zf:
|
||||
zf.writestr('a.txt','x'*1000)
|
||||
with zipfile.ZipFile(buf) as zf:
|
||||
assert zf.getinfo('a.txt').compress_type == zipfile.ZIP_ZSTANDARD
|
||||
assert zf.read('a.txt') == b'x'*1000
|
||||
print('zstd round-trip OK')
|
||||
"
|
||||
```
|
||||
|
||||
Expected: prints `zstd const 93 bounds (-131072, 22)` and `zstd round-trip OK`. This validates the constant, bounds, and that a zstd archive round-trips — the parts the 3.11 CI cannot exercise.
|
||||
|
||||
- [ ] **Step 3: Type-check on the VM (pyrefly)**
|
||||
|
||||
```bash
|
||||
tar czf - src pyproject.toml uv.lock .pyrefly-baseline.json | ssh -o BatchMode=yes -p 2244 trenton@localhost 'tar xzf - -C ~/projects/paperless-ngx'
|
||||
ssh -o BatchMode=yes -p 2244 trenton@localhost 'bash -lc "cd ~/projects/paperless-ngx && uv run pyrefly check"'
|
||||
```
|
||||
|
||||
Expected: no new type errors beyond the baseline. (Note: `import compression.zstd` is guarded behind `importlib.import_module`, so it is never statically resolved on the 3.11 baseline.)
|
||||
|
||||
- [ ] **Step 4: Final lint**
|
||||
|
||||
Run: `ruff check src/documents/export/ src/documents/management/commands/document_exporter.py src/documents/management/commands/document_importer.py && ruff format --check src/documents/export/ src/documents/management/commands/document_exporter.py src/documents/management/commands/document_importer.py`
|
||||
Expected: clean.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Default behavior is unchanged:** with no flags, the sink is constructed with `compression=ZIP_DEFLATED, compresslevel=None` — byte-method-identical to today (`shutil.make_archive` used `ZIP_DEFLATED` with no level). `test_default_zip_uses_deflate` pins this.
|
||||
- **zstd availability is gated three ways and never imported statically:** the constant via `getattr`, the codec via `importlib.import_module("compression.zstd")`, and the CLI value rejected with a friendly message on < 3.14. The choices list always contains `zstd` so argparse doesn't hide it.
|
||||
- **The importer pre-check is the safety net** for portability foot-guns — without it an unreadable entry raises a bare `NotImplementedError` mid-`extractall`. The check runs on `infolist()` (metadata only) before any extraction.
|
||||
- **Why `--zip-compression` defaults to `None`, not `"deflated"`:** so `handle()` can detect "user passed it without `--zip`" and fail fast. The effective default is resolved as `zip_compression or "deflated"`.
|
||||
@@ -1,236 +0,0 @@
|
||||
# Export Zip Compression Control — Design
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Branch base:** `dev`
|
||||
**Status:** Design complete (zstd facts verified on CPython 3.14.3) — **depends on**
|
||||
`2026-06-16-export-sink-architecture-design.md` being implemented first.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
This builds directly on the export sink refactor. It assumes `ZipExportSink`
|
||||
already exists and is the single place that owns `zipfile.ZipFile` creation and
|
||||
entry writes. Do not start this until that refactor has landed; without it, the
|
||||
change would have to touch the command's zip branches again.
|
||||
|
||||
## Problem
|
||||
|
||||
Zip export is hardwired to `ZIP_DEFLATED` at the library default level. Users
|
||||
have no way to trade speed against archive size — a fast `ZIP_STORED` pass for a
|
||||
quick local copy, or a maximal `ZIP_LZMA` pass for the smallest off-site backup.
|
||||
The sink refactor turns "which compression" into a single constructor argument,
|
||||
so exposing it is now a small, isolated change.
|
||||
|
||||
## Goal
|
||||
|
||||
Let the operator choose the zip compression method and level from the CLI, with
|
||||
behavior identical to today when the flags are omitted. All knowledge of
|
||||
compression stays inside `ZipExportSink`; the command only parses flags and maps
|
||||
them to sink arguments.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- `ZipExportSink` gains `compression: int` and `compresslevel: int | None`
|
||||
constructor parameters (default `ZIP_DEFLATED`, `None` → library default),
|
||||
passed straight to `zipfile.ZipFile(...)`.
|
||||
- New `document_exporter` flags: `--zip-compression` and
|
||||
`--zip-compression-level`, valid only with `--zip`.
|
||||
- Validation: method availability, level range per method, and the
|
||||
requires-`--zip` guard.
|
||||
- Import-side: a pre-extract support check in `document_importer` that turns an
|
||||
unsupported codec into a clear `CommandError` (the importer otherwise decompresses
|
||||
transparently via `ZipFile.extractall`).
|
||||
- Docs: add both flags and the zstd-portability caveat to `docs/administration.md`
|
||||
(the `document_exporter` option list, lines ~257-270 and the `-z`/`-zn` section,
|
||||
lines ~328-330). New flags are long-form only (`--zip-compression`,
|
||||
`--zip-compression-level`) — no short aliases, to avoid `-zc`/`-zl` collisions
|
||||
with the existing `-z`/`-zn`.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Compression for any non-zip sink (folder has none; a future S3 sink would
|
||||
handle its own object storage compression separately).
|
||||
- Changing the default. Omitting the flags must produce a byte-compatible-method
|
||||
archive to today's (`ZIP_DEFLATED`, default level).
|
||||
|
||||
## Design
|
||||
|
||||
### `ZipExportSink` changes
|
||||
|
||||
The base sink's signature is `ZipExportSink(target, zip_name, *, delete)`; this
|
||||
adds two keyword-only params after `delete`:
|
||||
|
||||
```python
|
||||
def __init__(
|
||||
self,
|
||||
target: Path,
|
||||
zip_name: str,
|
||||
*,
|
||||
delete: bool = False,
|
||||
compression: int = zipfile.ZIP_DEFLATED,
|
||||
compresslevel: int | None = None,
|
||||
) -> None:
|
||||
...
|
||||
# opened in __enter__:
|
||||
self._zip = zipfile.ZipFile(
|
||||
self._tmp_path,
|
||||
"w",
|
||||
compression=compression,
|
||||
compresslevel=compresslevel,
|
||||
allowZip64=True,
|
||||
)
|
||||
```
|
||||
|
||||
`ZipFile` applies `compression`/`compresslevel` as the default for every
|
||||
`write`/`writestr` (verified: a `ZipFile(..., compression=ZIP_BZIP2)` yields
|
||||
entries with `compress_type == ZIP_BZIP2` without per-call args), so `add_file` /
|
||||
`add_json` / the manifest entry need no changes. Directory marker entries are
|
||||
empty so their compressed payload is zero, but they are still _tagged_ with the
|
||||
chosen `compress_type` — harmless, but tests that read `infolist()` should filter
|
||||
or account for marker entries (see Testing).
|
||||
|
||||
### CLI flags (`document_exporter`)
|
||||
|
||||
- `--zip-compression {stored,deflated,bzip2,lzma}` — and `zstd` **when the
|
||||
runtime supports it** (see below). Maps to the matching `zipfile.ZIP_*`
|
||||
constant. Default `deflated`.
|
||||
- `--zip-compression-level N` — integer. Per-method accepted ranges (verified
|
||||
against the [3.14 `zipfile` docs](https://docs.python.org/3.14/library/zipfile.html#zipfile.ZipFile)):
|
||||
- `deflated`: **0–9** (`zlib` also accepts `-1` = "default", identical to
|
||||
omitting the flag / `compresslevel=None`).
|
||||
- `bzip2`: **1–9** (`0` is invalid for bzip2).
|
||||
- `lzma`, `stored`: level has **no effect** — passing `--zip-compression-level`
|
||||
with either is a `CommandError`, not a silent accept (consistent with the
|
||||
base refactor's fail-fast posture).
|
||||
- `zstd`: **-131072 … 22** (the documented commonly-accepted range; the
|
||||
authoritative bounds are
|
||||
`compression.zstd.CompressionParameter.compression_level.bounds()`).
|
||||
|
||||
Default: unset → library default (`compresslevel=None`).
|
||||
|
||||
Both flags require `--zip`; passing either without `--zip` raises a
|
||||
`CommandError`, matching the incremental-flag rule from the base refactor.
|
||||
|
||||
**Why validate up front (not let `zipfile` raise) — verified on 3.14.3:** an
|
||||
invalid level does _not_ fail at `ZipFile(...)` construction — it fails at the
|
||||
**first `write`/`writestr` call**, with an opaque message
|
||||
(`ValueError: Invalid initialization option` for deflated > 9, or
|
||||
`ValueError: compresslevel must be between 1 and 9` for bzip2). Worse, on context
|
||||
exit the half-initialized write handle emits a secondary
|
||||
`AttributeError: '_ZipWriteFile' object has no attribute '_compressor'` during GC
|
||||
finalization, so the user sees stack-trace noise unrelated to the real cause.
|
||||
Up-front validation turns all of that into a single clean `CommandError`.
|
||||
|
||||
### Validation (in `handle()`, before constructing the sink)
|
||||
|
||||
1. **Requires `--zip`.** Either flag without `--zip` → `CommandError`.
|
||||
2. **Method availability — via a named, patchable seam.** Expose a module-level
|
||||
helper `compression_available(method: str) -> bool` that does
|
||||
`try: import bz2 / import lzma / from compression import zstd except ImportError:
|
||||
return False` — **not** `importlib.util.find_spec`, which can report a stdlib
|
||||
C-extension as present when importing it actually fails. `stored`/`deflated`
|
||||
are always available (`zlib` is a hard CPython dependency). For `zstd` the probe
|
||||
must import `compression.zstd` (3.14+), not merely check that
|
||||
`zipfile.ZIP_ZSTANDARD` exists. Making this a named function is also what lets
|
||||
the test patch "method unavailable" with `mocker`. If the chosen method is
|
||||
unavailable, raise a `CommandError` naming the missing capability — `zipfile`
|
||||
itself would otherwise raise a bare `RuntimeError`
|
||||
("Compression requires the (missing) … module").
|
||||
3. **Level range.** Reject an out-of-range `--zip-compression-level` for the
|
||||
chosen method with a clear `CommandError`; reject the flag entirely for
|
||||
`stored`/`lzma` (see above).
|
||||
|
||||
### zstd (Python 3.14+)
|
||||
|
||||
**Verified empirically on CPython 3.14.3** (via `uv run --python 3.14 --no-project`)
|
||||
and against [PEP 784](https://peps.python.org/pep-0784/) +
|
||||
[the 3.14 `zipfile` docs](https://docs.python.org/3.14/library/zipfile.html):
|
||||
|
||||
- The compression-method constant is **`zipfile.ZIP_ZSTANDARD`** (added 3.14; its
|
||||
numeric value is `93`). It does **not** exist on < 3.14.
|
||||
- It is backed by the new **`compression.zstd`** stdlib module (PEP 784 added a
|
||||
`compression` namespace package; legacy `bz2`/`lzma`/`zlib` imports are
|
||||
unchanged). `zipfile` raises `RuntimeError` if `compression.zstd` is
|
||||
unavailable when zstd is requested.
|
||||
- Accepted `compresslevel` is **`-131072 … 22`**, confirmed at runtime via
|
||||
`compression.zstd.CompressionParameter.compression_level.bounds() == (-131072, 22)`.
|
||||
|
||||
Gate everything zstd-related at runtime so nothing is imported or referenced on
|
||||
< 3.14 (the project targets Python ≥ 3.11):
|
||||
|
||||
```python
|
||||
_ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None) # None before 3.14
|
||||
```
|
||||
|
||||
Presence of the _constant_ does not guarantee the _codec_ is usable, so the
|
||||
availability probe (validation step 2) imports `compression.zstd`, not merely
|
||||
checks the constant.
|
||||
|
||||
Keep `zstd` in the `--zip-compression` `choices` **always** (even on < 3.14), and
|
||||
reject it in validation with a friendly "zstd requires Python 3.14+" message. If
|
||||
it were dropped from `choices` on older runtimes, argparse would emit a generic
|
||||
"invalid choice" that reads as though the option never existed — worse UX.
|
||||
|
||||
### Import-side compatibility
|
||||
|
||||
`document_importer` reads zips with `ZipFile(self.source).extractall(...)`
|
||||
(`document_importer.py:453`), which decompresses each entry transparently using
|
||||
whatever method it was stored with — **provided the matching module exists on the
|
||||
importing machine.**
|
||||
|
||||
The failure mode when it doesn't is unfriendly and must be handled: a zstd (or
|
||||
otherwise unsupported) entry raises a bare `NotImplementedError` **per-entry,
|
||||
during `extractall`** — _not_ at `ZipFile(self.source)` open, and `is_zipfile()`
|
||||
still returns true (a zstd archive is a valid zip container). So the importer
|
||||
enters the zip branch, creates its temp dir, may partially extract other entries,
|
||||
then blows up mid-extract with no context. **Mitigation (in scope here):** before
|
||||
extracting, inspect `ZipFile(self.source).infolist()` compress types and, if any
|
||||
is unsupported on this runtime, raise a `CommandError` naming the method and the
|
||||
requirement (e.g. "this archive uses zstd, which needs Python 3.14+") instead of
|
||||
letting `NotImplementedError` escape.
|
||||
|
||||
Per-method summary (document in help text + `administration.md`):
|
||||
|
||||
- `deflated`/`stored`: universally importable.
|
||||
- `bzip2`/`lzma`: importable wherever the `bz2`/`lzma` modules are present
|
||||
(essentially always).
|
||||
- `zstd`: importable only on Python 3.14+. An archive compressed with `zstd` is
|
||||
**not** importable on older runtimes.
|
||||
|
||||
## Testing
|
||||
|
||||
New cases in the sink tests and an export→import round-trip
|
||||
(pytest classes, factory-boy, `mocker`, `parametrize`, typed; run on the VM):
|
||||
|
||||
- **Round-trip per method.** Parametrize over the available methods (skip `zstd`
|
||||
below 3.14, skip `bzip2`/`lzma` if the module is somehow absent): export a
|
||||
small library, import it back, assert documents/manifest match.
|
||||
- **Method is applied.** Assert each written _file_ entry's `compress_type`
|
||||
equals the requested method (read back via `ZipFile.infolist()`), filtering out
|
||||
directory marker entries (which are tagged but empty).
|
||||
- **Level affects size — robustly.** Do **not** compare deflate level 9 vs 1
|
||||
(on small or incompressible fixtures level 9 can equal or slightly exceed level
|
||||
1, causing flaky CI). Instead assert that a compressing method on a
|
||||
moderately-compressible fixture yields a total smaller than `stored`
|
||||
(`ZIP_STORED`), which is a stable invariant.
|
||||
- **Validation.** Each flag without `--zip` → `CommandError`; out-of-range level
|
||||
(`--zip-compression-level 99`) → a clean `CommandError` from validation
|
||||
(asserting we never reach the `writestr` that would raise the masked
|
||||
`ValueError`); `--zip-compression-level` with `stored`/`lzma` → `CommandError`;
|
||||
unavailable method (patch the named availability seam with `mocker`) →
|
||||
`CommandError`; on < 3.14, `--zip-compression zstd` → the friendly
|
||||
"requires 3.14+" `CommandError`.
|
||||
- **Import pre-check.** An archive containing an unsupported compress type
|
||||
produces a `CommandError` from the importer naming the method, not a raw
|
||||
`NotImplementedError` (simulate by patching the importer's support probe).
|
||||
- **Default unchanged.** Omitting both flags yields file entries with
|
||||
`compress_type == ZIP_DEFLATED`, identical to pre-feature behavior.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Foot-gun archives.** A user could produce a `zstd`/`lzma` archive their
|
||||
import target can't read. Mitigation: explicit help text and the import-side
|
||||
notes above; the default stays the universally-readable `deflated`.
|
||||
- **Optional-module assumptions.** Don't assume `bz2`/`lzma` are always compiled
|
||||
in; probe and error clearly. Mitigation: the availability validation step.
|
||||
+2
-2
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
|
||||
### Password reset
|
||||
|
||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set
|
||||
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings.
|
||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
||||
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
||||
|
||||
### Two-factor authentication
|
||||
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"azure-ai-documentintelligence>=1.0.2",
|
||||
"babel>=2.17",
|
||||
"bleach~=6.4.0",
|
||||
"bleach~=6.3.0",
|
||||
"celery[redis]~=5.6.2",
|
||||
"channels~=4.2",
|
||||
"channels-redis~=4.2",
|
||||
@@ -56,7 +56,7 @@ dependencies = [
|
||||
"llama-index-embeddings-openai-like>=0.2.2",
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"nltk~=3.9.1",
|
||||
"ocrmypdf~=17.4.2",
|
||||
"openai>=2.32",
|
||||
"pathvalidate~=3.3.1",
|
||||
@@ -101,11 +101,11 @@ dev = [
|
||||
{ include-group = "testing" },
|
||||
]
|
||||
docs = [
|
||||
"zensical>=0.0.47",
|
||||
"zensical>=0.0.43",
|
||||
]
|
||||
lint = [
|
||||
"prek~=0.3.10",
|
||||
"ruff~=0.15.20",
|
||||
"ruff~=0.15.15",
|
||||
]
|
||||
testing = [
|
||||
"daphne",
|
||||
|
||||
+12
-19
@@ -56,13 +56,13 @@
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-builders/custom-webpack:browser",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"customWebpackConfig": {
|
||||
"path": "./extra-webpack.config.ts"
|
||||
"outputPath": {
|
||||
"base": "dist/paperless-ui",
|
||||
"browser": ""
|
||||
},
|
||||
"outputPath": "dist/paperless-ui",
|
||||
"main": "src/main.ts",
|
||||
"browser": "src/main.ts",
|
||||
"outputHashing": "none",
|
||||
"index": "src/index.html",
|
||||
"polyfills": [
|
||||
@@ -79,16 +79,6 @@
|
||||
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
||||
"input": "node_modules/pdfjs-dist/legacy/build/",
|
||||
"output": "/assets/js/"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/pdfjs-dist/wasm/",
|
||||
"output": "/assets/wasm/"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/pdfjs-dist/iccs/",
|
||||
"output": "/assets/iccs/"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
@@ -97,6 +87,7 @@
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": [
|
||||
"file-saver",
|
||||
"mime-names",
|
||||
"utif"
|
||||
],
|
||||
"extractLicenses": false,
|
||||
@@ -117,11 +108,13 @@
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputPath": "../src/documents/static/frontend/",
|
||||
"outputPath": {
|
||||
"base": "../src/documents/static/frontend/",
|
||||
"browser": ""
|
||||
},
|
||||
"optimization": true,
|
||||
"outputHashing": "none",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"budgets": [
|
||||
{
|
||||
@@ -145,7 +138,7 @@
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build:en-US"
|
||||
},
|
||||
@@ -156,7 +149,7 @@
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
||||
"builder": "@angular/build:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build"
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import {
|
||||
CustomWebpackBrowserSchema,
|
||||
TargetOptions,
|
||||
} from '@angular-builders/custom-webpack'
|
||||
import * as webpack from 'webpack'
|
||||
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
||||
|
||||
export default (
|
||||
config: webpack.Configuration,
|
||||
options: CustomWebpackBrowserSchema,
|
||||
targetOptions: TargetOptions
|
||||
) => {
|
||||
if (config.plugins) {
|
||||
config.plugins.push(
|
||||
codecovWebpackPlugin({
|
||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||
bundleName: 'paperless-ngx',
|
||||
uploadToken: process.env.CODECOV_TOKEN,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
+286
-338
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -39,7 +39,6 @@
|
||||
"uuid": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^22.0.1",
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.0.5",
|
||||
"@angular-devkit/schematics": "^22.0.5",
|
||||
@@ -51,7 +50,6 @@
|
||||
"@angular/build": "^22.0.5",
|
||||
"@angular/cli": "~22.0.5",
|
||||
"@angular/compiler-cli": "~22.0.5",
|
||||
"@codecov/webpack-plugin": "^2.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.0.0",
|
||||
@@ -66,8 +64,7 @@
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0"
|
||||
}
|
||||
|
||||
Generated
+29
-466
@@ -87,9 +87,6 @@ importers:
|
||||
specifier: ^14.0.1
|
||||
version: 14.0.1
|
||||
devDependencies:
|
||||
'@angular-builders/custom-webpack':
|
||||
specifier: ^22.0.1
|
||||
version: 22.0.1(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5)(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(@angular/localize@22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5))(@angular/platform-browser@22.0.5(@angular/common@22.0.5(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2)))(@types/node@26.1.0)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.16)(rxjs@7.8.2)(terser@5.48.0)(tslib@2.8.1)(typescript@6.0.3)
|
||||
'@angular-builders/jest':
|
||||
specifier: ^22.0.1
|
||||
version: 22.0.1(6d3d466dba72c0eacaf0a456e8063975)
|
||||
@@ -123,9 +120,6 @@ importers:
|
||||
'@angular/compiler-cli':
|
||||
specifier: ~22.0.5
|
||||
version: 22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3)
|
||||
'@codecov/webpack-plugin':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(webpack@5.108.3(postcss@8.5.16))
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
@@ -171,30 +165,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
webpack:
|
||||
specifier: ^5.107.2
|
||||
version: 5.108.3(postcss@8.5.16)
|
||||
|
||||
packages:
|
||||
|
||||
'@actions/core@3.0.1':
|
||||
resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==}
|
||||
|
||||
'@actions/exec@3.0.0':
|
||||
resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==}
|
||||
|
||||
'@actions/github@9.1.1':
|
||||
resolution: {integrity: sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==}
|
||||
|
||||
'@actions/http-client@3.0.2':
|
||||
resolution: {integrity: sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==}
|
||||
|
||||
'@actions/http-client@4.0.1':
|
||||
resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==}
|
||||
|
||||
'@actions/io@3.0.2':
|
||||
resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==}
|
||||
|
||||
'@algolia/abtesting@1.18.0':
|
||||
resolution: {integrity: sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
@@ -259,13 +232,6 @@ packages:
|
||||
resolution: {integrity: sha512-7qZDXSS3CmN4W7z/p+VwH38id4Tq++pjl0GqH6XVp7Jj7RZGH+d4vPfsqF/ePG4N1XEJjL9ieG0vP6MNtf9UMQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@angular-builders/custom-webpack@22.0.1':
|
||||
resolution: {integrity: sha512-0wTSs9eH8LJjQYfuwySV9xOTxhfekbSaCFPHQv+GKsG0sAC/rjFG3sgRzObOd3ae3R8DW2389yZwu9juOWqRug==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@angular/compiler-cli': ^22.0.0
|
||||
rxjs: '>=7.0.0'
|
||||
|
||||
'@angular-builders/jest@22.0.1':
|
||||
resolution: {integrity: sha512-bSsV1a8A7KJs1RwsmKH5C9/K3j9GqRYQKiXPgrKOLUJ7dzsAvvlF58n5NX0Pn975ZCrGa1wY3tx8iwQpo+aj3g==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -1143,16 +1109,6 @@ packages:
|
||||
'@bcoe/v8-coverage@0.2.3':
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
|
||||
'@codecov/bundler-plugin-core@2.0.1':
|
||||
resolution: {integrity: sha512-TkdKn/rEwZQ723M7DDUmHe5r0IJa23rUT4TAx5jXmg12wGZGAHGWWU7LKeQsYCsKdLMxK7bLaGk9M++4wSRD5w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@codecov/webpack-plugin@2.0.1':
|
||||
resolution: {integrity: sha512-BC5+SOt8Z7mSAQnEZnmXuu8R//rYVWwQ0/CAglXAK6esY23Js8lLHU5fUo6fJL9e5iDCPSKJzxlAjFIsFMzimw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
webpack: 5.x
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2330,48 +2286,6 @@ packages:
|
||||
resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
'@octokit/auth-token@6.0.0':
|
||||
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/core@7.0.6':
|
||||
resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/endpoint@11.0.3':
|
||||
resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/graphql@9.0.3':
|
||||
resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/openapi-types@27.0.0':
|
||||
resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==}
|
||||
|
||||
'@octokit/plugin-paginate-rest@14.0.0':
|
||||
resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@17.0.0':
|
||||
resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/request-error@7.1.0':
|
||||
resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/request@10.0.11':
|
||||
resolution: {integrity: sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/types@16.0.0':
|
||||
resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -3297,9 +3211,6 @@ packages:
|
||||
resolution: {integrity: sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
before-after-hook@4.0.0:
|
||||
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
|
||||
|
||||
big.js@5.2.2:
|
||||
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
|
||||
|
||||
@@ -4624,9 +4535,6 @@ packages:
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
json-with-bigint@3.5.8:
|
||||
resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4861,49 +4769,6 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minimizer-webpack-plugin@5.6.1:
|
||||
resolution: {integrity: sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
peerDependencies:
|
||||
'@minify-html/node': '*'
|
||||
'@swc/core': '*'
|
||||
'@swc/css': '*'
|
||||
'@swc/html': '*'
|
||||
clean-css: '*'
|
||||
cssnano: '*'
|
||||
csso: '*'
|
||||
esbuild: '*'
|
||||
html-minifier-terser: '*'
|
||||
lightningcss: '*'
|
||||
postcss: '*'
|
||||
uglify-js: '*'
|
||||
webpack: ^5.1.0
|
||||
peerDependenciesMeta:
|
||||
'@minify-html/node':
|
||||
optional: true
|
||||
'@swc/core':
|
||||
optional: true
|
||||
'@swc/css':
|
||||
optional: true
|
||||
'@swc/html':
|
||||
optional: true
|
||||
clean-css:
|
||||
optional: true
|
||||
cssnano:
|
||||
optional: true
|
||||
csso:
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
html-minifier-terser:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
postcss:
|
||||
optional: true
|
||||
uglify-js:
|
||||
optional: true
|
||||
|
||||
minipass-collect@2.0.1:
|
||||
resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -6045,10 +5910,6 @@ packages:
|
||||
resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
tunnel@0.0.6:
|
||||
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
|
||||
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -6109,17 +5970,10 @@ packages:
|
||||
resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
universal-user-agent@7.0.3:
|
||||
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
unplugin@1.16.1:
|
||||
resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
unrs-resolver@1.12.2:
|
||||
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
|
||||
|
||||
@@ -6280,9 +6134,6 @@ packages:
|
||||
html-webpack-plugin:
|
||||
optional: true
|
||||
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
webpack@5.106.2:
|
||||
resolution: {integrity: sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -6293,16 +6144,6 @@ packages:
|
||||
webpack-cli:
|
||||
optional: true
|
||||
|
||||
webpack@5.108.3:
|
||||
resolution: {integrity: sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
webpack-cli: '*'
|
||||
peerDependenciesMeta:
|
||||
webpack-cli:
|
||||
optional: true
|
||||
|
||||
websocket-driver@0.7.5:
|
||||
resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
@@ -6444,9 +6285,6 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.28 || ^4
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.4.2:
|
||||
resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==}
|
||||
|
||||
@@ -6455,37 +6293,6 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@actions/core@3.0.1':
|
||||
dependencies:
|
||||
'@actions/exec': 3.0.0
|
||||
'@actions/http-client': 4.0.1
|
||||
|
||||
'@actions/exec@3.0.0':
|
||||
dependencies:
|
||||
'@actions/io': 3.0.2
|
||||
|
||||
'@actions/github@9.1.1':
|
||||
dependencies:
|
||||
'@actions/http-client': 3.0.2
|
||||
'@octokit/core': 7.0.6
|
||||
'@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6)
|
||||
'@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6)
|
||||
'@octokit/request': 10.0.11
|
||||
'@octokit/request-error': 7.1.0
|
||||
undici: 6.27.0
|
||||
|
||||
'@actions/http-client@3.0.2':
|
||||
dependencies:
|
||||
tunnel: 0.0.6
|
||||
undici: 6.27.0
|
||||
|
||||
'@actions/http-client@4.0.1':
|
||||
dependencies:
|
||||
tunnel: 0.0.6
|
||||
undici: 6.27.0
|
||||
|
||||
'@actions/io@3.0.2': {}
|
||||
|
||||
'@algolia/abtesting@1.18.0':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.52.0
|
||||
@@ -6585,65 +6392,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-builders/custom-webpack@22.0.1(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5)(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(@angular/localize@22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5))(@angular/platform-browser@22.0.5(@angular/common@22.0.5(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2)))(@types/node@26.1.0)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.16)(rxjs@7.8.2)(terser@5.48.0)(tslib@2.8.1)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@angular-builders/common': 6.0.1(chokidar@5.0.0)
|
||||
'@angular-devkit/architect': 0.2200.5(chokidar@5.0.0)
|
||||
'@angular-devkit/build-angular': 22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5)(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(@angular/localize@22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5))(@angular/platform-browser@22.0.5(@angular/common@22.0.5(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2)))(@types/node@26.1.0)(chokidar@5.0.0)(jiti@2.7.0)(typescript@6.0.3)
|
||||
'@angular-devkit/core': 22.0.5(chokidar@5.0.0)
|
||||
'@angular-devkit/schematics': 22.0.5(chokidar@5.0.0)
|
||||
'@angular/build': 22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5)(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(@angular/localize@22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5))(@angular/platform-browser@22.0.5(@angular/common@22.0.5(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2)))(@types/node@26.1.0)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.16)(terser@5.48.0)(tslib@2.8.1)(typescript@6.0.3)
|
||||
'@angular/compiler-cli': 22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3)
|
||||
'@schematics/angular': 22.0.5(chokidar@5.0.0)
|
||||
lodash: 4.18.1
|
||||
rxjs: 7.8.2
|
||||
webpack-merge: 6.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@angular/compiler'
|
||||
- '@angular/core'
|
||||
- '@angular/localize'
|
||||
- '@angular/platform-browser'
|
||||
- '@angular/platform-server'
|
||||
- '@angular/service-worker'
|
||||
- '@angular/ssr'
|
||||
- '@minify-html/node'
|
||||
- '@rspack/core'
|
||||
- '@swc/core'
|
||||
- '@swc/css'
|
||||
- '@swc/html'
|
||||
- '@types/node'
|
||||
- browser-sync
|
||||
- bufferutil
|
||||
- chokidar
|
||||
- clean-css
|
||||
- cssnano
|
||||
- csso
|
||||
- debug
|
||||
- html-minifier-terser
|
||||
- html-webpack-plugin
|
||||
- istanbul-lib-instrument
|
||||
- jiti
|
||||
- karma
|
||||
- less
|
||||
- lightningcss
|
||||
- ng-packagr
|
||||
- node-sass
|
||||
- postcss
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- tailwindcss
|
||||
- terser
|
||||
- tslib
|
||||
- tsx
|
||||
- typescript
|
||||
- uglify-js
|
||||
- utf-8-validate
|
||||
- vitest
|
||||
- webpack-cli
|
||||
- yaml
|
||||
|
||||
'@angular-builders/jest@22.0.1(6d3d466dba72c0eacaf0a456e8063975)':
|
||||
dependencies:
|
||||
'@angular-builders/common': 6.0.1(chokidar@5.0.0)
|
||||
@@ -6681,7 +6429,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@angular-devkit/architect': 0.2200.5(chokidar@5.0.0)
|
||||
'@angular-devkit/build-webpack': 0.2200.5(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16)))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13))
|
||||
'@angular-devkit/build-webpack': 0.2200.5(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13)))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13))
|
||||
'@angular-devkit/core': 22.0.5(chokidar@5.0.0)
|
||||
'@angular/build': 22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5)(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(@angular/localize@22.0.5(@angular/compiler-cli@22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3))(@angular/compiler@22.0.5))(@angular/platform-browser@22.0.5(@angular/common@22.0.5(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2)))(@types/node@26.1.0)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.13)(terser@5.46.2)(tslib@2.8.1)(typescript@6.0.3)
|
||||
'@angular/compiler-cli': 22.0.5(@angular/compiler@22.0.5)(typescript@6.0.3)
|
||||
@@ -6769,12 +6517,12 @@ snapshots:
|
||||
- webpack-cli
|
||||
- yaml
|
||||
|
||||
'@angular-devkit/build-webpack@0.2200.5(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16)))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13))':
|
||||
'@angular-devkit/build-webpack@0.2200.5(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13)))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13))':
|
||||
dependencies:
|
||||
'@angular-devkit/architect': 0.2200.5(chokidar@5.0.0)
|
||||
rxjs: 7.8.2
|
||||
webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.13)
|
||||
webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16))
|
||||
webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13))
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
@@ -7894,21 +7642,6 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@codecov/bundler-plugin-core@2.0.1':
|
||||
dependencies:
|
||||
'@actions/core': 3.0.1
|
||||
'@actions/github': 9.1.1
|
||||
chalk: 4.1.2
|
||||
semver: 7.8.5
|
||||
unplugin: 1.16.1
|
||||
zod: 3.25.76
|
||||
|
||||
'@codecov/webpack-plugin@2.0.1(webpack@5.108.3(postcss@8.5.16))':
|
||||
dependencies:
|
||||
'@codecov/bundler-plugin-core': 2.0.1
|
||||
unplugin: 1.16.1
|
||||
webpack: 5.108.3(postcss@8.5.16)
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
@@ -8950,58 +8683,6 @@ snapshots:
|
||||
node-gyp: 12.4.0
|
||||
proc-log: 6.1.0
|
||||
|
||||
'@octokit/auth-token@6.0.0': {}
|
||||
|
||||
'@octokit/core@7.0.6':
|
||||
dependencies:
|
||||
'@octokit/auth-token': 6.0.0
|
||||
'@octokit/graphql': 9.0.3
|
||||
'@octokit/request': 10.0.11
|
||||
'@octokit/request-error': 7.1.0
|
||||
'@octokit/types': 16.0.0
|
||||
before-after-hook: 4.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/endpoint@11.0.3':
|
||||
dependencies:
|
||||
'@octokit/types': 16.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/graphql@9.0.3':
|
||||
dependencies:
|
||||
'@octokit/request': 10.0.11
|
||||
'@octokit/types': 16.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/openapi-types@27.0.0': {}
|
||||
|
||||
'@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.6
|
||||
'@octokit/types': 16.0.0
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.6
|
||||
'@octokit/types': 16.0.0
|
||||
|
||||
'@octokit/request-error@7.1.0':
|
||||
dependencies:
|
||||
'@octokit/types': 16.0.0
|
||||
|
||||
'@octokit/request@10.0.11':
|
||||
dependencies:
|
||||
'@octokit/endpoint': 11.0.3
|
||||
'@octokit/request-error': 7.1.0
|
||||
'@octokit/types': 16.0.0
|
||||
content-type: 2.0.0
|
||||
json-with-bigint: 3.5.8
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/types@16.0.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 27.0.0
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
optional: true
|
||||
|
||||
@@ -9962,8 +9643,6 @@ snapshots:
|
||||
postcss-media-query-parser: 0.2.3
|
||||
postcss-safe-parser: 7.0.1(postcss@8.5.16)
|
||||
|
||||
before-after-hook@4.0.0: {}
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
@@ -10218,7 +9897,7 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 7.0.7
|
||||
tinyglobby: 0.2.16
|
||||
tinyglobby: 0.2.17
|
||||
webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.13)
|
||||
|
||||
core-js-compat@3.49.0:
|
||||
@@ -10251,14 +9930,14 @@ snapshots:
|
||||
|
||||
css-loader@7.1.4(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13)):
|
||||
dependencies:
|
||||
icss-utils: 5.1.0(postcss@8.5.13)
|
||||
postcss: 8.5.13
|
||||
postcss-modules-extract-imports: 3.1.0(postcss@8.5.13)
|
||||
postcss-modules-local-by-default: 4.2.0(postcss@8.5.13)
|
||||
postcss-modules-scope: 3.2.1(postcss@8.5.13)
|
||||
postcss-modules-values: 4.0.0(postcss@8.5.13)
|
||||
icss-utils: 5.1.0(postcss@8.5.16)
|
||||
postcss: 8.5.16
|
||||
postcss-modules-extract-imports: 3.1.0(postcss@8.5.16)
|
||||
postcss-modules-local-by-default: 4.2.0(postcss@8.5.16)
|
||||
postcss-modules-scope: 3.2.1(postcss@8.5.16)
|
||||
postcss-modules-values: 4.0.0(postcss@8.5.16)
|
||||
postcss-value-parser: 4.2.0
|
||||
semver: 7.7.4
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.13)
|
||||
|
||||
@@ -10990,9 +10669,9 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
icss-utils@5.1.0(postcss@8.5.13):
|
||||
icss-utils@5.1.0(postcss@8.5.16):
|
||||
dependencies:
|
||||
postcss: 8.5.13
|
||||
postcss: 8.5.16
|
||||
|
||||
ignore-walk@8.0.0:
|
||||
dependencies:
|
||||
@@ -11569,8 +11248,6 @@ snapshots:
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json-with-bigint@3.5.8: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
@@ -11816,16 +11493,6 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minimizer-webpack-plugin@5.6.1(postcss@8.5.16)(webpack@5.108.3(postcss@8.5.16)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.48.0
|
||||
webpack: 5.108.3(postcss@8.5.16)
|
||||
optionalDependencies:
|
||||
postcss: 8.5.16
|
||||
|
||||
minipass-collect@2.0.1:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
@@ -12268,7 +11935,7 @@ snapshots:
|
||||
cosmiconfig: 9.0.2(typescript@6.0.3)
|
||||
jiti: 2.7.0
|
||||
postcss: 8.5.13
|
||||
semver: 7.7.4
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.13)
|
||||
transitivePeerDependencies:
|
||||
@@ -12276,26 +11943,26 @@ snapshots:
|
||||
|
||||
postcss-media-query-parser@0.2.3: {}
|
||||
|
||||
postcss-modules-extract-imports@3.1.0(postcss@8.5.13):
|
||||
postcss-modules-extract-imports@3.1.0(postcss@8.5.16):
|
||||
dependencies:
|
||||
postcss: 8.5.13
|
||||
postcss: 8.5.16
|
||||
|
||||
postcss-modules-local-by-default@4.2.0(postcss@8.5.13):
|
||||
postcss-modules-local-by-default@4.2.0(postcss@8.5.16):
|
||||
dependencies:
|
||||
icss-utils: 5.1.0(postcss@8.5.13)
|
||||
postcss: 8.5.13
|
||||
icss-utils: 5.1.0(postcss@8.5.16)
|
||||
postcss: 8.5.16
|
||||
postcss-selector-parser: 7.1.4
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
postcss-modules-scope@3.2.1(postcss@8.5.13):
|
||||
postcss-modules-scope@3.2.1(postcss@8.5.16):
|
||||
dependencies:
|
||||
postcss: 8.5.13
|
||||
postcss: 8.5.16
|
||||
postcss-selector-parser: 7.1.4
|
||||
|
||||
postcss-modules-values@4.0.0(postcss@8.5.13):
|
||||
postcss-modules-values@4.0.0(postcss@8.5.16):
|
||||
dependencies:
|
||||
icss-utils: 5.1.0(postcss@8.5.13)
|
||||
postcss: 8.5.13
|
||||
icss-utils: 5.1.0(postcss@8.5.16)
|
||||
postcss: 8.5.16
|
||||
|
||||
postcss-safe-parser@7.0.1(postcss@8.5.16):
|
||||
dependencies:
|
||||
@@ -12463,7 +12130,7 @@ snapshots:
|
||||
adjust-sourcemap-loader: 4.0.0
|
||||
convert-source-map: 1.9.0
|
||||
loader-utils: 2.0.4
|
||||
postcss: 8.5.13
|
||||
postcss: 8.5.16
|
||||
source-map: 0.6.1
|
||||
|
||||
resolve@1.22.12:
|
||||
@@ -12884,16 +12551,15 @@ snapshots:
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
|
||||
terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.13)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13)):
|
||||
terser-webpack-plugin@5.6.1(postcss@8.5.16)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.46.2
|
||||
terser: 5.48.0
|
||||
webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.13)
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.1
|
||||
postcss: 8.5.13
|
||||
postcss: 8.5.16
|
||||
|
||||
terser@5.46.2:
|
||||
dependencies:
|
||||
@@ -13016,8 +12682,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
tunnel@0.0.6: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -13061,15 +12725,8 @@ snapshots:
|
||||
|
||||
unicode-property-aliases-ecmascript@2.2.0: {}
|
||||
|
||||
universal-user-agent@7.0.3: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unplugin@1.16.1:
|
||||
dependencies:
|
||||
acorn: 8.17.0
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
unrs-resolver@1.12.2:
|
||||
dependencies:
|
||||
napi-postinstall: 0.3.4
|
||||
@@ -13202,19 +12859,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- tslib
|
||||
|
||||
webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16)):
|
||||
dependencies:
|
||||
colorette: 2.0.20
|
||||
memfs: 4.57.8(tslib@2.8.1)
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.3.0
|
||||
schema-utils: 4.3.3
|
||||
optionalDependencies:
|
||||
webpack: 5.108.3(postcss@8.5.16)
|
||||
transitivePeerDependencies:
|
||||
- tslib
|
||||
|
||||
webpack-dev-middleware@8.0.3(tslib@2.8.1)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13)):
|
||||
dependencies:
|
||||
memfs: 4.57.8(tslib@2.8.1)
|
||||
@@ -13266,45 +12910,6 @@ snapshots:
|
||||
- tslib
|
||||
- utf-8-validate
|
||||
|
||||
webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16)):
|
||||
dependencies:
|
||||
'@types/bonjour': 3.5.13
|
||||
'@types/connect-history-api-fallback': 1.5.4
|
||||
'@types/express': 4.17.25
|
||||
'@types/express-serve-static-core': 4.19.8
|
||||
'@types/serve-index': 1.9.4
|
||||
'@types/serve-static': 1.15.10
|
||||
'@types/sockjs': 0.3.36
|
||||
'@types/ws': 8.18.1
|
||||
ansi-html-community: 0.0.8
|
||||
bonjour-service: 1.4.2
|
||||
chokidar: 3.6.0
|
||||
colorette: 2.0.20
|
||||
compression: 1.8.1
|
||||
connect-history-api-fallback: 2.0.0
|
||||
express: 4.22.2
|
||||
graceful-fs: 4.2.11
|
||||
http-proxy-middleware: 2.0.10(@types/express@4.17.25)
|
||||
ipaddr.js: 2.4.0
|
||||
launch-editor: 2.14.1
|
||||
open: 10.2.0
|
||||
p-retry: 6.2.1
|
||||
schema-utils: 4.3.3
|
||||
selfsigned: 5.5.0
|
||||
serve-index: 1.9.2
|
||||
sockjs: 0.3.24
|
||||
spdy: 4.0.2
|
||||
webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16))
|
||||
ws: 8.21.0
|
||||
optionalDependencies:
|
||||
webpack: 5.108.3(postcss@8.5.16)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
- supports-color
|
||||
- tslib
|
||||
- utf-8-validate
|
||||
|
||||
webpack-merge@6.0.1:
|
||||
dependencies:
|
||||
clone-deep: 4.0.1
|
||||
@@ -13318,8 +12923,6 @@ snapshots:
|
||||
typed-assert: 1.0.9
|
||||
webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.13)
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
@@ -13343,45 +12946,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(postcss@8.5.13)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13))
|
||||
watchpack: 2.5.2
|
||||
webpack-sources: 3.5.0
|
||||
transitivePeerDependencies:
|
||||
- '@minify-html/node'
|
||||
- '@swc/core'
|
||||
- '@swc/css'
|
||||
- '@swc/html'
|
||||
- clean-css
|
||||
- cssnano
|
||||
- csso
|
||||
- esbuild
|
||||
- html-minifier-terser
|
||||
- lightningcss
|
||||
- postcss
|
||||
- uglify-js
|
||||
|
||||
webpack@5.108.3(postcss@8.5.16):
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
'@types/json-schema': 7.0.15
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
'@webassemblyjs/wasm-edit': 1.14.1
|
||||
'@webassemblyjs/wasm-parser': 1.14.1
|
||||
acorn: 8.17.0
|
||||
acorn-import-phases: 1.0.4(acorn@8.17.0)
|
||||
browserslist: 4.28.4
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.24.1
|
||||
es-module-lexer: 2.3.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
graceful-fs: 4.2.11
|
||||
loader-runner: 4.3.2
|
||||
mime-db: 1.54.0
|
||||
minimizer-webpack-plugin: 5.6.1(postcss@8.5.16)(webpack@5.108.3(postcss@8.5.16))
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
terser-webpack-plugin: 5.6.1(postcss@8.5.16)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.13))
|
||||
watchpack: 2.5.2
|
||||
webpack-sources: 3.5.0
|
||||
transitivePeerDependencies:
|
||||
@@ -13520,8 +13085,6 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 4.4.2
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.2: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
i18n-info>
|
||||
<div class="input-group input-group-sm align-items-center">
|
||||
<div class="input-group input-group-sm me-3">
|
||||
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label>
|
||||
<span class="input-group-text text-muted" i18n>Show</span>
|
||||
<input
|
||||
id="logLineLimit"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="100"
|
||||
@@ -18,7 +17,7 @@
|
||||
<span class="input-group-text text-muted" i18n>lines</span>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,9 @@ export class LogsComponent
|
||||
parsed.some((log, idx) => {
|
||||
const current = this.logs()[idx]
|
||||
return (
|
||||
current?.message !== log.message || current?.level !== log.level
|
||||
!current ||
|
||||
current.message !== log.message ||
|
||||
current.level !== log.level
|
||||
)
|
||||
})
|
||||
if (hasChanges) {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||
|
||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
|
||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
|
||||
<li [ngbNavItem]="SettingsNavIDs.General">
|
||||
<a ngbNavLink i18n>General</a>
|
||||
<ng-template ngbNavContent>
|
||||
@@ -150,10 +150,10 @@
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<label for="searchLink" i18n>Full search links to</label>
|
||||
<span i18n>Full search links to</span>
|
||||
</div>
|
||||
<div class="col mb-3">
|
||||
<select id="searchLink" class="form-select" formControlName="searchLink">
|
||||
<select class="form-select" formControlName="searchLink">
|
||||
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||
</select>
|
||||
@@ -199,10 +199,10 @@
|
||||
<h5 i18n>Documents</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<label for="documentListItemPerPage" i18n>Items per page</label>
|
||||
<span i18n>Items per page</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage">
|
||||
<select class="form-select" formControlName="documentListItemPerPage">
|
||||
<option [ngValue]="10">10</option>
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
@@ -274,10 +274,10 @@
|
||||
<h5 class="mt-3" i18n>PDF Editor</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label>
|
||||
<span i18n>Default editing mode</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode">
|
||||
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
|
||||
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
||||
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
|
||||
</select>
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
|
||||
activatedRoute.snapshot.fragment = '#notifications'
|
||||
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
||||
component.ngOnInit()
|
||||
expect(component.activeNavID()).toEqual(4) // Notifications
|
||||
expect(component.activeNavID).toEqual(4) // Notifications
|
||||
component.ngAfterViewInit()
|
||||
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
||||
})
|
||||
@@ -399,17 +399,17 @@ describe('SettingsComponent', () => {
|
||||
completeSetup()
|
||||
const field = 'storage_path'
|
||||
expect(
|
||||
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||
).toHaveLength(0)
|
||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
||||
).toEqual(0)
|
||||
component.toggleDocumentDetailField(field, false)
|
||||
expect(
|
||||
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||
).toHaveLength(1)
|
||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
||||
).toEqual(1)
|
||||
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
|
||||
component.toggleDocumentDetailField(field, true)
|
||||
expect(
|
||||
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||
).toHaveLength(0)
|
||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
||||
).toEqual(0)
|
||||
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ export class SettingsComponent
|
||||
private systemStatusService = inject(SystemStatusService)
|
||||
private savedViewsService = inject(SavedViewService)
|
||||
|
||||
readonly activeNavID = signal<number>(undefined)
|
||||
activeNavID: number
|
||||
|
||||
settingsForm = new FormGroup({
|
||||
bulkEditConfirmationDialogs: new FormControl(null),
|
||||
@@ -283,7 +283,7 @@ export class SettingsComponent
|
||||
(navID) => navID.toLowerCase() == section
|
||||
)
|
||||
if (navIDKey) {
|
||||
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||
this.activeNavID = SettingsNavIDs[navIDKey]
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -386,7 +386,7 @@ export class SettingsComponent
|
||||
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
||||
.then((navigated) => {
|
||||
if (!navigated && this.isDirty) {
|
||||
this.activeNavID.set(navChangeEvent.activeId)
|
||||
this.activeNavID = navChangeEvent.activeId
|
||||
} else if (navigated && this.isDirty) {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
||||
</button>
|
||||
<div class="form-check form-switch mb-0 ms-2">
|
||||
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,8 +93,7 @@
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
|
||||
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
|
||||
<input #filterInput class="form-control form-control-sm" type="text"
|
||||
(keyup)="filterInputKeyup($event)"
|
||||
[(ngModel)]="filterText">
|
||||
</div>
|
||||
@@ -212,7 +211,7 @@
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
</button>
|
||||
</td>
|
||||
<td class="actions-col">
|
||||
<td scope="row" class="actions-col">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
|
||||
|
||||
@@ -150,8 +150,7 @@
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||
<span class="close flex-column justify-content-center"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
|
||||
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
|
||||
<i-bs name="x"></i-bs>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||
<div class="chat-container bg-light p-2">
|
||||
<div class="chat-messages font-monospace small">
|
||||
@for (message of messages(); track message) {
|
||||
@for (message of messages; track message) {
|
||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||
<span>
|
||||
@@ -29,19 +29,16 @@
|
||||
</div>
|
||||
|
||||
<form class="chat-input">
|
||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
#chatInput
|
||||
id="chatInput"
|
||||
class="form-control form-control-sm" name="chatInput" type="text"
|
||||
[placeholder]="placeholder"
|
||||
[disabled]="loading()"
|
||||
[ngModel]="input()"
|
||||
(ngModelChange)="input.set($event)"
|
||||
[disabled]="loading"
|
||||
[(ngModel)]="input"
|
||||
(keydown)="searchInputKeyDown($event)"
|
||||
/>
|
||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -56,53 +56,44 @@ describe('ChatComponent', () => {
|
||||
it('should update documentId on initialization', () => {
|
||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
||||
component.ngOnInit()
|
||||
expect(component.documentId()).toBe(123)
|
||||
expect(component.documentId).toBe(123)
|
||||
})
|
||||
|
||||
it('should update documentId on navigation', () => {
|
||||
component.ngOnInit()
|
||||
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
||||
expect(component.documentId()).toBe(456)
|
||||
expect(component.documentId).toBe(456)
|
||||
})
|
||||
|
||||
it('should return correct placeholder based on documentId', () => {
|
||||
component.documentId.set(123)
|
||||
component.documentId = 123
|
||||
expect(component.placeholder).toBe('Ask a question about this document...')
|
||||
component.documentId.set(undefined)
|
||||
component.documentId = undefined
|
||||
expect(component.placeholder).toBe('Ask a question about a document...')
|
||||
})
|
||||
|
||||
it('should send a message and render the streaming response', async () => {
|
||||
component.input.set('Hello')
|
||||
it('should send a message and handle streaming response', () => {
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
expect(component.messages()).toHaveLength(2)
|
||||
expect(component.messages()[0].content).toBe('Hello')
|
||||
expect(component.loading()).toBe(true)
|
||||
expect(component.messages.length).toBe(2)
|
||||
expect(component.messages[0].content).toBe('Hello')
|
||||
expect(component.loading).toBe(true)
|
||||
|
||||
mockStream$.next('Hi')
|
||||
expect(component.messages()[1].content).toBe('H')
|
||||
expect(component.messages[1].content).toBe('H')
|
||||
mockStream$.next('Hi there')
|
||||
// advance time to process the typewriter effect
|
||||
await jest.runAllTimersAsync()
|
||||
await fixture.whenStable()
|
||||
expect(component.messages()[1].content).toBe('Hi there')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.chat-messages').textContent
|
||||
).toContain('Hi there')
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(component.messages[1].content).toBe('Hi there')
|
||||
|
||||
mockStream$.complete()
|
||||
await jest.runAllTimersAsync()
|
||||
await fixture.whenStable()
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.messages()[1].isStreaming).toBe(false)
|
||||
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||
false
|
||||
)
|
||||
expect(component.loading).toBe(false)
|
||||
expect(component.messages[1].isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('should parse references from the metadata trailer without showing it', () => {
|
||||
component.input.set('Hello')
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next(
|
||||
@@ -110,14 +101,14 @@ describe('ChatComponent', () => {
|
||||
)
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
expect(component.messages()[1].content).toBe('Hi there')
|
||||
expect(component.messages()[1].references).toEqual([
|
||||
expect(component.messages[1].content).toBe('Hi there')
|
||||
expect(component.messages[1].references).toEqual([
|
||||
{ id: 42, title: 'Bread Recipe' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should render document reference links under assistant messages', () => {
|
||||
component.input.set('Hello')
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next(
|
||||
@@ -132,12 +123,12 @@ describe('ChatComponent', () => {
|
||||
})
|
||||
|
||||
it('should remove delimiter fragments that were already streamed', () => {
|
||||
component.input.set('Hello')
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(component.messages()[1].content).toBe(
|
||||
expect(component.messages[1].content).toBe(
|
||||
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
||||
)
|
||||
|
||||
@@ -146,28 +137,28 @@ describe('ChatComponent', () => {
|
||||
)
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
expect(component.messages()[1].content).toBe('Hi there')
|
||||
expect(component.messages()[1].references).toEqual([
|
||||
expect(component.messages[1].content).toBe('Hi there')
|
||||
expect(component.messages[1].references).toEqual([
|
||||
{ id: 42, title: 'Bread Recipe' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle errors during streaming', () => {
|
||||
component.input.set('Hello')
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.error('Error')
|
||||
expect(component.messages()[1].content).toContain(
|
||||
expect(component.messages[1].content).toContain(
|
||||
'⚠️ Error receiving response.'
|
||||
)
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('should enqueue typewriter chunks correctly', () => {
|
||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||
component.enqueueTypewriter('Hello', message as any)
|
||||
expect(component['typewriterBuffer']).toHaveLength(4)
|
||||
expect(component['typewriterBuffer'].length).toBe(4)
|
||||
})
|
||||
|
||||
it('should scroll to bottom after sending a message', () => {
|
||||
@@ -175,7 +166,7 @@ describe('ChatComponent', () => {
|
||||
ChatComponent.prototype as any,
|
||||
'scrollToBottom'
|
||||
)
|
||||
component.input.set('Test')
|
||||
component.input = 'Test'
|
||||
component.sendMessage()
|
||||
expect(scrollSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -30,10 +23,10 @@ import {
|
||||
styleUrl: './chat.component.scss',
|
||||
})
|
||||
export class ChatComponent implements OnInit {
|
||||
readonly messages = signal<ChatMessage[]>([])
|
||||
readonly loading = signal(false)
|
||||
readonly input = signal('')
|
||||
readonly documentId = signal<number>(undefined)
|
||||
public messages: ChatMessage[] = []
|
||||
public loading = false
|
||||
public input: string = ''
|
||||
public documentId!: number
|
||||
|
||||
private chatService: ChatService = inject(ChatService)
|
||||
private router: Router = inject(Router)
|
||||
@@ -45,7 +38,7 @@ export class ChatComponent implements OnInit {
|
||||
private typewriterActive = false
|
||||
|
||||
public get placeholder(): string {
|
||||
return this.documentId()
|
||||
return this.documentId
|
||||
? $localize`Ask a question about this document...`
|
||||
: $localize`Ask a question about a document...`
|
||||
}
|
||||
@@ -64,14 +57,14 @@ export class ChatComponent implements OnInit {
|
||||
|
||||
private updateDocumentId(url: string): void {
|
||||
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
||||
this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
|
||||
this.documentId = docIdRe ? +docIdRe[1] : undefined
|
||||
}
|
||||
|
||||
sendMessage(): void {
|
||||
if (!this.input().trim()) return
|
||||
if (!this.input.trim()) return
|
||||
|
||||
const userMessage: ChatMessage = { role: 'user', content: this.input() }
|
||||
this.messages.update((messages) => [...messages, userMessage])
|
||||
const userMessage: ChatMessage = { role: 'user', content: this.input }
|
||||
this.messages.push(userMessage)
|
||||
this.scrollToBottom()
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
@@ -79,12 +72,12 @@ export class ChatComponent implements OnInit {
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
}
|
||||
this.messages.update((messages) => [...messages, assistantMessage])
|
||||
this.loading.set(true)
|
||||
this.messages.push(assistantMessage)
|
||||
this.loading = true
|
||||
|
||||
let lastVisibleContent = ''
|
||||
|
||||
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||
this.chatService.streamChat(this.documentId, this.input).subscribe({
|
||||
next: (chunk) => {
|
||||
const nextResponse = parseChatResponse(chunk)
|
||||
|
||||
@@ -100,30 +93,26 @@ export class ChatComponent implements OnInit {
|
||||
}
|
||||
|
||||
assistantMessage.references = nextResponse.references
|
||||
this.notifyMessagesChanged()
|
||||
},
|
||||
error: () => {
|
||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||
assistantMessage.isStreaming = false
|
||||
this.notifyMessagesChanged()
|
||||
this.loading.set(false)
|
||||
this.loading = false
|
||||
},
|
||||
complete: () => {
|
||||
assistantMessage.isStreaming = false
|
||||
this.notifyMessagesChanged()
|
||||
this.loading.set(false)
|
||||
this.loading = false
|
||||
this.scrollToBottom()
|
||||
},
|
||||
})
|
||||
|
||||
this.input.set('')
|
||||
this.input = ''
|
||||
}
|
||||
|
||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||
this.typewriterBuffer = []
|
||||
this.typewriterActive = false
|
||||
message.content = content
|
||||
this.notifyMessagesChanged()
|
||||
this.scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -146,16 +135,11 @@ export class ChatComponent implements OnInit {
|
||||
|
||||
const nextChar = this.typewriterBuffer.shift()
|
||||
message.content += nextChar
|
||||
this.notifyMessagesChanged()
|
||||
this.scrollToBottom()
|
||||
|
||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||
}
|
||||
|
||||
private notifyMessagesChanged(): void {
|
||||
this.messages.update((messages) => [...messages])
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
setTimeout(() => {
|
||||
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
+1
-11
@@ -1,12 +1,5 @@
|
||||
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
LOCALE_ID,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
@@ -29,7 +22,6 @@ export class CustomFieldDisplayComponent
|
||||
{
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
private documentService = inject(DocumentService)
|
||||
private changeDetector = inject(ChangeDetectorRef)
|
||||
|
||||
CustomFieldDataType = CustomFieldDataType
|
||||
|
||||
@@ -82,7 +74,6 @@ export class CustomFieldDisplayComponent
|
||||
this.customFieldService.listAll().subscribe((r) => {
|
||||
this.customFields = r.results
|
||||
this.init()
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +111,6 @@ export class CustomFieldDisplayComponent
|
||||
this.docLinkDocuments = this.value
|
||||
.map((id) => result.results.find((d) => d.id === id))
|
||||
.filter((d) => d)
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
|
||||
it('should support update unused fields', () => {
|
||||
component.existingFields = [{ field: fields[0].id } as any]
|
||||
component['updateUnusedFields']()
|
||||
expect(component['unusedFields']().length).toEqual(1)
|
||||
expect(component['unusedFields']()[0].name).toEqual('Field 2')
|
||||
expect(component['unusedFields'].length).toEqual(1)
|
||||
expect(component['unusedFields'][0].name).toEqual('Field 2')
|
||||
})
|
||||
|
||||
it('should support getting data type label', () => {
|
||||
|
||||
+4
-7
@@ -8,7 +8,6 @@ import {
|
||||
ViewChild,
|
||||
ViewChildren,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -66,11 +65,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
||||
@ViewChildren('button') buttons: QueryList<ElementRef>
|
||||
|
||||
private customFields: CustomField[] = []
|
||||
private readonly unusedFields = signal<CustomField[]>([])
|
||||
private unusedFields: CustomField[] = []
|
||||
private keyboardIndex: number
|
||||
|
||||
public get filteredFields(): CustomField[] {
|
||||
return this.unusedFields().filter(
|
||||
return this.unusedFields.filter(
|
||||
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
||||
)
|
||||
}
|
||||
@@ -100,10 +99,8 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
||||
}
|
||||
|
||||
private updateUnusedFields() {
|
||||
this.unusedFields.set(
|
||||
this.customFields.filter(
|
||||
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
||||
)
|
||||
this.unusedFields = this.customFields.filter(
|
||||
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@
|
||||
<div class="input-group input-group-sm">
|
||||
<ng-select #fieldSelects
|
||||
class="paperless-input-select"
|
||||
[items]="customFields()"
|
||||
[items]="customFields"
|
||||
[(ngModel)]="atom.field"
|
||||
[disabled]="disabled"
|
||||
bindLabel="name"
|
||||
|
||||
+3
-3
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
})
|
||||
|
||||
it('should initialize custom fields on creation', () => {
|
||||
expect(component.customFields()).toEqual(customFields)
|
||||
expect(component.customFields).toEqual(customFields)
|
||||
})
|
||||
|
||||
it('should add an expression when opened if queries are empty', () => {
|
||||
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
data_type: CustomFieldDataType.String,
|
||||
extra_data: {},
|
||||
}
|
||||
component.customFields.set([field])
|
||||
component.customFields = [field]
|
||||
const operators = component.getOperatorsForField(1)
|
||||
expect(operators.length).toEqual(
|
||||
[
|
||||
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
],
|
||||
},
|
||||
}
|
||||
component.customFields.set([field])
|
||||
component.customFields = [field]
|
||||
const options = component.getSelectOptionsForField(1)
|
||||
expect(options).toEqual([
|
||||
{ label: 'Option 1', id: 'abc-123' },
|
||||
|
||||
+5
-6
@@ -6,7 +6,6 @@ import {
|
||||
Input,
|
||||
Output,
|
||||
QueryList,
|
||||
signal,
|
||||
ViewChild,
|
||||
ViewChildren,
|
||||
} from '@angular/core'
|
||||
@@ -279,7 +278,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
@Output()
|
||||
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
||||
|
||||
readonly customFields = signal<CustomField[]>([])
|
||||
customFields: CustomField[] = []
|
||||
|
||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||
|
||||
@@ -326,12 +325,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => {
|
||||
this.customFields.set(result.results)
|
||||
this.customFields = result.results
|
||||
})
|
||||
}
|
||||
|
||||
public getCustomFieldByID(id: number): CustomField {
|
||||
return this.customFields().find((field) => field.id === id)
|
||||
return this.customFields.find((field) => field.id === id)
|
||||
}
|
||||
|
||||
public addAtom(expression: CustomFieldQueryExpression) {
|
||||
@@ -354,7 +353,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
getOperatorsForField(
|
||||
fieldID: number
|
||||
): Array<{ value: string; label: string }> {
|
||||
const field = this.customFields().find((field) => field.id === fieldID)
|
||||
const field = this.customFields.find((field) => field.id === fieldID)
|
||||
const groups: CustomFieldQueryOperatorGroups[] = field
|
||||
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
||||
: [CustomFieldQueryOperatorGroups.Basic]
|
||||
@@ -370,7 +369,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
getSelectOptionsForField(
|
||||
fieldID: number
|
||||
): Array<{ label: string; id: string }> {
|
||||
const field = this.customFields().find((field) => field.id === fieldID)
|
||||
const field = this.customFields.find((field) => field.id === fieldID)
|
||||
if (field) {
|
||||
return field.extra_data['select_options']
|
||||
}
|
||||
|
||||
+5
-5
@@ -22,11 +22,11 @@
|
||||
<ng-template>
|
||||
<div class="card mb-2">
|
||||
<div class="card-body p-2">
|
||||
@if (testLoading()) {
|
||||
@if (testLoading) {
|
||||
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
||||
} @else if (testResult()) {
|
||||
<code>{{testResult()}}</code>
|
||||
} @else if (testFailed()) {
|
||||
} @else if (testResult) {
|
||||
<code>{{testResult}}</code>
|
||||
} @else if (testFailed) {
|
||||
<div class="text-danger" i18n>Path test failed</div>
|
||||
} @else {
|
||||
<div class="text-muted small" i18n>No document selected</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
[compareWith]="compareDocuments"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="loading()"
|
||||
[loading]="loading"
|
||||
[typeahead]="documentsInput$"
|
||||
(change)="testPath($event)">
|
||||
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
||||
|
||||
+5
-5
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
component.testPath({ id: 1 })
|
||||
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
||||
expect(component.testResult()).toBe('test/abc123')
|
||||
expect(component.testFailed()).toBeFalsy()
|
||||
expect(component.testResult).toBe('test/abc123')
|
||||
expect(component.testFailed).toBeFalsy()
|
||||
|
||||
// test failed
|
||||
testSpy.mockReturnValueOnce(of(''))
|
||||
component.testPath({ id: 1 })
|
||||
expect(component.testResult()).toBeNull()
|
||||
expect(component.testFailed()).toBeTruthy()
|
||||
expect(component.testResult).toBeNull()
|
||||
expect(component.testFailed).toBeTruthy()
|
||||
|
||||
component.testPath(null)
|
||||
expect(component.testResult()).toBeNull()
|
||||
expect(component.testResult).toBeNull()
|
||||
})
|
||||
|
||||
it('should compare two documents by id', () => {
|
||||
|
||||
+11
-11
@@ -1,5 +1,5 @@
|
||||
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, OnDestroy, inject, signal } from '@angular/core'
|
||||
import { Component, OnDestroy, inject } from '@angular/core'
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
|
||||
public documentsInput$ = new Subject<string>()
|
||||
public foundDocuments$: Observable<Document[]>
|
||||
private testDocument: Document
|
||||
readonly testResult = signal<string>(undefined)
|
||||
readonly testFailed = signal(false)
|
||||
readonly testLoading = signal(false)
|
||||
public testResult: string
|
||||
public testFailed: boolean = false
|
||||
public testLoading = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
|
||||
|
||||
public testPath(document: Document) {
|
||||
if (!document) {
|
||||
this.testResult.set(null)
|
||||
this.testResult = null
|
||||
return
|
||||
}
|
||||
this.testDocument = document
|
||||
this.testLoading.set(true)
|
||||
this.testLoading = true
|
||||
;(this.service as StoragePathService)
|
||||
.testPath(this.objectForm.get('path').value, document.id)
|
||||
.subscribe((result) => {
|
||||
if (result?.length) {
|
||||
this.testResult.set(result)
|
||||
this.testFailed.set(false)
|
||||
this.testResult = result
|
||||
this.testFailed = false
|
||||
} else {
|
||||
this.testResult.set(null)
|
||||
this.testFailed.set(true)
|
||||
this.testResult = null
|
||||
this.testFailed = true
|
||||
}
|
||||
this.testLoading.set(false)
|
||||
this.testLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
||||
|
||||
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
||||
|
||||
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, signal } from '@angular/core'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
|
||||
],
|
||||
})
|
||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||
readonly tags = signal<Tag[]>([])
|
||||
tags: Tag[]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||
this.userService = inject(UserService)
|
||||
this.settingsService = inject(SettingsService)
|
||||
this.service.listAll().subscribe((result) => {
|
||||
this.tags.set(result.results)
|
||||
this.tags = result.results
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@
|
||||
<div class="col">
|
||||
<div class="trigger-filters mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="form-label mb-0" i18n>Advanced Filters</span>
|
||||
<label class="form-label mb-0" i18n>Advanced Filters</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary ms-auto"
|
||||
|
||||
+5
-5
@@ -401,7 +401,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
component.addFilter(triggerGroup as FormGroup)
|
||||
|
||||
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
|
||||
expect(filters).toHaveLength(3)
|
||||
expect(filters.length).toBe(3)
|
||||
|
||||
filters.at(0).get('values').setValue([1])
|
||||
filters.at(1).get('values').setValue([2, 3])
|
||||
@@ -672,7 +672,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
} as any
|
||||
|
||||
const filters = component['buildFiltersFormArray'](trigger)
|
||||
expect(filters).toHaveLength(0)
|
||||
expect(filters.length).toBe(0)
|
||||
|
||||
component.filterDefinitions = originalDefinitions
|
||||
})
|
||||
@@ -739,7 +739,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
component.ngOnInit()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
const filters = component.getFiltersFormArray(triggerGroup)
|
||||
expect(filters).toHaveLength(13)
|
||||
expect(filters.length).toBe(13)
|
||||
const customFieldFilter = filters.at(12) as FormGroup
|
||||
expect(customFieldFilter.get('type').value).toBe(
|
||||
TriggerFilterType.CustomFieldQuery
|
||||
@@ -969,12 +969,12 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
component.addFilter(triggerGroup)
|
||||
|
||||
component.removeFilter(triggerGroup, 0)
|
||||
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(0)
|
||||
expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
|
||||
|
||||
component.addFilter(triggerGroup)
|
||||
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
|
||||
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
|
||||
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(1)
|
||||
expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
|
||||
})
|
||||
|
||||
it('should remove selected custom field from the form group', () => {
|
||||
|
||||
+1
-1
@@ -933,7 +933,7 @@ export class WorkflowEditDialogComponent
|
||||
|
||||
getFilterSelectItems(type: TriggerFilterType) {
|
||||
const definition = this.getFilterDefinition(type)
|
||||
if (definition?.inputType !== 'select') {
|
||||
if (!definition || definition.inputType !== 'select') {
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
+17
-15
@@ -34,30 +34,32 @@
|
||||
</div>
|
||||
@if (selectionModel.items) {
|
||||
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
|
||||
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index">
|
||||
<pngx-toggleable-dropdown-button
|
||||
[item]="item"
|
||||
[hideCount]="hideCount(item)"
|
||||
[opacifyCount]="!editing"
|
||||
[state]="selectionModel.get(item.id)"
|
||||
[count]="getUpdatedDocumentCount(item.id)"
|
||||
(toggled)="selectionModel.toggle(item.id)"
|
||||
(exclude)="excludeClicked(item.id)"
|
||||
[disabled]="disabled">
|
||||
</pngx-toggleable-dropdown-button>
|
||||
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
|
||||
@if (allowSelectNone || item.id) {
|
||||
<pngx-toggleable-dropdown-button
|
||||
[item]="item"
|
||||
[hideCount]="hideCount(item)"
|
||||
[opacifyCount]="!editing"
|
||||
[state]="selectionModel.get(item.id)"
|
||||
[count]="getUpdatedDocumentCount(item.id)"
|
||||
(toggled)="selectionModel.toggle(item.id)"
|
||||
(exclude)="excludeClicked(item.id)"
|
||||
[disabled]="disabled">
|
||||
</pngx-toggleable-dropdown-button>
|
||||
}
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
}
|
||||
@if (editing) {
|
||||
@if (filteredItems.length === 0 && createRef !== undefined) {
|
||||
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@if (filteredItems.length > 0) {
|
||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
|
||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
|
||||
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
}
|
||||
|
||||
+3
-28
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
selectionModel.toggle(items[0].id)
|
||||
fixture.detectChanges()
|
||||
expect(component.modelIsDirty()).toBeTruthy()
|
||||
expect(component.modelIsDirty).toBeTruthy()
|
||||
let applyResult: ChangedItems
|
||||
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
||||
component.apply.subscribe((result) => (applyResult = result))
|
||||
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
selectionModel.toggle(items[0].id)
|
||||
fixture.detectChanges()
|
||||
expect(component.modelIsDirty()).toBeTruthy()
|
||||
expect(component.modelIsDirty).toBeTruthy()
|
||||
let applyResult: ChangedItems
|
||||
component.apply.subscribe((result) => (applyResult = result))
|
||||
component.dropdown.close()
|
||||
@@ -265,9 +265,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(document.activeElement).toEqual(
|
||||
component.listFilterTextInput.nativeElement
|
||||
)
|
||||
expect(component.buttonsViewport.getRenderedRange().end).toEqual(
|
||||
items.length
|
||||
) // all selectable items shown
|
||||
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
|
||||
|
||||
component.filterText = 'Tag2'
|
||||
fixture.detectChanges()
|
||||
@@ -280,29 +278,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(component.filterText).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should omit disallowed null items from the virtual scroll viewport', async () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
fixture.detectChanges()
|
||||
await wait(100)
|
||||
fixture.detectChanges()
|
||||
component.buttonsViewport?.checkViewportSize()
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.filteredItems).toEqual(items)
|
||||
expect(component.scrollViewportHeight).toEqual(
|
||||
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
|
||||
)
|
||||
expect(
|
||||
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
|
||||
'.cdk-virtual-scroll-content-wrapper > div'
|
||||
)
|
||||
).toHaveLength(items.length)
|
||||
})
|
||||
|
||||
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
|
||||
+10
-15
@@ -12,7 +12,6 @@ import {
|
||||
Output,
|
||||
ViewChild,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -661,6 +660,7 @@ export class FilterableDropdownSelectionModel {
|
||||
imports: [
|
||||
ClearableBadgeComponent,
|
||||
ToggleableDropdownButtonComponent,
|
||||
FilterPipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
@@ -796,27 +796,22 @@ export class FilterableDropdownComponent
|
||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||
}
|
||||
|
||||
readonly modelIsDirty = signal(false)
|
||||
modelIsDirty: boolean = false
|
||||
|
||||
private keyboardIndex: number
|
||||
|
||||
public get filteredItems(): MatchingModel[] {
|
||||
return this.filterPipe
|
||||
.transform(this.items, this.filterText, 'name')
|
||||
.filter((item) => this.allowSelectNone || Boolean(item.id))
|
||||
}
|
||||
|
||||
public get scrollViewportHeight(): number {
|
||||
return Math.min(
|
||||
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX,
|
||||
400
|
||||
)
|
||||
const filteredLength = this.filterPipe.transform(
|
||||
this.items,
|
||||
this.filterText
|
||||
).length
|
||||
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.selectionModelChange.subscribe((updatedModel) => {
|
||||
this.modelIsDirty.set(updatedModel.isDirty())
|
||||
this.modelIsDirty = updatedModel.isDirty()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -863,7 +858,7 @@ export class FilterableDropdownComponent
|
||||
}, 0)
|
||||
if (this.editing) {
|
||||
this.selectionModel.reset()
|
||||
this.modelIsDirty.set(false)
|
||||
this.modelIsDirty = false
|
||||
}
|
||||
this.selectionModel.singleSelect =
|
||||
this.editing && !this.selectionModel.manyToOne
|
||||
@@ -882,7 +877,7 @@ export class FilterableDropdownComponent
|
||||
}
|
||||
|
||||
listFilterEnter(): void {
|
||||
const filtered = this.filteredItems
|
||||
let filtered = this.filterPipe.transform(this.items, this.filterText)
|
||||
if (filtered.length == 1) {
|
||||
this.selectionModel.toggle(filtered[0].id)
|
||||
setTimeout(() => {
|
||||
|
||||
+3
-4
@@ -5,7 +5,6 @@ import {
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
FormsModule,
|
||||
@@ -64,11 +63,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
||||
|
||||
super()
|
||||
customFieldsService.listAll().subscribe((items) => {
|
||||
this.fields.set(items.results)
|
||||
this.fields = items.results
|
||||
})
|
||||
}
|
||||
|
||||
private readonly fields = signal<CustomField[]>([])
|
||||
private fields: CustomField[]
|
||||
|
||||
private _selectedFields: number[]
|
||||
|
||||
@@ -91,6 +90,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
||||
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
|
||||
|
||||
public getCustomField(id: number): CustomField {
|
||||
return this.fields().find((field) => field.id === id)
|
||||
return this.fields.find((field) => field.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
[compareWith]="compareDocuments"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="loading()"
|
||||
[loading]="loading"
|
||||
[typeahead]="documentsInput$"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
(change)="onChange(selectedDocumentIDs)">
|
||||
|
||||
+2
-26
@@ -2,11 +2,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { provideRouter } from '@angular/router'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { of, Subject, throwError } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { DocumentLinkComponent } from './document-link.component'
|
||||
@@ -37,11 +33,10 @@ describe('DocumentLinkComponent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||
imports: [DocumentLinkComponent],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
],
|
||||
})
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
@@ -65,25 +60,6 @@ describe('DocumentLinkComponent', () => {
|
||||
expect(getSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render loading and selected documents after async updates', async () => {
|
||||
const result$ = new Subject<any>()
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
|
||||
|
||||
component.writeValue([1])
|
||||
await fixture.whenStable()
|
||||
|
||||
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
|
||||
.componentInstance as NgSelectComponent
|
||||
expect(select.loading()).toBe(true)
|
||||
|
||||
result$.next({ count: 1, all: [1], results: [documents[0]] })
|
||||
result$.complete()
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(select.loading()).toBe(false)
|
||||
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
|
||||
})
|
||||
|
||||
it('shoud maintain ordering of selected documents', () => {
|
||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||
getSpy.mockImplementation((ids) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
FormsModule,
|
||||
@@ -64,7 +63,7 @@ export class DocumentLinkComponent
|
||||
|
||||
documentsInput$ = new Subject<string>()
|
||||
foundDocuments$: Observable<Document[]>
|
||||
readonly loading = signal(false)
|
||||
loading = false
|
||||
selectedDocuments: Document[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
@@ -94,12 +93,12 @@ export class DocumentLinkComponent
|
||||
this.selectedDocuments = []
|
||||
super.writeValue([])
|
||||
} else {
|
||||
this.loading.set(true)
|
||||
this.loading = true
|
||||
this.documentsService
|
||||
.getFew(documentIDs, { fields: 'id,title' })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((documentResults) => {
|
||||
this.loading.set(false)
|
||||
this.loading = false
|
||||
this.selectedDocuments = documentIDs.map(
|
||||
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||
)
|
||||
@@ -114,7 +113,7 @@ export class DocumentLinkComponent
|
||||
this.documentsInput$.pipe(
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap(() => this.loading.set(true)),
|
||||
tap(() => (this.loading = true)),
|
||||
switchMap((title) =>
|
||||
this.documentsService
|
||||
.listFiltered(
|
||||
@@ -134,7 +133,7 @@ export class DocumentLinkComponent
|
||||
)
|
||||
),
|
||||
catchError(() => of([])), // empty on error
|
||||
tap(() => this.loading.set(false))
|
||||
tap(() => (this.loading = false))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
||||
this.documentService.getNextAsn().subscribe((nextAsn) => {
|
||||
this.value = nextAsn
|
||||
this.onChange(this.value)
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -157,8 +157,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
private removeChildren(tagIDs: number[], tag: Tag) {
|
||||
if (tag.children?.length) {
|
||||
const childIDs = new Set(tag.children.map((child) => child.id))
|
||||
tagIDs = tagIDs.filter((id) => !childIDs.has(id))
|
||||
const childIDs = tag.children.map((child) => child.id)
|
||||
tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
|
||||
for (const child of tag.children) {
|
||||
tagIDs = this.removeChildren(tagIDs, child)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { environment } from 'src/environments/environment'
|
||||
})
|
||||
export class PageHeaderComponent {
|
||||
private titleService = inject(Title)
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
private clipboard = inject(Clipboard)
|
||||
|
||||
readonly id = input<number>(undefined)
|
||||
readonly subTitle = input('')
|
||||
|
||||
@@ -49,7 +49,8 @@ describe('PngxPdfViewerComponent', () => {
|
||||
new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString()
|
||||
)
|
||||
const isVisible = (component as any).findController.onIsPageVisible as
|
||||
(() => boolean) | undefined
|
||||
| (() => boolean)
|
||||
| undefined
|
||||
expect(isVisible?.()).toBe(true)
|
||||
expect(loadSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ numPages: 1 })
|
||||
@@ -60,7 +61,6 @@ describe('PngxPdfViewerComponent', () => {
|
||||
|
||||
it('resolves the worker source relative to the document base URI', async () => {
|
||||
setBaseHref('/paperless/')
|
||||
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||
|
||||
await initComponent()
|
||||
|
||||
@@ -70,13 +70,6 @@ describe('PngxPdfViewerComponent', () => {
|
||||
expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain(
|
||||
'/paperless/assets/js/pdf.worker.min.mjs'
|
||||
)
|
||||
expect(getDocumentSpy).toHaveBeenCalledWith({
|
||||
url: 'test.pdf',
|
||||
password: undefined,
|
||||
withCredentials: true,
|
||||
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
|
||||
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
|
||||
})
|
||||
})
|
||||
|
||||
it('initializes single-page viewer and disables text layer', async () => {
|
||||
@@ -138,18 +131,6 @@ describe('PngxPdfViewerComponent', () => {
|
||||
expect(applyScaleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reset the viewer when it is already on the requested page', async () => {
|
||||
await initComponent()
|
||||
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
const currentPageSpy = jest.spyOn(viewer, 'currentPageNumber', 'set')
|
||||
component.page = viewer.currentPageNumber
|
||||
|
||||
;(component as any).applyViewerState()
|
||||
|
||||
expect(currentPageSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches find when search query changes after render', async () => {
|
||||
await initComponent()
|
||||
|
||||
|
||||
@@ -174,12 +174,10 @@ export class PngxPdfViewerComponent
|
||||
'assets/js/pdf.worker.min.mjs',
|
||||
this.document.baseURI
|
||||
).toString()
|
||||
const initOptions = {
|
||||
let initOptions = {
|
||||
url: this.src,
|
||||
password: this.password,
|
||||
withCredentials: true,
|
||||
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
|
||||
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
|
||||
}
|
||||
this.loadingTask = getDocument(initOptions)
|
||||
try {
|
||||
@@ -256,9 +254,7 @@ export class PngxPdfViewerComponent
|
||||
Math.max(Math.trunc(this.page), 1),
|
||||
this.pdfViewer.pagesCount
|
||||
)
|
||||
if (nextPage !== this.pdfViewer.currentPageNumber) {
|
||||
this.pdfViewer.currentPageNumber = nextPage
|
||||
}
|
||||
this.pdfViewer.currentPageNumber = nextPage
|
||||
}
|
||||
if (this.page === this.lastViewerPage) {
|
||||
this.lastViewerPage = undefined
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@
|
||||
[(ngModel)]="selectionModel.includeUsers"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[items]="users()"
|
||||
[items]="users"
|
||||
bindLabel="username"
|
||||
multiple="true"
|
||||
bindValue="id"
|
||||
|
||||
+3
-10
@@ -1,12 +1,5 @@
|
||||
import { NgClass } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
@@ -82,7 +75,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
@Output()
|
||||
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
|
||||
|
||||
readonly users = signal<User[]>([])
|
||||
users: User[]
|
||||
|
||||
hideUnowned: boolean
|
||||
|
||||
@@ -109,7 +102,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (result) => this.users.set(result.results),
|
||||
next: (result) => (this.users = result.results),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+17
-17
@@ -57,15 +57,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
@if (socialAccounts().length > 0) {
|
||||
@if (socialAccounts?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Connected social accounts</p>
|
||||
<ul class="list-group">
|
||||
@for (account of socialAccounts(); track account.id) {
|
||||
@for (account of socialAccounts; track account.id) {
|
||||
<li class="list-group-item"
|
||||
ngbPopover="Set a password before disconnecting social account."
|
||||
i18n-ngbPopover
|
||||
[disablePopover]="hasUsablePassword()"
|
||||
[disablePopover]="hasUsablePassword"
|
||||
triggers="mouseenter:mouseleave">
|
||||
{{account.name}} ({{account.provider}})
|
||||
<pngx-confirm-button
|
||||
@@ -75,7 +75,7 @@
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||
iconName="trash"
|
||||
[disabled]="!hasUsablePassword()"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="disconnectSocialAccount(account.id)">
|
||||
</pngx-confirm-button>
|
||||
</li>
|
||||
@@ -84,11 +84,11 @@
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||
</div>
|
||||
}
|
||||
@if (socialAccountProviders().length > 0) {
|
||||
@if (socialAccountProviders?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Connect new social account</p>
|
||||
<div class="list-group">
|
||||
@for (provider of socialAccountProviders(); track provider.name) {
|
||||
@for (provider of socialAccountProviders; track provider.name) {
|
||||
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
|
||||
</a>
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!isTotpEnabled()) {
|
||||
@if (!isTotpEnabled) {
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
@@ -105,10 +105,10 @@
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
@if (totpSettingsLoading()) {
|
||||
@if (totpSettingsLoading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
} @else if (totpSettings()) {
|
||||
} @else if (totpSettings) {
|
||||
<figure class="figure">
|
||||
@if (qrSvgDataUrl) {
|
||||
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
|
||||
@@ -116,14 +116,14 @@
|
||||
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
||||
</figure>
|
||||
<p>
|
||||
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>.
|
||||
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
|
||||
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
||||
</p>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
||||
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()">
|
||||
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
|
||||
<ng-container i18n>Enable</ng-container>
|
||||
@if (totpLoading()) {
|
||||
@if (totpLoading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
@@ -137,18 +137,18 @@
|
||||
</div>
|
||||
} @else {
|
||||
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
||||
@if (recoveryCodes()) {
|
||||
@if (recoveryCodes) {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
||||
</div>
|
||||
<div class="d-flex flex-row align-items-start mb-3">
|
||||
<ul class="list-group w-50">
|
||||
@for (code of recoveryCodes(); track code; let i = $index) {
|
||||
@for (code of recoveryCodes; track code; let i = $index) {
|
||||
@if (i % 2 === 0) {
|
||||
<li class="list-group-item d-flex justify-content-around align-items-center">
|
||||
<code>{{code}}</code>
|
||||
@if (recoveryCodes()[i + 1]) {
|
||||
<code>{{recoveryCodes()[i + 1]}}</code>
|
||||
@if (recoveryCodes[i + 1]) {
|
||||
<code>{{recoveryCodes[i + 1]}}</code>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
@@ -171,7 +171,7 @@
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm"
|
||||
iconName="trash"
|
||||
[disabled]="totpLoading()"
|
||||
[disabled]="totpLoading"
|
||||
(confirm)="deactivateTotp()">
|
||||
</pngx-confirm-button>
|
||||
}
|
||||
|
||||
+12
-37
@@ -10,7 +10,7 @@ import {
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { NEVER, of, Subject, throwError } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { ProfileService } from 'src/app/services/profile.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import * as navUtils from 'src/app/utils/navigation'
|
||||
@@ -60,10 +60,6 @@ describe('ProfileEditDialogComponent', () => {
|
||||
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
|
||||
})
|
||||
profileService = TestBed.inject(ProfileService)
|
||||
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
|
||||
jest
|
||||
.spyOn(profileService, 'getSocialAccountProviders')
|
||||
.mockReturnValue(of([]))
|
||||
toastService = TestBed.inject(ToastService)
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
||||
@@ -159,7 +155,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.hasUsablePassword.set(true)
|
||||
component.hasUsablePassword = true
|
||||
component.ngOnInit()
|
||||
component.form.get('password').patchValue('new*pass')
|
||||
component.onPasswordKeyUp({
|
||||
@@ -272,27 +268,6 @@ describe('ProfileEditDialogComponent', () => {
|
||||
expect(getProvidersSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render social account providers after an async update', async () => {
|
||||
const providers$ = new Subject<typeof socialAccountProviders>()
|
||||
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
|
||||
jest
|
||||
.spyOn(profileService, 'getSocialAccountProviders')
|
||||
.mockReturnValue(providers$)
|
||||
|
||||
component.ngOnInit()
|
||||
await fixture.whenStable()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||
).toBeNull()
|
||||
|
||||
providers$.next(socialAccountProviders)
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should remove disconnected social account from component, show error if needed', () => {
|
||||
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
@@ -301,7 +276,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
expect(component.socialAccounts()).toContainEqual(socialAccount)
|
||||
expect(component.socialAccounts).toContainEqual(socialAccount)
|
||||
|
||||
// fail first
|
||||
disconnectSpy.mockReturnValueOnce(
|
||||
@@ -314,7 +289,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
||||
component.disconnectSocialAccount(socialAccount.id)
|
||||
expect(disconnectSpy).toHaveBeenCalled()
|
||||
expect(component.socialAccounts()).not.toContainEqual(socialAccount)
|
||||
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
||||
})
|
||||
|
||||
it('should get totp settings', () => {
|
||||
@@ -335,7 +310,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
getSpy.mockReturnValue(of(settings))
|
||||
component.gettotpSettings()
|
||||
expect(getSpy).toHaveBeenCalled()
|
||||
expect(component.totpSettings()).toEqual(settings)
|
||||
expect(component.totpSettings).toEqual(settings)
|
||||
})
|
||||
|
||||
it('should activate totp', () => {
|
||||
@@ -344,15 +319,15 @@ describe('ProfileEditDialogComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const error = new Error('failed to activate totp')
|
||||
activateSpy.mockReturnValueOnce(throwError(() => error))
|
||||
component.totpSettings.set({
|
||||
component.totpSettings = {
|
||||
url: 'http://localhost/',
|
||||
qr_svg: 'svg',
|
||||
secret: 'secret',
|
||||
})
|
||||
}
|
||||
component.form.get('totp_code').patchValue('123456')
|
||||
component.activateTotp()
|
||||
expect(activateSpy).toHaveBeenCalledWith(
|
||||
component.totpSettings().secret,
|
||||
component.totpSettings.secret,
|
||||
component.form.get('totp_code').value
|
||||
)
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
@@ -366,8 +341,8 @@ describe('ProfileEditDialogComponent', () => {
|
||||
)
|
||||
component.activateTotp()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(component.isTotpEnabled()).toBeTruthy()
|
||||
expect(component.recoveryCodes()).toEqual(['1', '2', '3'])
|
||||
expect(component.isTotpEnabled).toBeTruthy()
|
||||
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
|
||||
})
|
||||
|
||||
it('should deactivate totp', () => {
|
||||
@@ -387,13 +362,13 @@ describe('ProfileEditDialogComponent', () => {
|
||||
deactivateSpy.mockReturnValueOnce(of(true))
|
||||
component.deactivateTotp()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(component.isTotpEnabled()).toBeFalsy()
|
||||
expect(component.isTotpEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should copy recovery codes', () => {
|
||||
jest.useFakeTimers()
|
||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||
component.recoveryCodes.set(['1', '2', '3'])
|
||||
component.recoveryCodes = ['1', '2', '3']
|
||||
component.copyRecoveryCodes()
|
||||
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
||||
jest.advanceTimersByTime(3000)
|
||||
|
||||
+33
-36
@@ -56,14 +56,6 @@ export class ProfileEditDialogComponent
|
||||
readonly showEmailConfirm = signal(false)
|
||||
readonly copied = signal(false)
|
||||
readonly codesCopied = signal(false)
|
||||
readonly hasUsablePassword = signal(false)
|
||||
readonly isTotpEnabled = signal(false)
|
||||
readonly totpSettings = signal<TotpSettings>(undefined)
|
||||
readonly totpSettingsLoading = signal(false)
|
||||
readonly totpLoading = signal(false)
|
||||
readonly recoveryCodes = signal<string[]>(undefined)
|
||||
readonly socialAccounts = signal<SocialAccount[]>([])
|
||||
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
|
||||
|
||||
public form = new FormGroup({
|
||||
email: new FormControl(''),
|
||||
@@ -80,15 +72,25 @@ export class ProfileEditDialogComponent
|
||||
private newPassword: string
|
||||
private passwordConfirm: string
|
||||
|
||||
public hasUsablePassword: boolean = false
|
||||
|
||||
private currentEmail: string
|
||||
private newEmail: string
|
||||
private emailConfirm: string
|
||||
|
||||
public isTotpEnabled: boolean = false
|
||||
public totpSettings: TotpSettings
|
||||
public totpSettingsLoading: boolean = false
|
||||
public totpLoading: boolean = false
|
||||
public recoveryCodes: string[]
|
||||
public socialAccounts: SocialAccount[] = []
|
||||
public socialAccountProviders: SocialAccountProvider[] = []
|
||||
|
||||
get qrSvgDataUrl(): string | null {
|
||||
if (!this.totpSettings()?.qr_svg) {
|
||||
if (!this.totpSettings?.qr_svg) {
|
||||
return null
|
||||
}
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}`
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -105,20 +107,20 @@ export class ProfileEditDialogComponent
|
||||
this.onEmailChange()
|
||||
})
|
||||
this.currentPassword = profile.password
|
||||
this.hasUsablePassword.set(profile.has_usable_password)
|
||||
this.hasUsablePassword = profile.has_usable_password
|
||||
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
||||
this.newPassword = newPassword
|
||||
this.onPasswordChange()
|
||||
})
|
||||
this.socialAccounts.set(profile.social_accounts ?? [])
|
||||
this.isTotpEnabled.set(profile.is_mfa_enabled)
|
||||
this.socialAccounts = profile.social_accounts
|
||||
this.isTotpEnabled = profile.is_mfa_enabled
|
||||
})
|
||||
|
||||
this.profileService
|
||||
.getSocialAccountProviders()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((providers) => {
|
||||
this.socialAccountProviders.set(providers ?? [])
|
||||
this.socialAccountProviders = providers
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,9 +259,7 @@ export class ProfileEditDialogComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (id: number) => {
|
||||
this.socialAccounts.update((accounts) =>
|
||||
accounts.filter((account) => account.id != id)
|
||||
)
|
||||
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
@@ -271,39 +271,36 @@ export class ProfileEditDialogComponent
|
||||
}
|
||||
|
||||
public gettotpSettings(): void {
|
||||
this.totpSettingsLoading.set(true)
|
||||
this.totpSettingsLoading = true
|
||||
this.profileService
|
||||
.getTotpSettings()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (totpSettings) => {
|
||||
this.totpSettingsLoading.set(false)
|
||||
this.totpSettings.set(totpSettings)
|
||||
this.totpSettingsLoading = false
|
||||
this.totpSettings = totpSettings
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error fetching TOTP settings`,
|
||||
error
|
||||
)
|
||||
this.totpSettingsLoading.set(false)
|
||||
this.totpSettingsLoading = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public activateTotp(): void {
|
||||
this.totpLoading.set(true)
|
||||
this.totpLoading = true
|
||||
this.form.get('totp_code').disable()
|
||||
this.profileService
|
||||
.activateTotp(
|
||||
this.totpSettings().secret,
|
||||
this.form.get('totp_code').value
|
||||
)
|
||||
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (activationResponse) => {
|
||||
this.totpLoading.set(false)
|
||||
this.isTotpEnabled.set(activationResponse.success)
|
||||
this.recoveryCodes.set(activationResponse.recovery_codes)
|
||||
this.totpLoading = false
|
||||
this.isTotpEnabled = activationResponse.success
|
||||
this.recoveryCodes = activationResponse.recovery_codes
|
||||
this.form.get('totp_code').enable()
|
||||
if (activationResponse.success) {
|
||||
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||
@@ -312,7 +309,7 @@ export class ProfileEditDialogComponent
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.totpLoading.set(false)
|
||||
this.totpLoading = false
|
||||
this.form.get('totp_code').enable()
|
||||
this.toastService.showError($localize`Error activating TOTP`, error)
|
||||
},
|
||||
@@ -320,15 +317,15 @@ export class ProfileEditDialogComponent
|
||||
}
|
||||
|
||||
public deactivateTotp(): void {
|
||||
this.totpLoading.set(true)
|
||||
this.totpLoading = true
|
||||
this.profileService
|
||||
.deactivateTotp()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (success) => {
|
||||
this.totpLoading.set(false)
|
||||
this.isTotpEnabled.set(!success)
|
||||
this.recoveryCodes.set(null)
|
||||
this.totpLoading = false
|
||||
this.isTotpEnabled = !success
|
||||
this.recoveryCodes = null
|
||||
if (success) {
|
||||
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||
} else {
|
||||
@@ -336,14 +333,14 @@ export class ProfileEditDialogComponent
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.totpLoading.set(false)
|
||||
this.totpLoading = false
|
||||
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public copyRecoveryCodes(): void {
|
||||
this.clipboard.copy(this.recoveryCodes().join('\n'))
|
||||
this.clipboard.copy(this.recoveryCodes.join('\n'))
|
||||
this.codesCopied.set(true)
|
||||
setTimeout(() => {
|
||||
this.codesCopied.set(false)
|
||||
|
||||
+1
-2
@@ -65,9 +65,8 @@
|
||||
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
|
||||
<dt class="col-sm-4" i18n>Link</dt>
|
||||
<dd class="col-sm-8">
|
||||
<label class="visually-hidden" for="shareBundleLink" i18n>Share link</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input id="shareBundleLink" class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
|
||||
<input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
|
||||
<button
|
||||
class="btn btn-outline-primary"
|
||||
type="button"
|
||||
|
||||
+1
-3
@@ -14,9 +14,7 @@
|
||||
}
|
||||
|
||||
<div content class="wrapper fade" [class.show]="show()">
|
||||
@if (error()) {
|
||||
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
|
||||
} @else if (displayMode() === DisplayMode.TABLE) {
|
||||
@if (displayMode() === DisplayMode.TABLE) {
|
||||
<table class="table table-hover mb-0 mt-n2 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
+2
-27
@@ -1,10 +1,6 @@
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { DatePipe } from '@angular/common'
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
provideHttpClient,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
@@ -12,7 +8,7 @@ import { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { Subject, of } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
||||
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
||||
@@ -234,27 +230,6 @@ describe('SavedViewWidgetComponent', () => {
|
||||
expect(component.documents()).toEqual(documentResults)
|
||||
})
|
||||
|
||||
it('should show an error if documents fail to load', () => {
|
||||
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
error: { added__date__lte: ['Enter a valid date.'] },
|
||||
status: 400,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
component.reload()
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.error()).toEqual('Added: Enter a valid date.')
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||
'Error while loading documents: Added: Enter a valid date.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reload on document consumption finished', () => {
|
||||
const fileStatusSubject = new Subject<FileStatus>()
|
||||
jest
|
||||
|
||||
+6
-37
@@ -125,8 +125,6 @@ export class SavedViewWidgetComponent
|
||||
|
||||
readonly count = signal<number>(null)
|
||||
|
||||
readonly error = signal<string | null>(null)
|
||||
|
||||
placeholderRows: number[] = []
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -182,7 +180,6 @@ export class SavedViewWidgetComponent
|
||||
|
||||
reload() {
|
||||
this.loading.set(this.documents().length == 0)
|
||||
this.error.set(null)
|
||||
this.show.set(true)
|
||||
this.documentService
|
||||
.listFiltered(
|
||||
@@ -194,40 +191,12 @@ export class SavedViewWidgetComponent
|
||||
{ truncate_content: true }
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.documents.set(result.results)
|
||||
this.count.set(result.count)
|
||||
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
||||
this.loading.set(false)
|
||||
this.show.set(true)
|
||||
},
|
||||
error: (error) => {
|
||||
this.documents.set([])
|
||||
this.count.set(null)
|
||||
let errorMessage
|
||||
if (
|
||||
typeof error.error === 'object' &&
|
||||
Object.keys(error.error).length > 0
|
||||
) {
|
||||
errorMessage = Object.keys(error.error)
|
||||
.map((fieldName) => {
|
||||
const fieldNameBase = fieldName.split('__')[0]
|
||||
const fieldError: Array<string> = error.error[fieldName]
|
||||
return `${
|
||||
this.documentService.sortFields.find(
|
||||
(f) => f.field?.split('__')[0] == fieldNameBase
|
||||
)?.name ?? fieldNameBase
|
||||
}: ${fieldError[0]}`
|
||||
})
|
||||
.join(', ')
|
||||
} else {
|
||||
errorMessage = error.error
|
||||
}
|
||||
this.error.set(errorMessage)
|
||||
this.loading.set(false)
|
||||
this.show.set(true)
|
||||
},
|
||||
.subscribe((result) => {
|
||||
this.documents.set(result.results)
|
||||
this.count.set(result.count)
|
||||
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
||||
this.loading.set(false)
|
||||
this.show.set(true)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
<p i18n>Searching document with asn {{asn()}}</p>
|
||||
<p i18n>Searching document with asn {{asn}}</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { FILTER_ASN } from '../../data/filter-rule-type'
|
||||
import { DocumentService } from '../../services/rest/document.service'
|
||||
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute)
|
||||
private router = inject(Router)
|
||||
|
||||
readonly asn = signal<string>(undefined)
|
||||
asn: string
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe((paramMap) => {
|
||||
this.asn.set(paramMap.get('id'))
|
||||
this.asn = paramMap.get('id')
|
||||
this.documentsService
|
||||
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }])
|
||||
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
|
||||
.subscribe((documentId) => {
|
||||
if (documentId.length == 1) {
|
||||
this.router.navigate(['documents', documentId[0]])
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
<td>{{metadata()?.original_filename}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n>Original SHA256 checksum</td>
|
||||
<td i18n>Original MD5 checksum</td>
|
||||
<td>{{metadata()?.original_checksum}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -328,7 +328,7 @@
|
||||
</tr>
|
||||
@if (metadata()?.has_archive_version) {
|
||||
<tr>
|
||||
<td i18n>Archive SHA256 checksum</td>
|
||||
<td i18n>Archive MD5 checksum</td>
|
||||
<td>{{metadata()?.archive_checksum}}</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -482,7 +482,9 @@
|
||||
</pngx-pdf-viewer>
|
||||
</div>
|
||||
} @else {
|
||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
|
||||
<span>Preview is unavailable.</span>
|
||||
</object>
|
||||
}
|
||||
}
|
||||
@case (ContentRenderType.Text) {
|
||||
@@ -503,7 +505,9 @@
|
||||
}
|
||||
}
|
||||
@case (ContentRenderType.Other) {
|
||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
|
||||
<span>Preview is unavailable.</span>
|
||||
</object>
|
||||
}
|
||||
}
|
||||
@if (requiresPassword) {
|
||||
|
||||
@@ -510,7 +510,7 @@ describe('DocumentDetailComponent', () => {
|
||||
const deleteButtons = fixture.debugElement.queryAll(
|
||||
By.css('pngx-confirm-button')
|
||||
)
|
||||
expect(deleteButtons).toHaveLength(1)
|
||||
expect(deleteButtons.length).toEqual(1)
|
||||
})
|
||||
|
||||
it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
@if (loading()) {
|
||||
<div class="d-flex">
|
||||
<output class="spinner-border spinner-border-sm fw-normal"></output>
|
||||
<output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="list-group">
|
||||
|
||||
+6
-6
@@ -26,11 +26,11 @@ import { UserService } from 'src/app/services/rest/user.service'
|
||||
],
|
||||
})
|
||||
export class DocumentHistoryComponent implements OnInit {
|
||||
private readonly documentService = inject(DocumentService)
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly storagePathService = inject(StoragePathService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly userService = inject(UserService)
|
||||
private documentService = inject(DocumentService)
|
||||
private correspondentService = inject(CorrespondentService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
private documentTypeService = inject(DocumentTypeService)
|
||||
private userService = inject(UserService)
|
||||
|
||||
public AuditLogAction = AuditLogAction
|
||||
|
||||
@@ -70,7 +70,7 @@ export class DocumentHistoryComponent implements OnInit {
|
||||
return cached
|
||||
}
|
||||
|
||||
const idInt = Number.parseInt(id, 10)
|
||||
const idInt = parseInt(id, 10)
|
||||
const fallback$ = of(id)
|
||||
|
||||
let result$: Observable<string>
|
||||
|
||||
+7
-12
@@ -5,11 +5,10 @@
|
||||
</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
<div class="px-3 py-2 mb-2">
|
||||
@if (versionUploadState() === UploadState.Idle) {
|
||||
@if (versionUploadState === UploadState.Idle) {
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
|
||||
<span class="input-group-text" i18n>Label</span>
|
||||
<input
|
||||
id="newVersionLabel"
|
||||
class="form-control"
|
||||
type="text"
|
||||
[(ngModel)]="newVersionLabel"
|
||||
@@ -32,7 +31,7 @@
|
||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
||||
</button>
|
||||
} @else {
|
||||
@switch (versionUploadState()) {
|
||||
@switch (versionUploadState) {
|
||||
@case (UploadState.Uploading) {
|
||||
<div class="small text-muted mt-1 d-flex align-items-center">
|
||||
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
|
||||
@@ -50,8 +49,8 @@
|
||||
<span i18n>Version upload failed.</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
|
||||
</div>
|
||||
@if (versionUploadError()) {
|
||||
<div class="small text-muted mt-1">{{ versionUploadError() }}</div>
|
||||
@if (versionUploadError) {
|
||||
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,19 +70,15 @@
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
@if (isEditingVersion(version.id)) {
|
||||
<label class="visually-hidden" for="versionLabel{{ version.id }}" i18n>Version label</label>
|
||||
}
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
@if (isEditingVersion(version.id)) {
|
||||
<input
|
||||
id="versionLabel{{ version.id }}"
|
||||
class="form-control"
|
||||
type="text"
|
||||
[(ngModel)]="versionLabelDraft"
|
||||
i18n-placeholder
|
||||
placeholder="Version label"
|
||||
[disabled]="savingVersionLabelId() !== null"
|
||||
[disabled]="savingVersionLabelId !== null"
|
||||
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
||||
(keydown.escape)="cancelEditingVersion($event)"
|
||||
(click)="$event.stopPropagation()"
|
||||
@@ -101,7 +96,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
[disabled]="savingVersionLabelId() !== null"
|
||||
[disabled]="savingVersionLabelId !== null"
|
||||
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
||||
>
|
||||
@if (isEditingVersion(version.id)) {
|
||||
|
||||
+15
-17
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
||||
])
|
||||
expect(component.savingVersionLabelId()).toBeNull()
|
||||
expect(component.savingVersionLabelId).toBeNull()
|
||||
})
|
||||
|
||||
it('saveVersionLabel should show error toast on failure', () => {
|
||||
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
'Error updating version label',
|
||||
error
|
||||
)
|
||||
expect(component.savingVersionLabelId()).toBeNull()
|
||||
expect(component.savingVersionLabelId).toBeNull()
|
||||
})
|
||||
|
||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||
expect(component.versionUploadError()).toBeNull()
|
||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
||||
expect(component.versionUploadError).toBeNull()
|
||||
})
|
||||
|
||||
it('onVersionFileSelected should render failed state after websocket failure', async () => {
|
||||
it('onVersionFileSelected should set failed state after websocket failure', () => {
|
||||
const file = new File(['test'], 'new-version.pdf', {
|
||||
type: 'application/pdf',
|
||||
})
|
||||
@@ -266,11 +266,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
|
||||
component.onVersionFileSelected({ target: input } as Event)
|
||||
failed$.next({ taskId: 'task-1', message: 'processing failed' })
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||
expect(component.versionUploadError()).toEqual('processing failed')
|
||||
expect(fixture.nativeElement.textContent).toContain('processing failed')
|
||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
||||
expect(component.versionUploadError).toEqual('processing failed')
|
||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -284,8 +282,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
|
||||
component.onVersionFileSelected({ target: input } as Event)
|
||||
|
||||
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||
expect(component.versionUploadError()).toEqual('Missing task ID.')
|
||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
||||
expect(component.versionUploadError).toEqual('Missing task ID.')
|
||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -300,8 +298,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
|
||||
component.onVersionFileSelected({ target: input } as Event)
|
||||
|
||||
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||
expect(component.versionUploadError()).toEqual('upload failed')
|
||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
||||
expect(component.versionUploadError).toEqual('upload failed')
|
||||
expect(toastService.showError).toHaveBeenCalledWith(
|
||||
'Error uploading new version',
|
||||
error
|
||||
@@ -309,8 +307,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
})
|
||||
|
||||
it('ngOnChanges should clear upload status on document switch', () => {
|
||||
component.versionUploadState.set(UploadState.Failed)
|
||||
component.versionUploadError.set('something failed')
|
||||
component.versionUploadState = UploadState.Failed
|
||||
component.versionUploadError = 'something failed'
|
||||
component.editingVersionId = 10
|
||||
component.versionLabelDraft = 'draft'
|
||||
|
||||
@@ -318,8 +316,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
documentId: new SimpleChange(3, 4, false),
|
||||
})
|
||||
|
||||
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||
expect(component.versionUploadError()).toBeNull()
|
||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
||||
expect(component.versionUploadError).toBeNull()
|
||||
expect(component.editingVersionId).toBeNull()
|
||||
expect(component.versionLabelDraft).toEqual('')
|
||||
})
|
||||
|
||||
+20
-24
@@ -7,7 +7,6 @@ import {
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
signal,
|
||||
SimpleChanges,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
@@ -60,9 +59,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
|
||||
|
||||
newVersionLabel: string = ''
|
||||
readonly versionUploadState = signal(UploadState.Idle)
|
||||
readonly versionUploadError = signal<string | null>(null)
|
||||
readonly savingVersionLabelId = signal<number | null>(null)
|
||||
versionUploadState: UploadState = UploadState.Idle
|
||||
versionUploadError: string | null = null
|
||||
savingVersionLabelId: number | null = null
|
||||
editingVersionId: number | null = null
|
||||
versionLabelDraft: string = ''
|
||||
|
||||
@@ -102,7 +101,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return
|
||||
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
|
||||
this.editingVersionId = version.id
|
||||
this.versionLabelDraft = version.version_label ?? ''
|
||||
}
|
||||
@@ -117,7 +116,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (this.savingVersionLabelId() !== null) return
|
||||
if (this.savingVersionLabelId !== null) return
|
||||
const nextLabel = this.versionLabelDraft?.trim() || null
|
||||
const currentLabel = version.version_label?.trim() || null
|
||||
if (nextLabel === currentLabel) {
|
||||
@@ -159,15 +158,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
||||
if (this.savingVersionLabelId() !== null) return
|
||||
this.savingVersionLabelId.set(versionId)
|
||||
if (this.savingVersionLabelId !== null) return
|
||||
this.savingVersionLabelId = versionId
|
||||
this.documentsService
|
||||
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
||||
.pipe(
|
||||
first(),
|
||||
finalize(() => {
|
||||
if (this.savingVersionLabelId() === versionId) {
|
||||
this.savingVersionLabelId.set(null)
|
||||
if (this.savingVersionLabelId === versionId) {
|
||||
this.savingVersionLabelId = null
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
@@ -200,8 +199,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
const file = input.files[0]
|
||||
input.value = ''
|
||||
const label = this.newVersionLabel?.trim()
|
||||
this.versionUploadState.set(UploadState.Uploading)
|
||||
this.versionUploadError.set(null)
|
||||
this.versionUploadState = UploadState.Uploading
|
||||
this.versionUploadError = null
|
||||
this.documentsService
|
||||
.uploadVersion(uploadDocumentId, file, label)
|
||||
.pipe(
|
||||
@@ -211,7 +210,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
$localize`Uploading new version. Processing will happen in the background.`
|
||||
)
|
||||
this.newVersionLabel = ''
|
||||
this.versionUploadState.set(UploadState.Processing)
|
||||
this.versionUploadState = UploadState.Processing
|
||||
}),
|
||||
map((taskId) =>
|
||||
typeof taskId === 'string'
|
||||
@@ -220,8 +219,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
),
|
||||
switchMap((taskId) => {
|
||||
if (!taskId) {
|
||||
this.versionUploadState.set(UploadState.Failed)
|
||||
this.versionUploadError.set($localize`Missing task ID.`)
|
||||
this.versionUploadState = UploadState.Failed
|
||||
this.versionUploadError = $localize`Missing task ID.`
|
||||
return of(null)
|
||||
}
|
||||
return merge(
|
||||
@@ -241,10 +240,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
switchMap((result) => {
|
||||
if (result?.state !== 'success') {
|
||||
if (result?.state === 'failed') {
|
||||
this.versionUploadState.set(UploadState.Failed)
|
||||
this.versionUploadError.set(
|
||||
this.versionUploadState = UploadState.Failed
|
||||
this.versionUploadError =
|
||||
result.message || $localize`Upload failed.`
|
||||
)
|
||||
}
|
||||
return of(null)
|
||||
}
|
||||
@@ -266,10 +264,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
},
|
||||
error: (error) => {
|
||||
if (uploadDocumentId !== this.documentId) return
|
||||
this.versionUploadState.set(UploadState.Failed)
|
||||
this.versionUploadError.set(
|
||||
error?.message || $localize`Upload failed.`
|
||||
)
|
||||
this.versionUploadState = UploadState.Failed
|
||||
this.versionUploadError = error?.message || $localize`Upload failed.`
|
||||
this.toastService.showError(
|
||||
$localize`Error uploading new version`,
|
||||
error
|
||||
@@ -279,7 +275,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
clearVersionUploadStatus(): void {
|
||||
this.versionUploadState.set(UploadState.Idle)
|
||||
this.versionUploadError.set(null)
|
||||
this.versionUploadState = UploadState.Idle
|
||||
this.versionUploadError = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[createRef]="createTag.bind(this)"
|
||||
(opened)="openTagsDropdown()"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
[documentCounts]="tagDocumentCounts()"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
(apply)="setTags($event)"
|
||||
shortcutKey="t">
|
||||
</pngx-filterable-dropdown>
|
||||
@@ -24,7 +24,7 @@
|
||||
[createRef]="createCorrespondent.bind(this)"
|
||||
(opened)="openCorrespondentDropdown()"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
[documentCounts]="correspondentDocumentCounts()"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
(apply)="setCorrespondents($event)"
|
||||
shortcutKey="y">
|
||||
</pngx-filterable-dropdown>
|
||||
@@ -38,7 +38,7 @@
|
||||
[createRef]="createDocumentType.bind(this)"
|
||||
(opened)="openDocumentTypeDropdown()"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
[documentCounts]="documentTypeDocumentCounts()"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
(apply)="setDocumentTypes($event)"
|
||||
shortcutKey="u">
|
||||
</pngx-filterable-dropdown>
|
||||
@@ -52,7 +52,7 @@
|
||||
[createRef]="createStoragePath.bind(this)"
|
||||
(opened)="openStoragePathDropdown()"
|
||||
[(selectionModel)]="storagePathsSelectionModel"
|
||||
[documentCounts]="storagePathDocumentCounts()"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
(apply)="setStoragePaths($event)"
|
||||
shortcutKey="i">
|
||||
</pngx-filterable-dropdown>
|
||||
@@ -66,7 +66,7 @@
|
||||
[createRef]="createCustomField.bind(this)"
|
||||
(opened)="openCustomFieldsDropdown()"
|
||||
[(selectionModel)]="customFieldsSelectionModel"
|
||||
[documentCounts]="customFieldDocumentCounts()"
|
||||
[documentCounts]="customFieldDocumentCounts"
|
||||
extraButtonTitle="Set values"
|
||||
i18n-extraButtonTitle
|
||||
(extraButton)="setCustomFieldValues($event)"
|
||||
@@ -103,13 +103,13 @@
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
id="dropdownSend"
|
||||
ngbDropdownToggle
|
||||
[disabled]="disabled || !canSendSelection"
|
||||
[disabled]="disabled || !list.hasSelection || list.allSelected"
|
||||
>
|
||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
|
||||
</div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
||||
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
|
||||
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
|
||||
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||
@@ -117,18 +117,18 @@
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
@if (emailEnabled) {
|
||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
|
||||
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
|
||||
@if (!awaitingDownload()) {
|
||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||
@if (!awaitingDownload) {
|
||||
<i-bs name="arrow-down"></i-bs>
|
||||
}
|
||||
@if (awaitingDownload()) {
|
||||
@if (awaitingDownload) {
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">Preparing download...</span>
|
||||
</div>
|
||||
|
||||
@@ -208,50 +208,6 @@ describe('BulkEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should allow sending an all-filtered selection that fits on the current page', () => {
|
||||
jest
|
||||
.spyOn(documentListViewService, 'hasSelection', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||
.mockReturnValue(5)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.canSendSelection).toBe(true)
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should prevent sending an all-filtered selection spanning multiple pages', () => {
|
||||
jest
|
||||
.spyOn(documentListViewService, 'hasSelection', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||
.mockReturnValue(6)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.canSendSelection).toBe(false)
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply selection data to correspondents menu', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
@@ -347,7 +303,7 @@ describe('BulkEditorComponent', () => {
|
||||
component.openDocumentTypeDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||
expect(component.documentTypeDocumentCounts()).toEqual(
|
||||
expect(component.documentTypeDocumentCounts).toEqual(
|
||||
selectionData.selected_document_types
|
||||
)
|
||||
})
|
||||
@@ -364,7 +320,7 @@ describe('BulkEditorComponent', () => {
|
||||
component.openCorrespondentDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||
expect(component.correspondentDocumentCounts()).toEqual(
|
||||
expect(component.correspondentDocumentCounts).toEqual(
|
||||
selectionData.selected_correspondents
|
||||
)
|
||||
})
|
||||
@@ -381,7 +337,7 @@ describe('BulkEditorComponent', () => {
|
||||
component.openStoragePathDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||
expect(component.storagePathDocumentCounts()).toEqual(
|
||||
expect(component.storagePathDocumentCounts).toEqual(
|
||||
selectionData.selected_storage_paths
|
||||
)
|
||||
})
|
||||
@@ -398,7 +354,7 @@ describe('BulkEditorComponent', () => {
|
||||
component.openCustomFieldsDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||
expect(component.customFieldDocumentCounts()).toEqual(
|
||||
expect(component.customFieldDocumentCounts).toEqual(
|
||||
selectionData.selected_custom_fields
|
||||
)
|
||||
})
|
||||
@@ -1641,7 +1597,6 @@ describe('BulkEditorComponent', () => {
|
||||
expect(modal.componentInstance.customFields.length).toEqual(2)
|
||||
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
|
||||
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
|
||||
expect(modal.componentInstance.selectionCount).toEqual(2)
|
||||
expect(modal.componentInstance.documents).toEqual([3, 4])
|
||||
|
||||
modal.componentInstance.failed.emit()
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
@@ -108,12 +101,12 @@ export class BulkEditorComponent
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
||||
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||
readonly awaitingDownload = signal(false)
|
||||
tagDocumentCounts: SelectionDataItem[]
|
||||
correspondentDocumentCounts: SelectionDataItem[]
|
||||
documentTypeDocumentCounts: SelectionDataItem[]
|
||||
storagePathDocumentCounts: SelectionDataItem[]
|
||||
customFieldDocumentCounts: SelectionDataItem[]
|
||||
awaitingDownload: boolean
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
@@ -372,8 +365,8 @@ export class BulkEditorComponent
|
||||
openTagsDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
||||
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
||||
this.tagDocumentCounts = selectionData?.selected_tags ?? []
|
||||
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -381,7 +374,7 @@ export class BulkEditorComponent
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.tagDocumentCounts.set(s.selected_tags)
|
||||
this.tagDocumentCounts = s.selected_tags
|
||||
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
|
||||
})
|
||||
}
|
||||
@@ -389,11 +382,10 @@ export class BulkEditorComponent
|
||||
openDocumentTypeDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.documentTypeDocumentCounts.set(
|
||||
this.documentTypeDocumentCounts =
|
||||
selectionData?.selected_document_types ?? []
|
||||
)
|
||||
this.applySelectionData(
|
||||
this.documentTypeDocumentCounts(),
|
||||
this.documentTypeDocumentCounts,
|
||||
this.documentTypeSelectionModel
|
||||
)
|
||||
return
|
||||
@@ -403,7 +395,7 @@ export class BulkEditorComponent
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
||||
this.documentTypeDocumentCounts = s.selected_document_types
|
||||
this.applySelectionData(
|
||||
s.selected_document_types,
|
||||
this.documentTypeSelectionModel
|
||||
@@ -414,11 +406,10 @@ export class BulkEditorComponent
|
||||
openCorrespondentDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.correspondentDocumentCounts.set(
|
||||
this.correspondentDocumentCounts =
|
||||
selectionData?.selected_correspondents ?? []
|
||||
)
|
||||
this.applySelectionData(
|
||||
this.correspondentDocumentCounts(),
|
||||
this.correspondentDocumentCounts,
|
||||
this.correspondentSelectionModel
|
||||
)
|
||||
return
|
||||
@@ -428,7 +419,7 @@ export class BulkEditorComponent
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
||||
this.correspondentDocumentCounts = s.selected_correspondents
|
||||
this.applySelectionData(
|
||||
s.selected_correspondents,
|
||||
this.correspondentSelectionModel
|
||||
@@ -439,11 +430,10 @@ export class BulkEditorComponent
|
||||
openStoragePathDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.storagePathDocumentCounts.set(
|
||||
this.storagePathDocumentCounts =
|
||||
selectionData?.selected_storage_paths ?? []
|
||||
)
|
||||
this.applySelectionData(
|
||||
this.storagePathDocumentCounts(),
|
||||
this.storagePathDocumentCounts,
|
||||
this.storagePathsSelectionModel
|
||||
)
|
||||
return
|
||||
@@ -453,7 +443,7 @@ export class BulkEditorComponent
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
||||
this.storagePathDocumentCounts = s.selected_storage_paths
|
||||
this.applySelectionData(
|
||||
s.selected_storage_paths,
|
||||
this.storagePathsSelectionModel
|
||||
@@ -464,11 +454,10 @@ export class BulkEditorComponent
|
||||
openCustomFieldsDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.customFieldDocumentCounts.set(
|
||||
this.customFieldDocumentCounts =
|
||||
selectionData?.selected_custom_fields ?? []
|
||||
)
|
||||
this.applySelectionData(
|
||||
this.customFieldDocumentCounts(),
|
||||
this.customFieldDocumentCounts,
|
||||
this.customFieldsSelectionModel
|
||||
)
|
||||
return
|
||||
@@ -478,7 +467,7 @@ export class BulkEditorComponent
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
||||
this.customFieldDocumentCounts = s.selected_custom_fields
|
||||
this.applySelectionData(
|
||||
s.selected_custom_fields,
|
||||
this.customFieldsSelectionModel
|
||||
@@ -887,7 +876,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
downloadSelected() {
|
||||
this.awaitingDownload.set(true)
|
||||
this.awaitingDownload = true
|
||||
let downloadFileType: string =
|
||||
this.downloadForm.get('downloadFileTypeArchive').value &&
|
||||
this.downloadForm.get('downloadFileTypeOriginals').value
|
||||
@@ -904,7 +893,7 @@ export class BulkEditorComponent
|
||||
.pipe(first())
|
||||
.subscribe((result: any) => {
|
||||
saveAs(result, 'documents.zip')
|
||||
this.awaitingDownload.set(false)
|
||||
this.awaitingDownload = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1020,7 +1009,6 @@ export class BulkEditorComponent
|
||||
)
|
||||
|
||||
dialog.selection = this.getSelectionQuery()
|
||||
dialog.selectionCount = this.getSelectionSize()
|
||||
dialog.succeeded.subscribe((result) => {
|
||||
this.toastService.showInfo($localize`Custom fields updated.`)
|
||||
this.list.reload()
|
||||
@@ -1041,14 +1029,6 @@ export class BulkEditorComponent
|
||||
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
}
|
||||
|
||||
public get canSendSelection(): boolean {
|
||||
return (
|
||||
this.list.hasSelection &&
|
||||
(!this.list.allSelected ||
|
||||
this.list.selectedCount === this.list.selected.size)
|
||||
)
|
||||
}
|
||||
|
||||
createShareLinkBundle() {
|
||||
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
|
||||
backdrop: 'static',
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title" i8n>{
|
||||
documentCount,
|
||||
documents.length,
|
||||
plural,
|
||||
=1 {Set custom fields for 1 document} other {Set custom fields for {{documentCount}} documents}
|
||||
=1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
|
||||
}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
|
||||
-14
@@ -42,20 +42,6 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
|
||||
expect(component.form.contains('2')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render the document count for a filtered selection', () => {
|
||||
component.selection = {
|
||||
all: true,
|
||||
filters: { title__icontains: 'invoice' },
|
||||
}
|
||||
component.selectionCount = 42
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.documents).toEqual([])
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.modal-title').textContent
|
||||
).toContain('Set custom fields for 42 documents')
|
||||
})
|
||||
|
||||
it('should emit succeeded event and close modal on successful save', () => {
|
||||
const editSpy = jest
|
||||
.spyOn(documentService, 'bulkEdit')
|
||||
|
||||
+1
-7
@@ -81,14 +81,8 @@ export class CustomFieldsBulkEditDialogComponent {
|
||||
|
||||
public selection: DocumentSelectionQuery = { documents: [] }
|
||||
|
||||
public selectionCount: number
|
||||
|
||||
public get documents(): number[] {
|
||||
return this.selection.documents ?? []
|
||||
}
|
||||
|
||||
public get documentCount(): number {
|
||||
return this.selectionCount ?? this.documents.length
|
||||
return this.selection.documents
|
||||
}
|
||||
|
||||
initForm() {
|
||||
|
||||
+1
-5
@@ -2,11 +2,7 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
||||
@if (document()) {
|
||||
@if (priority()) {
|
||||
<img [ngSrc]="getThumbUrl()" fill priority class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||
} @else {
|
||||
<img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||
}
|
||||
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
||||
|
||||
<div class="border-end border-bottom bg-light document-card-check">
|
||||
<div class="form-check">
|
||||
|
||||
-15
@@ -88,21 +88,6 @@ describe('DocumentCardLargeComponent', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
||||
})
|
||||
|
||||
it('should lazy load the thumbnail', () => {
|
||||
const thumbnail: HTMLImageElement =
|
||||
fixture.nativeElement.querySelector('img.doc-img')
|
||||
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
|
||||
})
|
||||
|
||||
it('should prioritize the thumbnail when requested', () => {
|
||||
fixture.componentRef.setInput('priority', true)
|
||||
fixture.detectChanges()
|
||||
const thumbnail: HTMLImageElement =
|
||||
fixture.nativeElement.querySelector('img.doc-img')
|
||||
expect(thumbnail.getAttribute('loading')).toEqual('eager')
|
||||
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
|
||||
})
|
||||
|
||||
it('should trim content', () => {
|
||||
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
||||
})
|
||||
|
||||
+1
-3
@@ -1,4 +1,4 @@
|
||||
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
@@ -46,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
TagComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
AsyncPipe,
|
||||
NgOptimizedImage,
|
||||
UsernamePipe,
|
||||
CorrespondentNamePipe,
|
||||
DocumentTypeNamePipe,
|
||||
@@ -66,7 +65,6 @@ export class DocumentCardLargeComponent
|
||||
private documentService = inject(DocumentService)
|
||||
settingsService = inject(SettingsService)
|
||||
readonly selected = input(false)
|
||||
readonly priority = input(false)
|
||||
readonly displayFields = input<string[]>(
|
||||
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
|
||||
)
|
||||
|
||||
+1
-5
@@ -2,11 +2,7 @@
|
||||
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
|
||||
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
||||
@if (document()) {
|
||||
@if (priority()) {
|
||||
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill priority alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||
} @else {
|
||||
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||
}
|
||||
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
|
||||
|
||||
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
|
||||
<div class="form-check">
|
||||
|
||||
+1
-5
@@ -22,14 +22,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.doc-img-container {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.doc-img {
|
||||
object-fit: cover;
|
||||
object-position: top left;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.document-card-check {
|
||||
|
||||
-15
@@ -61,21 +61,6 @@ describe('DocumentCardSmallComponent', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('12 pages')
|
||||
})
|
||||
|
||||
it('should lazy load the thumbnail', () => {
|
||||
const thumbnail: HTMLImageElement =
|
||||
fixture.nativeElement.querySelector('img.doc-img')
|
||||
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
|
||||
})
|
||||
|
||||
it('should prioritize the thumbnail when requested', () => {
|
||||
fixture.componentRef.setInput('priority', true)
|
||||
fixture.detectChanges()
|
||||
const thumbnail: HTMLImageElement =
|
||||
fixture.nativeElement.querySelector('img.doc-img')
|
||||
expect(thumbnail.getAttribute('loading')).toEqual('eager')
|
||||
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
|
||||
})
|
||||
|
||||
it('should display a document, limit tags to 5', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('Document 10')
|
||||
expect(
|
||||
|
||||
+1
-3
@@ -1,4 +1,4 @@
|
||||
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
@@ -46,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
TagComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
AsyncPipe,
|
||||
NgOptimizedImage,
|
||||
UsernamePipe,
|
||||
CorrespondentNamePipe,
|
||||
DocumentTypeNamePipe,
|
||||
@@ -66,7 +65,6 @@ export class DocumentCardSmallComponent
|
||||
private documentService = inject(DocumentService)
|
||||
settingsService = inject(SettingsService)
|
||||
readonly selected = input(false)
|
||||
readonly priority = input(false)
|
||||
readonly document = input<Document>(undefined)
|
||||
readonly displayFields = input<string[]>(
|
||||
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||
</div>
|
||||
|
||||
@@ -164,10 +164,9 @@
|
||||
} @else {
|
||||
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
|
||||
<div>
|
||||
@for (d of list.documents; track d.id; let i = $index) {
|
||||
@for (d of list.documents; track d.id) {
|
||||
<pngx-document-card-large
|
||||
[selected]="list.isSelected(d)"
|
||||
[priority]="i < 2"
|
||||
(toggleSelected)="toggleSelected(d, $event)"
|
||||
(dblClickDocument)="openDocumentDetail(d)"
|
||||
[document]="d"
|
||||
@@ -399,10 +398,9 @@
|
||||
}
|
||||
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
|
||||
<div class="row row-cols-paperless-cards">
|
||||
@for (d of list.documents; track d.id; let i = $index) {
|
||||
@for (d of list.documents; track d.id) {
|
||||
<pngx-document-card-small class="p-0"
|
||||
[selected]="list.isSelected(d)"
|
||||
[priority]="i < 6"
|
||||
(toggleSelected)="toggleSelected(d, $event)"
|
||||
(dblClickDocument)="openDocumentDetail(d)"
|
||||
[document]="d"
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
QueryList,
|
||||
signal,
|
||||
ViewChild,
|
||||
ViewChildren,
|
||||
} from '@angular/core'
|
||||
@@ -150,7 +149,7 @@ export class DocumentListComponent
|
||||
)
|
||||
}
|
||||
|
||||
readonly unmodifiedFilterRules = signal<FilterRule[]>([])
|
||||
unmodifiedFilterRules: FilterRule[] = []
|
||||
private unmodifiedSavedView: SavedView
|
||||
private activeSavedView: SavedView | null = null
|
||||
|
||||
@@ -300,7 +299,7 @@ export class DocumentListComponent
|
||||
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
||||
})
|
||||
this.updateDisplayCustomFields()
|
||||
this.unmodifiedFilterRules.set(view.filter_rules)
|
||||
this.unmodifiedFilterRules = view.filter_rules
|
||||
})
|
||||
|
||||
this.route.queryParamMap
|
||||
@@ -317,7 +316,7 @@ export class DocumentListComponent
|
||||
this.activeSavedView = null
|
||||
this.list.activateSavedView(null)
|
||||
this.list.loadFromQueryParams(queryParams)
|
||||
this.unmodifiedFilterRules.set([])
|
||||
this.unmodifiedFilterRules = []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -416,7 +415,7 @@ export class DocumentListComponent
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||
)
|
||||
this.unmodifiedFilterRules.set(this.list.filterRules)
|
||||
this.unmodifiedFilterRules = this.list.filterRules
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.showError(
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
shortcutKey="i"></pngx-filterable-dropdown>
|
||||
}
|
||||
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) {
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
|
||||
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
|
||||
[(selectionModel)]="customFieldQueriesModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
|
||||
+1
-1
@@ -328,7 +328,7 @@ describe('FilterEditorComponent', () => {
|
||||
})
|
||||
|
||||
it('should ingest legacy text filter rules for doc title + content', () => {
|
||||
expect(component.textFilter).toBeNull()
|
||||
expect(component.textFilter).toEqual(null)
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_TITLE_CONTENT,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
Output,
|
||||
ViewChild,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -351,7 +349,7 @@ export class FilterEditorComponent
|
||||
@ViewChild('textFilterInput')
|
||||
textFilterInput: ElementRef
|
||||
|
||||
readonly customFields = signal<CustomField[]>([])
|
||||
customFields: CustomField[] = []
|
||||
|
||||
tagDocumentCounts: SelectionDataItem[]
|
||||
correspondentDocumentCounts: SelectionDataItem[]
|
||||
@@ -516,7 +514,6 @@ export class FilterEditorComponent
|
||||
this.documentService.get(this._moreLikeId).subscribe((result) => {
|
||||
this._moreLikeDoc = result
|
||||
this._textFilter = result.title
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
break
|
||||
case FILTER_CREATED_AFTER:
|
||||
@@ -1165,7 +1162,6 @@ export class FilterEditorComponent
|
||||
|
||||
private loadingCountTotal: number = 0
|
||||
private loadingCount: number = 0
|
||||
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||
|
||||
private maybeCompleteLoading() {
|
||||
this.loadingCount++
|
||||
@@ -1233,7 +1229,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
this.loadingCountTotal++
|
||||
this.customFieldService.listAll().subscribe((result) => {
|
||||
this.customFields.set(result.results)
|
||||
this.customFields = result.results
|
||||
this.maybeCompleteLoading()
|
||||
})
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
<div class="row mb-3">
|
||||
<div class="col mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<label class="text-muted me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label>
|
||||
<input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||
<label class="input-group-text border-0" for="managementPageSize" i18n>Show:</label>
|
||||
<span class="input-group-text border-0" i18n>Show:</span>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-auto me-3">
|
||||
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||
<select class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
|
||||
+2
-2
@@ -83,12 +83,12 @@ describe('TagListComponent', () => {
|
||||
]
|
||||
component['_nameFilter'] = null // Simulate empty name filter
|
||||
const filtered = component.filterData(tags as any)
|
||||
expect(filtered).toHaveLength(2)
|
||||
expect(filtered.length).toBe(2)
|
||||
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
|
||||
|
||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
||||
const filteredWithName = component.filterData(tags as any)
|
||||
expect(filteredWithName).toHaveLength(2)
|
||||
expect(filteredWithName.length).toBe(2)
|
||||
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
|
||||
expect(
|
||||
filteredWithName
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<th scope="col" style="width: 40px;">
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-objects"><span class="visually-hidden" i18n>Select all processed email messages</span></label>
|
||||
<label class="form-check-label" for="all-objects"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" i18n>Subject</th>
|
||||
|
||||
+1
-1
@@ -5,6 +5,6 @@
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { normalizeSync } from 'normalize-diacritics'
|
||||
|
||||
export type SearchTextValue =
|
||||
string | number | boolean | bigint | null | undefined
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| bigint
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export function normalizeSearchText(value: SearchTextValue): string {
|
||||
return normalizeSync(String(value ?? '')).toLocaleLowerCase()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user