Compare commits

..

1 Commits

Author SHA1 Message Date
shamoon
aa8991b4ca Change: remove file extension filtering from document consumer 2026-02-01 23:27:02 -08:00
232 changed files with 4304 additions and 27393 deletions

View File

@@ -64,6 +64,8 @@ ARG RUNTIME_PACKAGES="\
libmagic1 \
media-types \
zlib1g \
# Barcode splitter
libzbar0 \
poppler-utils \
htop \
sudo"

View File

@@ -91,12 +91,12 @@ Additional tasks are available for common maintenance operations:
## Committing from the Host Machine
The DevContainer automatically installs Git pre-commit hooks during setup. However, these hooks are configured for use inside the container.
The DevContainer automatically installs pre-commit hooks during setup. However, these hooks are configured for use inside the container.
If you want to commit changes from your host machine (outside the DevContainer), you need to set up prek on your host. This installs it as a standalone tool.
If you want to commit changes from your host machine (outside the DevContainer), you need to set up pre-commit on your host. This installs it as a standalone tool.
```bash
uv tool install prek && prek install
uv tool install pre-commit && pre-commit install
```
After this, you can commit either from inside the DevContainer or from your host machine.

View File

@@ -7,7 +7,7 @@
"containerEnv": {
"UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache"
},
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run prek install'",
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
"customizations": {
"vscode": {
"extensions": [

View File

@@ -116,9 +116,9 @@
},
{
"label": "Maintenance: Build Documentation",
"description": "Build the documentation with Zensical",
"description": "Build the documentation with MkDocs",
"type": "shell",
"command": "uv run zensical build && uv run zensical serve",
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
"group": "none",
"presentation": {
"echo": true,

View File

@@ -28,4 +28,3 @@
./resources
# Other stuff
**/*.drawio.png
.mypy_baseline

View File

@@ -37,6 +37,6 @@ NOTE: PRs that do not address the following will not be merged, please do not sk
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
- [ ] I have run all Git `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
- [ ] I have made corresponding changes to the documentation as needed.
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -46,8 +46,8 @@ updates:
patterns:
- "*pytest*"
- "ruff"
- "zensical"
- "prek*"
- "mkdocs-material"
- "pre-commit*"
# Django & DRF Ecosystem
django-ecosystem:
patterns:
@@ -69,6 +69,7 @@ updates:
patterns:
- "ocrmypdf"
- "pdf2image"
- "pyzbar"
- "zxing-cpp"
- "tika-client"
- "gotenberg-client"

View File

@@ -23,7 +23,7 @@ concurrency:
group: backend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_UV_VERSION: "0.9.x"
NLTK_DATA: "/usr/share/nltk_data"
jobs:
test:
@@ -55,7 +55,7 @@ jobs:
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends \
unpaper tesseract-ocr imagemagick ghostscript poppler-utils
unpaper tesseract-ocr imagemagick ghostscript libzbar0 poppler-utils
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
@@ -99,52 +99,3 @@ jobs:
run: |
docker compose --file docker/compose/docker-compose.ci-test.yml logs
docker compose --file docker/compose/docker-compose.ci-test.yml down
typing:
name: Check project typing
runs-on: ubuntu-24.04
env:
DEFAULT_PYTHON: "3.12"
steps:
- name: Checkout
uses: actions/checkout@v6.0.1
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6.2.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@v7.2.1
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install Python dependencies
run: |
uv sync \
--python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--group typing \
--frozen
- name: List installed Python dependencies
run: |
uv pip list
- name: Check typing (pyrefly)
run: |
uv run pyrefly \
check \
src/
- name: Cache Mypy
uses: actions/cache@v5.0.3
with:
path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes
key: ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-${{ hashFiles('pyproject.toml', 'uv.lock') }}
restore-keys: |
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
${{ runner.os }}-mypy-
- name: Check typing (mypy)
run: |
uv run mypy \
--show-error-codes \
--warn-unused-configs \
src/ | uv run mypy-baseline filter

View File

@@ -106,7 +106,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -180,20 +180,20 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}

View File

@@ -6,34 +6,25 @@ on:
- dev
paths:
- 'docs/**'
- 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- 'mkdocs.yml'
- '.github/workflows/ci-docs.yml'
pull_request:
paths:
- 'docs/**'
- 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- 'mkdocs.yml'
- '.github/workflows/ci-docs.yml'
workflow_dispatch:
concurrency:
group: docs-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pages: write
id-token: write
env:
DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_PYTHON_VERSION: "3.12"
DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11"
jobs:
build:
name: Build Documentation
runs-on: ubuntu-24.04
steps:
- uses: actions/configure-pages@v5
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
@@ -56,23 +47,42 @@ jobs:
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
zensical build --clean
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v4
mkdocs build --config-file ./mkdocs.yml
- name: Upload artifact
uses: actions/upload-artifact@v6
with:
path: site
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
name: documentation
path: site/
retention-days: 7
deploy:
name: Deploy Documentation
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy GitHub Pages
uses: actions/deploy-pages@v4
id: deployment
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Deploy documentation
run: |
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history

View File

@@ -121,7 +121,7 @@ jobs:
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: install-dependencies
runs-on: ubuntu-24.04
container: mcr.microsoft.com/playwright:v1.58.2-noble
container: mcr.microsoft.com/playwright:v1.58.1-noble
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1

View File

@@ -10,15 +10,15 @@ concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Linting via prek
runs-on: ubuntu-slim
pre-commit:
name: Pre-commit Checks
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6.2.0
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Run prek
uses: j178/prek-action@v1.1.1
python-version: "3.11"
- name: Run pre-commit
uses: pre-commit/action@v3.0.1

View File

@@ -8,15 +8,15 @@ concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_PYTHON_VERSION: "3.12"
DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11"
jobs:
wait-for-docker:
name: Wait for Docker Build
runs-on: ubuntu-24.04
steps:
- name: Wait for Docker build
uses: lewagon/wait-on-check-action@v1.5.0
uses: lewagon/wait-on-check-action@v1.4.1
with:
ref: ${{ github.sha }}
check-name: 'Build Docker Image'
@@ -70,7 +70,7 @@ jobs:
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
zensical build --clean
mkdocs build --config-file ./mkdocs.yml
# ---- Prepare Release ----
- name: Generate requirements file
run: |
@@ -211,7 +211,7 @@ jobs:
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
prek run --files changelog.md || true
pre-commit run --files changelog.md || true
git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

2
.gitignore vendored
View File

@@ -54,7 +54,7 @@ junit.xml
# Django stuff:
*.log
# Zensical documentation
# MkDocs documentation
site/
# PyBuilder

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
# This file configures pre-commit hooks.
# See https://pre-commit.com/ for general information
# See https://pre-commit.com/hooks.html for a listing of possible hooks
# We actually run via https://github.com/j178/prek which is compatible
repos:
# General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -50,12 +49,12 @@ repos:
- 'prettier-plugin-organize-imports@4.1.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.0
rev: v0.14.14
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.12.1"
rev: "v2.11.1"
hooks:
- id: pyproject-fmt
# Dockerfile hooks

File diff suppressed because one or more lines are too long

View File

@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.10.0-python3.12-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
@@ -154,6 +154,8 @@ ARG RUNTIME_PACKAGES="\
libmagic1 \
media-types \
zlib1g \
# Barcode splitter
libzbar0 \
poppler-utils"
# Install basic runtime packages.

View File

@@ -4,7 +4,7 @@
# correct networking for the tests
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.26
image: docker.io/gotenberg/gotenberg:8.25
hostname: gotenberg
container_name: gotenberg
network_mode: host
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP
restart: unless-stopped
nginx:
image: docker.io/nginx:1.29.5-alpine
image: docker.io/nginx:1.29-alpine
hostname: nginx
container_name: nginx
ports:

View File

@@ -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.26
image: docker.io/gotenberg/gotenberg:8.25
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.

View File

@@ -66,7 +66,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.26
image: docker.io/gotenberg/gotenberg:8.25
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.

View File

@@ -55,7 +55,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.26
image: docker.io/gotenberg/gotenberg:8.25
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.

View File

@@ -774,6 +774,7 @@ At this time, the library utilized for detection of barcodes supports the follow
- QR Code
- SQ Code
You may check for updates on the [zbar library homepage](https://github.com/mchehab/zbar).
For usage in Paperless, the type of barcode does not matter, only the contents of it.
For how to enable barcode usage, see [the configuration](configuration.md#barcodes).

View File

@@ -1,4 +1,4 @@
# REST API
# The REST API
Paperless-ngx now ships with a fully-documented REST API and a browsable
web interface to explore it. The API browsable interface is available at

View File

@@ -1,31 +1,13 @@
:root>* {
--paperless-green: #17541f;
--paperless-green-accent: #2b8a38;
--md-primary-fg-color: var(--paperless-green);
--md-primary-fg-color--dark: var(--paperless-green);
--md-primary-fg-color--light: var(--paperless-green-accent);
--md-accent-fg-color: var(--paperless-green-accent);
:root > * {
--md-primary-fg-color: #17541f;
--md-primary-fg-color--dark: #17541f;
--md-primary-fg-color--light: #17541f;
--md-accent-fg-color: #2b8a38;
--md-typeset-a-color: #21652a;
}
.md-header,
.md-tabs {
background-color: var(--paperless-green);
color: #fff;
}
.md-tabs__link {
color: rgba(255, 255, 255, 0.82);
}
.md-tabs__link:hover,
.md-tabs__link--active {
color: #fff;
}
[data-md-color-scheme="slate"] {
--md-hue: 222;
--md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1);
}
@media (min-width: 768px) {
@@ -87,8 +69,8 @@ h4 code {
}
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="PAPERLESS_"]),
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="PAPERLESS_"],
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] {
display: none;
}
@@ -101,3 +83,18 @@ h4 code {
border-top: .05rem solid var(--md-default-fg-color--lightest);
}
}
/* Show search shortcut key */
[data-md-toggle="search"]:not(:checked) ~ .md-header .md-search__form::after {
position: absolute;
top: .3rem;
right: .3rem;
display: block;
padding: .1rem .4rem;
color: var(--md-default-fg-color--lighter);
font-weight: bold;
font-size: .8rem;
border: .05rem solid var(--md-default-fg-color--lighter);
border-radius: .1rem;
content: "/";
}

View File

@@ -1222,6 +1222,14 @@ using Python's `re.match()`, which anchors at the start of the filename.
The default ignores are `[.stfolder, .stversions, .localized, @eaDir, .Spotlight-V100, .Trashes, __MACOSX]` and cannot be overridden.
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
: Sets the barcode scanner used for barcode functionality.
Currently, "PYZBAR" (the default) or "ZXING" might be selected.
If you have problems that your Barcodes/QR-Codes are not detected
(especially with bad scan quality and/or small codes), try the other one.
#### [`PAPERLESS_PRE_CONSUME_SCRIPT=<filename>`](#PAPERLESS_PRE_CONSUME_SCRIPT) {#PAPERLESS_PRE_CONSUME_SCRIPT}
: After some initial validation, Paperless can trigger an arbitrary

View File

@@ -81,7 +81,7 @@ first-time setup.
5. Install pre-commit hooks:
```bash
$ uv run prek install
$ uv run pre-commit install
```
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
@@ -217,7 +217,7 @@ commit. See [above](#code-formatting-with-pre-commit-hooks) for installation ins
command such as
```bash
$ git ls-files -- '*.ts' | xargs prek run prettier --files
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files
```
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
@@ -338,13 +338,13 @@ LANGUAGES = [
## Building the documentation
The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
If you want to build the documentation locally, this is how you do it:
1. Build the documentation
```bash
$ uv run zensical build
$ uv run mkdocs build --config-file mkdocs.yml
```
_alternatively..._
@@ -355,7 +355,7 @@ If you want to build the documentation locally, this is how you do it:
something.
```bash
$ uv run zensical serve
$ uv run mkdocs serve
```
## Building the Docker image
@@ -481,147 +481,3 @@ To get started:
5. The project is ready for debugging, start either run the fullstack debug or individual debug
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
## Developing Date Parser Plugins
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
### Creating a Date Parser Plugin
To create a custom date parser plugin, you need to:
1. Create a class that inherits from `DateParserPluginBase`
2. Implement the required abstract method
3. Register your plugin via an entry point
#### 1. Implementing the Parser Class
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
```python
from collections.abc import Iterator
import datetime
from documents.plugins.date_parsing import DateParserPluginBase
class MyDateParserPlugin(DateParserPluginBase):
"""
Custom date parser implementation.
"""
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
"""
Parse dates from the document's filename and content.
Args:
filename: The original filename of the document
content: The extracted text content of the document
Yields:
datetime.datetime: Valid datetime objects found in the document
"""
# Your parsing logic here
# Use self.config to access configuration settings
# Example: parse dates from filename first
if self.config.filename_date_order:
# Your filename parsing logic
yield some_datetime
# Then parse dates from content
# Your content parsing logic
yield another_datetime
```
#### 2. Configuration and Helper Methods
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
- `languages: list[str]` - List of language codes for date parsing
- `timezone_str: str` - Timezone string for date localization
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
- `reference_time: datetime.datetime` - Current time for filtering future dates
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
- `content_date_order: str` - Date order preference for content
The base class provides two helper methods you can use:
```python
def _parse_string(
self,
date_string: str,
date_order: str,
) -> datetime.datetime | None:
"""
Parse a single date string using dateparser with configured settings.
"""
def _filter_date(
self,
date: datetime.datetime | None,
) -> datetime.datetime | None:
"""
Validate a parsed datetime against configured rules.
Filters out dates before 1900, future dates, and ignored dates.
"""
```
#### 3. Resource Management (Optional)
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
#### 4. Registering Your Plugin
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
```toml
[project.entry-points."paperless_ngx.date_parsers"]
my_parser = "my_package.parsers:MyDateParserPlugin"
```
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
### Plugin Discovery
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
1. Queries the `paperless_ngx.date_parsers` entry point group
2. Validates that each plugin is a subclass of `DateParserPluginBase`
3. Sorts valid plugins alphabetically by entry point name
4. Uses the first valid plugin, or falls back to the default `RegexDateParserPlugin` if none are found
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
### Example: Simple Date Parser
Here's a minimal example that only looks for ISO 8601 dates:
```python
import datetime
import re
from collections.abc import Iterator
from documents.plugins.date_parsing.base import DateParserPluginBase
class ISODateParserPlugin(DateParserPluginBase):
"""
Parser that only matches ISO 8601 formatted dates (YYYY-MM-DD).
"""
ISO_REGEX = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
# Combine filename and content for searching
text = f"{filename} {content}"
for match in self.ISO_REGEX.finditer(text):
date_string = match.group(1)
# Use helper method to parse with configured timezone
date = self._parse_string(date_string, "YMD")
# Use helper method to validate the date
filtered_date = self._filter_date(date)
if filtered_date is not None:
yield filtered_date
```

View File

@@ -1,7 +1,3 @@
---
title: FAQs
---
# Frequently Asked Questions
## _What's the general plan for Paperless-ngx?_
@@ -67,10 +63,8 @@ elsewhere. Here are a couple notes about that.
Paperless also supports various Office documents (.docx, .doc, odt,
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
Paperless-ngx determines the type of a file by inspecting its content
rather than its file extensions. However, files processed via the
consumption directory will be rejected if they have a file extension that
not supported by any of the available parsers.
Paperless-ngx determines the type of a file by inspecting its content.
The file extensions do not matter.
## _Will paperless-ngx run on Raspberry Pi?_

View File

@@ -1,7 +1,3 @@
---
title: Home
---
<div class="grid-left" markdown>
![image](assets/logo_full_black.svg#only-light){.index-logo}
![image](assets/logo_full_white.svg#only-dark){.index-logo}

View File

@@ -23,28 +23,3 @@ separating the directory ignore from the file ignore.
Document and thumbnail encryption is no longer supported. This was previously deprecated in [paperless-ng 0.9.3](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/changelog.md#paperless-ng-093)
Users must decrypt their document using the `decrypt_documents` command before upgrading.
## Barcode Scanner Changes
Support for [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar) has been removed. The underlying libzbar library has
seen no updates in 16 years and is largely unmaintained, and the pyzbar Python wrapper last saw a release in March 2022. In
practice, pyzbar struggled with barcode detection reliability, particularly on skewed, low-contrast, or partially
obscured barcodes. [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp) is actively maintained, significantly more
reliable at finding barcodes, and now ships pre-built wheels for both x86_64 and arm64, removing the need to build the library.
The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the only backend.
### Summary
| Old Setting | New Setting | Notes |
| -------------------------- | ----------- | --------------------------------- |
| `CONSUMER_BARCODE_SCANNER` | _Removed_ | zxing-cpp is now the only backend |
### Action Required
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
reliability.
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
images or host installations.

View File

@@ -1,8 +1,4 @@
---
title: Setup
---
# Installation
## Installation
You can go multiple routes to setup and run Paperless:
@@ -207,12 +203,13 @@ are released, dependency support is confirmed, etc.
- `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection
- `mariadb-client` for MariaDB compile time
- `libzbar0` for barcode detection
- `poppler-utils` for barcode detection
Use this list for your preferred package management:
```
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev libzbar0 poppler-utils
```
These dependencies are required for OCRmyPDF, which is used for text

View File

@@ -1,8 +1,4 @@
---
title: Basic Usage
---
# Usage
# Usage Overview
Paperless-ngx is an application that manages your personal documents. With
the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy
@@ -566,8 +562,8 @@ you may want to adjust these settings to prevent abuse.
#### Workflow placeholders
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
This allows for complex logic to be used, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
The template is provided as a string.
@@ -590,7 +586,7 @@ applied. You can use the following placeholders in the template with any trigger
- `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension
- `{{doc_title}}`: current document title (cannot be used in title assignment)
- `{{doc_title}}`: current document title
The following placeholders are only available for "added" or "updated" triggers

87
mkdocs.yml Normal file
View File

@@ -0,0 +1,87 @@
site_name: Paperless-ngx
theme:
name: material
logo: assets/logo.svg
font:
text: Roboto
code: Roboto Mono
palette:
# Palette toggle for automatic mode
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: Switch to light mode
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to system preference
features:
- navigation.tabs
- navigation.top
- toc.integrate
- content.code.annotate
icon:
repo: fontawesome/brands/github
favicon: assets/favicon.png
repo_url: https://github.com/paperless-ngx/paperless-ngx
repo_name: paperless-ngx/paperless-ngx
edit_uri: blob/main/docs/
extra_css:
- assets/extra.css
markdown_extensions:
- attr_list
- md_in_html
- def_list
- admonition
- tables
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.tilde
- footnotes
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
strict: true
nav:
- index.md
- setup.md
- 'Basic Usage': usage.md
- configuration.md
- administration.md
- advanced_usage.md
- 'REST API': api.md
- development.md
- 'FAQs': faq.md
- troubleshooting.md
- 'Migration to v3': migration.md
- changelog.md
copyright: Copyright &copy; 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/paperless-ngx/paperless-ngx
- icon: fontawesome/brands/docker
link: https://hub.docker.com/r/paperlessngx/paperless-ngx
- icon: material/chat
link: https://matrix.to/#/#paperless:matrix.org
plugins:
- search
- glightbox:
skip_classes:
- no-lightbox

View File

@@ -27,9 +27,9 @@ dependencies = [
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.10",
"django-allauth[mfa,socialaccount]~=65.14.0",
"django-allauth[mfa,socialaccount]~=65.13.1",
"django-auditlog~=3.4.1",
"django-cachalot~=2.9.0",
"django-cachalot~=2.8.0",
"django-celery-results~=2.6.0",
"django-compression-middleware~=0.5.0",
"django-cors-headers~=4.9.0",
@@ -42,7 +42,7 @@ dependencies = [
"djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2026.1.1",
"drf-spectacular-sidecar~=2025.10.1",
"drf-writable-nested~=0.7.1",
"faiss-cpu>=1.10",
"filelock~=3.20.0",
@@ -68,6 +68,7 @@ dependencies = [
"python-gnupg~=0.5.4",
"python-ipware~=3.0.0",
"python-magic~=0.4.27",
"pyzbar~=0.1.9",
"rapidfuzz~=3.14.0",
"redis[hiredis]~=5.2.1",
"regex>=2025.9.18",
@@ -75,12 +76,12 @@ dependencies = [
"sentence-transformers>=4.1",
"setproctitle~=1.3.4",
"tika-client~=0.10.0",
"torch~=2.10.0",
"torch~=2.9.1",
"tqdm~=4.67.1",
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"whoosh-reloaded>=2.7.5",
"zxing-cpp~=3.0.0",
"zxing-cpp~=2.3.0",
]
optional-dependencies.mariadb = [
@@ -93,7 +94,7 @@ optional-dependencies.postgres = [
"psycopg-pool==3.3",
]
optional-dependencies.webserver = [
"granian[uvloop]~=2.7.0",
"granian[uvloop]~=2.6.0",
]
[dependency-groups]
@@ -105,7 +106,8 @@ dev = [
]
docs = [
"zensical>=0.0.21",
"mkdocs-glightbox~=0.5.1",
"mkdocs-material~=9.7.0",
]
testing = [
@@ -125,8 +127,9 @@ testing = [
]
lint = [
"prek~=0.3.0",
"ruff~=0.15.0",
"pre-commit~=4.5.1",
"pre-commit-uv~=4.2.0",
"ruff~=0.14.0",
]
typing = [
@@ -135,12 +138,8 @@ typing = [
"django-stubs[compatible-mypy]",
"djangorestframework-stubs[compatible-mypy]",
"lxml-stubs",
"microsoft-python-type-stubs @ git+https://github.com/microsoft/python-type-stubs.git",
"mypy",
"mypy-baseline",
"pyrefly",
"types-bleach",
"types-channels",
"types-colorama",
"types-dateparser",
"types-markdown",
@@ -160,22 +159,26 @@ environments = [
"sys_platform == 'linux'",
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
torch = [
{ index = "pytorch-cpu" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.ruff]
target-version = "py310"
line-length = 88
@@ -303,20 +306,12 @@ markers = [
"gotenberg: Tests requiring Gotenberg service",
"tika: Tests requiring Tika service",
"greenmail: Tests requiring Greenmail service",
"date_parsing: Tests which cover date parsing from content or filename",
]
[tool.pytest_env]
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.coverage.run]
source = [
"src/",
@@ -328,6 +323,13 @@ omit = [
"paperless/auth.py",
]
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.mypy]
mypy_path = "src"
plugins = [
@@ -341,15 +343,5 @@ disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
[tool.pyrefly]
search-path = [ "src" ]
baseline = ".pyrefly-baseline.json"
python-platform = "linux"
[tool.django-stubs]
django_settings_module = "paperless.settings"
[tool.mypy-baseline]
baseline_path = ".mypy-baseline.txt"
sort_baseline = true
ignore_categories = [ "note" ]

View File

@@ -86,6 +86,7 @@
],
"scripts": [],
"allowedCommonJsDependencies": [
"ng2-pdf-viewer",
"file-saver",
"utif"
],

View File

@@ -72,7 +72,7 @@ test('should show a mobile preview', async ({ page }) => {
await page.setViewportSize({ width: 400, height: 1000 })
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
await page.getByRole('tab', { name: 'Preview' }).click()
await page.waitForSelector('pngx-pdf-viewer')
await page.waitForSelector('pdf-viewer')
})
test('should show a list of notes', async ({ page }) => {

View File

@@ -31,10 +31,6 @@ module.exports = {
moduleNameMapper: {
...esmPreset.moduleNameMapper,
'^src/(.*)': '<rootDir>/src/$1',
'^pdfjs-dist/legacy/build/pdf\\.mjs$':
'<rootDir>/src/test/mocks/pdfjs-legacy-build-pdf.ts',
'^pdfjs-dist/web/pdf_viewer\\.mjs$':
'<rootDir>/src/test/mocks/pdfjs-web-pdf_viewer.ts',
},
workerIdleMemoryLimit: '512MB',
reporters: [

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,15 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^21.1.3",
"@angular/common": "~21.1.3",
"@angular/compiler": "~21.1.3",
"@angular/core": "~21.1.3",
"@angular/forms": "~21.1.3",
"@angular/localize": "~21.1.3",
"@angular/platform-browser": "~21.1.3",
"@angular/platform-browser-dynamic": "~21.1.3",
"@angular/router": "~21.1.3",
"@angular/cdk": "^21.1.2",
"@angular/common": "~21.1.2",
"@angular/compiler": "~21.1.2",
"@angular/core": "~21.1.2",
"@angular/forms": "~21.1.2",
"@angular/localize": "~21.1.2",
"@angular/platform-browser": "~21.1.2",
"@angular/platform-browser-dynamic": "~21.1.2",
"@angular/router": "~21.1.2",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.2.0",
"@ngneat/dirty-check-forms": "^3.0.3",
@@ -27,12 +27,12 @@
"bootstrap": "^5.3.8",
"file-saver": "^2.0.5",
"mime-names": "^1.0.0",
"ng2-pdf-viewer": "^10.4.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0",
"ngx-cookie-service": "^21.1.0",
"ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
"pdfjs-dist": "^5.4.624",
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
@@ -42,20 +42,20 @@
"devDependencies": {
"@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.1.3",
"@angular-devkit/schematics": "^21.1.3",
"@angular-devkit/core": "^21.1.2",
"@angular-devkit/schematics": "^21.1.2",
"@angular-eslint/builder": "21.2.0",
"@angular-eslint/eslint-plugin": "21.2.0",
"@angular-eslint/eslint-plugin-template": "21.2.0",
"@angular-eslint/schematics": "21.2.0",
"@angular-eslint/template-parser": "21.2.0",
"@angular/build": "^21.1.3",
"@angular/cli": "~21.1.3",
"@angular/compiler-cli": "~21.1.3",
"@angular/build": "^21.1.2",
"@angular/cli": "~21.1.2",
"@angular/compiler-cli": "~21.1.2",
"@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.58.1",
"@types/jest": "^30.0.0",
"@types/node": "^25.2.1",
"@types/node": "^25.2.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@typescript-eslint/utils": "^8.54.0",
@@ -68,7 +68,7 @@
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1",
"typescript": "^5.9.3",
"webpack": "^5.105.0"
"webpack": "^5.103.0"
},
"packageManager": "pnpm@10.17.1",
"pnpm": {

1332
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -100,10 +100,10 @@ const mock = () => {
}
}
Object.defineProperty(globalThis, 'open', { value: jest.fn() })
Object.defineProperty(globalThis, 'localStorage', { value: mock() })
Object.defineProperty(globalThis, 'sessionStorage', { value: mock() })
Object.defineProperty(globalThis, 'getComputedStyle', {
Object.defineProperty(window, 'open', { value: jest.fn() })
Object.defineProperty(window, 'localStorage', { value: mock() })
Object.defineProperty(window, 'sessionStorage', { value: mock() })
Object.defineProperty(window, 'getComputedStyle', {
value: () => ['-webkit-appearance'],
})
Object.defineProperty(navigator, 'clipboard', {
@@ -115,33 +115,13 @@ Object.defineProperty(navigator, 'canShare', { value: () => true })
if (!navigator.share) {
Object.defineProperty(navigator, 'share', { value: jest.fn() })
}
if (!globalThis.URL.createObjectURL) {
Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() })
if (!URL.createObjectURL) {
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
}
if (!globalThis.URL.revokeObjectURL) {
Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() })
if (!URL.revokeObjectURL) {
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
}
class MockResizeObserver {
private readonly callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe = jest.fn()
unobserve = jest.fn()
disconnect = jest.fn()
trigger = (entries: ResizeObserverEntry[] = []) => {
this.callback(entries, this)
}
}
Object.defineProperty(globalThis, 'ResizeObserver', {
writable: true,
configurable: true,
value: MockResizeObserver,
})
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
if (typeof IntersectionObserver === 'undefined') {
class MockIntersectionObserver {
@@ -156,7 +136,7 @@ if (typeof IntersectionObserver === 'undefined') {
takeRecords = jest.fn()
}
Object.defineProperty(globalThis, 'IntersectionObserver', {
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,

View File

@@ -9,11 +9,7 @@ import {
import { Router, RouterModule } from '@angular/router'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
provideUiTour,
TourNgBootstrap,
TourService,
} from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { Subject } from 'rxjs'
import { routes } from './app-routing.module'
import { AppComponent } from './app.component'
@@ -44,12 +40,12 @@ describe('AppComponent', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
TourNgBootstrapModule,
RouterModule.forRoot(routes),
NgbModalModule,
AppComponent,
ToastsComponent,
FileDropComponent,
TourNgBootstrap,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
@@ -57,7 +53,6 @@ describe('AppComponent', () => {
DirtySavedViewGuard,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
import { Router, RouterOutlet } from '@angular/router'
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { first, Subscription } from 'rxjs'
import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component'
@@ -21,7 +21,12 @@ import { WebsocketStatusService } from './services/websocket-status.service'
selector: 'pngx-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
imports: [
FileDropComponent,
ToastsComponent,
TourNgBootstrapModule,
RouterOutlet,
],
})
export class AppComponent implements OnInit, OnDestroy {
private settings = inject(SettingsService)
@@ -162,91 +167,108 @@ export class AppComponent implements OnInit, OnDestroy {
})
}
this.tourService.initialize([
{
anchorId: 'tour.dashboard',
content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`,
route: '/dashboard',
delayAfterNavigation: 500,
isOptional: false,
},
{
anchorId: 'tour.upload-widget',
content: $localize`Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.`,
route: '/dashboard',
},
{
anchorId: 'tour.documents',
content: $localize`The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.`,
route: '/documents?sort=created&reverse=1&page=1',
delayAfterNavigation: 500,
placement: 'bottom',
},
{
anchorId: 'tour.documents-filter-editor',
content: $localize`The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.`,
route: '/documents?sort=created&reverse=1&page=1',
placement: 'bottom',
},
{
anchorId: 'tour.documents-views',
content: $localize`Any combination of filters can be saved as a 'view' which can then be displayed on the dashboard and / or sidebar.`,
route: '/documents?sort=created&reverse=1&page=1',
},
{
anchorId: 'tour.tags',
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
route: '/tags',
backdropConfig: {
offset: 0,
const prevBtnTitle = $localize`Prev`
const nextBtnTitle = $localize`Next`
const endBtnTitle = $localize`End`
this.tourService.initialize(
[
{
anchorId: 'tour.dashboard',
content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`,
route: '/dashboard',
delayAfterNavigation: 500,
isOptional: false,
},
},
{
anchorId: 'tour.mail',
content: $localize`Manage e-mail accounts and rules for automatically importing documents.`,
route: '/mail',
backdropConfig: {
offset: 0,
{
anchorId: 'tour.upload-widget',
content: $localize`Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.`,
route: '/dashboard',
},
},
{
anchorId: 'tour.workflows',
content: $localize`Workflows give you more control over the document pipeline.`,
route: '/workflows',
backdropConfig: {
offset: 0,
{
anchorId: 'tour.documents',
content: $localize`The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.`,
route: '/documents?sort=created&reverse=1&page=1',
delayAfterNavigation: 500,
placement: 'bottom',
},
},
{
anchorId: 'tour.file-tasks',
content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`,
route: '/tasks',
backdropConfig: {
offset: 0,
{
anchorId: 'tour.documents-filter-editor',
content: $localize`The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.`,
route: '/documents?sort=created&reverse=1&page=1',
placement: 'bottom',
},
},
{
anchorId: 'tour.settings',
content: $localize`Check out the settings for various tweaks to the web app.`,
route: '/settings',
backdropConfig: {
offset: 0,
{
anchorId: 'tour.documents-views',
content: $localize`Any combination of filters can be saved as a 'view' which can then be displayed on the dashboard and / or sidebar.`,
route: '/documents?sort=created&reverse=1&page=1',
},
},
{
anchorId: 'tour.outro',
title: $localize`Thank you! 🙏`,
content:
$localize`There are <em>tons</em> more features and info we didn't cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.` +
'<br/><br/>' +
$localize`Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!`,
route: '/dashboard',
isOptional: false,
backdropConfig: {
offset: 0,
{
anchorId: 'tour.tags',
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
route: '/tags',
backdropConfig: {
offset: 0,
},
},
},
])
{
anchorId: 'tour.mail',
content: $localize`Manage e-mail accounts and rules for automatically importing documents.`,
route: '/mail',
backdropConfig: {
offset: 0,
},
},
{
anchorId: 'tour.workflows',
content: $localize`Workflows give you more control over the document pipeline.`,
route: '/workflows',
backdropConfig: {
offset: 0,
},
},
{
anchorId: 'tour.file-tasks',
content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`,
route: '/tasks',
backdropConfig: {
offset: 0,
},
},
{
anchorId: 'tour.settings',
content: $localize`Check out the settings for various tweaks to the web app.`,
route: '/settings',
backdropConfig: {
offset: 0,
},
},
{
anchorId: 'tour.outro',
title: $localize`Thank you! 🙏`,
content:
$localize`There are <em>tons</em> more features and info we didn't cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.` +
'<br/><br/>' +
$localize`Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!`,
route: '/dashboard',
isOptional: false,
backdropConfig: {
offset: 0,
},
},
],
{
enableBackdrop: true,
backdropConfig: {
offset: 10,
},
prevBtnTitle,
nextBtnTitle,
endBtnTitle,
isOptional: true,
useLegacyTitle: true,
}
)
this.tourService.start$.subscribe(() => {
this.renderer.addClass(document.body, 'tour-active')

View File

@@ -222,8 +222,8 @@
</div>
<div class="col">
<select class="form-select" formControlName="pdfViewerDefaultZoom">
<option [ngValue]="PdfZoomScale.PageWidth" i18n>Fit width</option>
<option [ngValue]="PdfZoomScale.PageFit" i18n>Fit page</option>
<option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option>
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
</select>
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
</div>

View File

@@ -16,7 +16,6 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import {
@@ -148,7 +147,6 @@ describe('SettingsComponent', () => {
PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -65,8 +65,8 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
import { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
import { PdfZoomScale } from '../../common/pdf-viewer/pdf-viewer.types'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { ZoomSetting } from '../../document-detail/zoom-setting'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs {
@@ -196,7 +196,7 @@ export class SettingsComponent
public readonly GlobalSearchType = GlobalSearchType
public readonly PdfZoomScale = PdfZoomScale
public readonly ZoomSetting = ZoomSetting
public readonly PdfEditorEditMode = PdfEditorEditMode

View File

@@ -16,7 +16,6 @@ import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { SavedView } from 'src/app/data/saved-view'
@@ -158,7 +157,6 @@ describe('AppFrameComponent', () => {
PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -16,7 +16,7 @@ import {
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { Observable } from 'rxjs'
import { first } from 'rxjs/operators'
import { Document } from 'src/app/data/document'
@@ -69,7 +69,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
NgbNavModule,
NgxBootstrapIconsModule,
DragDropModule,
TourNgBootstrap,
TourNgBootstrapModule,
],
})
export class AppFrameComponent

View File

@@ -430,24 +430,6 @@
</div>
</div>
}
@case (WorkflowActionType.PasswordRemoval) {
<div class="row">
<div class="col">
<p class="small" i18n>
One password per line. The workflow will try them in order until one succeeds.
</p>
<pngx-input-textarea
i18n-title
title="Passwords"
formControlName="passwords"
rows="4"
[error]="error?.actions?.[i]?.passwords"
hint="Passwords are stored in plain text. Use with caution."
i18n-hint
></pngx-input-textarea>
</div>
</div>
}
}
</div>
</ng-template>

View File

@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormArray,
FormControl,
FormGroup,
FormsModule,
@@ -995,32 +994,4 @@ describe('WorkflowEditDialogComponent', () => {
component.removeSelectedCustomField(3, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([])
})
it('should handle parsing of passwords from array to string and back on save', () => {
const passwordAction: WorkflowAction = {
id: 1,
type: WorkflowActionType.PasswordRemoval,
passwords: ['pass1', 'pass2'],
}
component.object = {
name: 'Workflow with Passwords',
id: 1,
order: 1,
enabled: true,
triggers: [],
actions: [passwordAction],
}
component.ngOnInit()
const formActions = component.objectForm.get('actions') as FormArray
expect(formActions.value[0].passwords).toBe('pass1\npass2')
formActions.at(0).get('passwords').setValue('pass1\npass2\npass3')
component.save()
expect(component.objectForm.get('actions').value[0].passwords).toEqual([
'pass1',
'pass2',
'pass3',
])
})
})

View File

@@ -139,10 +139,6 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Webhook,
name: $localize`Webhook`,
},
{
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
]
export enum TriggerFilterType {
@@ -1206,25 +1202,11 @@ export class WorkflowEditDialogComponent
headers: new FormControl(action.webhook?.headers),
include_document: new FormControl(!!action.webhook?.include_document),
}),
passwords: new FormControl(
this.formatPasswords(action.passwords ?? [])
),
}),
{ emitEvent }
)
}
private formatPasswords(passwords: string[] = []): string {
return passwords.join('\n')
}
private parsePasswords(value: string = ''): string[] {
return value
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
private updateAllTriggerActionFields(emitEvent: boolean = false) {
this.triggerFields.clear({ emitEvent: false })
this.object?.triggers.forEach((trigger) => {
@@ -1349,7 +1331,6 @@ export class WorkflowEditDialogComponent
headers: null,
include_document: false,
},
passwords: [],
}
this.object.actions.push(action)
this.createActionField(action)
@@ -1386,7 +1367,6 @@ export class WorkflowEditDialogComponent
if (action.type !== WorkflowActionType.Email) {
action.email = null
}
action.passwords = this.parsePasswords(action.passwords as any)
})
super.save()
}

View File

@@ -15,7 +15,7 @@
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
}
@if (info) {
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs>
</button>
<ng-template #infoPopover>
@@ -26,9 +26,6 @@
}
</ng-template>
}
@if (loading) {
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
}
</h3>
</div>
<div class="btn-toolbar col col-md-auto gap-2">

View File

@@ -3,14 +3,14 @@ import { Component, Input, inject } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { environment } from 'src/environments/environment'
@Component({
selector: 'pngx-page-header',
templateUrl: './page-header.component.html',
styleUrls: ['./page-header.component.scss'],
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrapModule],
})
export class PageHeaderComponent {
private titleService = inject(Title)
@@ -42,9 +42,6 @@ export class PageHeaderComponent {
@Input()
infoLink: string
@Input()
loading: boolean = false
public copyID() {
this.copied = this.clipboard.copy(this.id.toString())
clearTimeout(this.copyTimeout)

View File

@@ -1,4 +1,4 @@
<pngx-pdf-viewer class="visually-hidden" [src]="pdfSrc" [renderMode]="PdfRenderMode.Single" [page]="1" [selectable]="false" (afterLoadComplete)="pdfLoaded($event)"></pngx-pdf-viewer>
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
<div class="modal-header">
<h4 class="modal-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
@@ -59,7 +59,7 @@
<span class="placeholder w-100 h-100"></span>
</div>
}
<pngx-pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [renderMode]="PdfRenderMode.Single" (rendered)="p.loaded = true"></pngx-pdf-viewer>
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
} @placeholder {
<div class="placeholder-glow w-100 h-100 z-10">
<span class="placeholder w-100 h-100"></span>

View File

@@ -15,13 +15,13 @@
background-color: gray;
height: 240px;
pngx-pdf-viewer {
pdf-viewer {
width: 100%;
height: 100%;
}
}
::ng-deep .pngx-pdf-viewer-container {
::ng-deep .ng2-pdf-viewer-container {
overflow: hidden;
}

View File

@@ -6,16 +6,12 @@ import {
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import {
PdfRenderMode,
PngxPdfDocumentProxy,
} from '../pdf-viewer/pdf-viewer.types'
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
interface PageOperation {
@@ -33,12 +29,11 @@ interface PageOperation {
imports: [
DragDropModule,
FormsModule,
PdfViewerModule,
NgxBootstrapIconsModule,
PngxPdfViewerComponent,
],
})
export class PDFEditorComponent extends ConfirmDialogComponent {
PdfRenderMode = PdfRenderMode
public PdfEditorEditMode = PdfEditorEditMode
private documentService = inject(DocumentService)
@@ -58,7 +53,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
return this.documentService.getPreviewUrl(this.documentID)
}
pdfLoaded(pdf: PngxPdfDocumentProxy) {
pdfLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
page: i + 1,

View File

@@ -1,3 +0,0 @@
<div #container class="pngx-pdf-viewer-container">
<div #viewer class="pdfViewer"></div>
</div>

View File

@@ -1,153 +0,0 @@
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
}
:host ::ng-deep .pngx-pdf-viewer-container {
position: absolute;
inset: 0;
overflow: auto;
}
:host ::ng-deep .pdfViewer {
--scale-factor: 1;
--page-bg-color: unset;
padding-bottom: 0;
}
:host ::ng-deep .pdfViewer .page {
--user-unit: 1;
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
--scale-round-x: 1px;
--scale-round-y: 1px;
direction: ltr;
margin: 0 auto 10px;
border: 0;
position: relative;
overflow: visible;
background-clip: content-box;
background-color: var(--page-bg-color, rgb(255 255 255));
}
:host ::ng-deep .pdfViewer > .page:last-of-type {
margin-bottom: 0;
}
:host ::ng-deep .pdfViewer.singlePageView {
display: inline-block;
}
:host ::ng-deep .pdfViewer.singlePageView .page {
margin: 0;
border: none;
}
:host ::ng-deep .pdfViewer .canvasWrapper {
overflow: hidden;
width: 100%;
height: 100%;
}
:host ::ng-deep .pdfViewer .canvasWrapper canvas {
position: absolute;
top: 0;
left: 0;
margin: 0;
display: block;
width: 100%;
height: 100%;
contain: content;
}
:host ::ng-deep .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 0;
user-select: text;
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
}
:host ::ng-deep .textLayer.highlighting {
touch-action: none;
}
:host ::ng-deep .textLayer :is(span, br) {
position: absolute;
white-space: pre;
color: transparent;
cursor: text;
transform-origin: 0% 0%;
}
:host ::ng-deep .textLayer > :not(.markedContent),
:host ::ng-deep .textLayer .markedContent span:not(.markedContent) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--text-scale-factor) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x))
scale(var(--min-font-size-inv));
}
:host ::ng-deep .textLayer .markedContent {
display: contents;
}
:host ::ng-deep .textLayer span[role='img'] {
user-select: none;
cursor: default;
}
:host ::ng-deep .textLayer .highlight {
--highlight-bg-color: rgb(180 0 170 / 0.25);
--highlight-selected-bg-color: rgb(0 100 0 / 0.25);
--highlight-backdrop-filter: none;
--highlight-selected-backdrop-filter: none;
margin: -1px;
padding: 1px;
background-color: var(--highlight-bg-color);
backdrop-filter: var(--highlight-backdrop-filter);
border-radius: 4px;
}
:host ::ng-deep .appended:is(.textLayer .highlight) {
position: initial;
}
:host ::ng-deep .begin:is(.textLayer .highlight) {
border-radius: 4px 0 0 4px;
}
:host ::ng-deep .end:is(.textLayer .highlight) {
border-radius: 0 4px 4px 0;
}
:host ::ng-deep .middle:is(.textLayer .highlight) {
border-radius: 0;
}
:host ::ng-deep .selected:is(.textLayer .highlight) {
background-color: var(--highlight-selected-bg-color);
}
:host ::ng-deep .textLayer ::selection {
background: rgba(30, 100, 255, 0.35);
}
:host ::ng-deep .annotationLayer {
position: absolute;
inset: 0;
pointer-events: none;
}

View File

@@ -1,299 +0,0 @@
import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
import { PngxPdfViewerComponent } from './pdf-viewer.component'
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
describe('PngxPdfViewerComponent', () => {
let fixture: ComponentFixture<PngxPdfViewerComponent>
let component: PngxPdfViewerComponent
const initComponent = async (src = 'test.pdf') => {
component.src = src
fixture.detectChanges()
await fixture.whenStable()
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PngxPdfViewerComponent],
}).compileComponents()
fixture = TestBed.createComponent(PngxPdfViewerComponent)
component = fixture.componentInstance
})
it('loads a document and emits events', async () => {
const loadSpy = jest.fn()
const renderedSpy = jest.fn()
component.afterLoadComplete.subscribe(loadSpy)
component.rendered.subscribe(renderedSpy)
await initComponent()
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(
'/assets/js/pdf.worker.min.mjs'
)
const isVisible = (component as any).findController.onIsPageVisible as
| (() => boolean)
| undefined
expect(isVisible?.()).toBe(true)
expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({ numPages: 1 })
)
expect(renderedSpy).toHaveBeenCalled()
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
})
it('initializes single-page viewer and disables text layer', async () => {
component.renderMode = PdfRenderMode.Single
component.selectable = false
await initComponent()
const viewer = (component as any).pdfViewer as PDFSinglePageViewer & {
options: Record<string, unknown>
}
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
expect(viewer.options.textLayerMode).toBe(0)
})
it('applies zoom, rotation, and page changes', async () => {
await initComponent()
const pageSpy = jest.fn()
component.pageChange.subscribe(pageSpy)
component.zoomScale = PdfZoomScale.PageFit
component.zoom = PdfZoomLevel.Two
component.rotation = 90
component.page = 2
component.ngOnChanges({
zoomScale: new SimpleChange(
PdfZoomScale.PageWidth,
PdfZoomScale.PageFit,
false
),
zoom: new SimpleChange(PdfZoomLevel.One, PdfZoomLevel.Two, false),
rotation: new SimpleChange(undefined, 90, false),
page: new SimpleChange(undefined, 2, false),
})
const viewer = (component as any).pdfViewer as PDFViewer
expect(viewer.pagesRotation).toBe(90)
expect(viewer.currentPageNumber).toBe(2)
expect(pageSpy).toHaveBeenCalledWith(2)
viewer.currentScale = 1
;(component as any).applyScale()
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
expect(viewer.currentScale).toBe(2)
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
component.page = 2
;(component as any).lastViewerPage = 2
;(component as any).applyViewerState()
expect((component as any).lastViewerPage).toBeUndefined()
expect(applyScaleSpy).toHaveBeenCalled()
})
it('dispatches find when search query changes after render', async () => {
await initComponent()
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
;(component as any).hasRenderedPage = true
component.searchQuery = 'needle'
component.ngOnChanges({
searchQuery: new SimpleChange('', 'needle', false),
})
expect(dispatchSpy).toHaveBeenCalledWith('find', {
query: 'needle',
caseSensitive: false,
highlightAll: true,
phraseSearch: true,
})
component.ngOnChanges({
searchQuery: new SimpleChange('needle', 'needle', false),
})
expect(dispatchSpy).toHaveBeenCalledTimes(1)
})
it('emits error when document load fails', async () => {
const errorSpy = jest.fn()
component.loadError.subscribe(errorSpy)
jest.spyOn(pdfjs, 'getDocument').mockImplementationOnce(() => {
return {
promise: Promise.reject(new Error('boom')),
destroy: jest.fn(),
} as any
})
await initComponent('bad.pdf')
expect(errorSpy).toHaveBeenCalled()
})
it('cleans up resources on destroy', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as { cleanup: jest.Mock }
const loadingTask = (component as any).loadingTask as unknown as {
destroy: jest.Mock
}
const resizeObserver = (component as any).resizeObserver as unknown as {
disconnect: jest.Mock
}
const eventBus = (component as any).eventBus as { off: jest.Mock }
jest.spyOn(viewer, 'cleanup')
jest.spyOn(loadingTask, 'destroy')
jest.spyOn(resizeObserver, 'disconnect')
jest.spyOn(eventBus, 'off')
component.ngOnDestroy()
expect(eventBus.off).toHaveBeenCalledWith(
'pagerendered',
expect.any(Function)
)
expect(eventBus.off).toHaveBeenCalledWith('pagesinit', expect.any(Function))
expect(eventBus.off).toHaveBeenCalledWith(
'pagechanging',
expect.any(Function)
)
expect(resizeObserver.disconnect).toHaveBeenCalled()
expect(loadingTask.destroy).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
expect((component as any).pdfViewer).toBeUndefined()
})
it('skips work when viewer is missing or has no pages', () => {
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
;(component as any).dispatchFindIfReady()
expect(dispatchSpy).not.toHaveBeenCalled()
;(component as any).applyViewerState()
;(component as any).applyScale()
const viewer = new PDFViewer({ eventBus: undefined })
viewer.pagesCount = 0
;(component as any).pdfViewer = viewer
viewer.currentScale = 5
;(component as any).applyScale()
expect(viewer.currentScale).toBe(5)
})
it('returns early on src change in ngOnChanges', () => {
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const initSpy = jest.spyOn(component as any, 'initViewer')
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
component.ngOnChanges({
src: new SimpleChange(undefined, 'test.pdf', true),
zoomScale: new SimpleChange(
PdfZoomScale.PageWidth,
PdfZoomScale.PageFit,
false
),
})
expect(loadSpy).toHaveBeenCalled()
expect(resizeSpy).not.toHaveBeenCalled()
expect(initSpy).not.toHaveBeenCalled()
expect(scaleSpy).not.toHaveBeenCalled()
})
it('applies viewer state after view init when already loaded', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true
;(component as any).pdf = { numPages: 1 }
fixture.detectChanges()
expect(applySpy).toHaveBeenCalled()
})
it('skips viewer state after view init when no pdf is available', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true
fixture.detectChanges()
expect(applySpy).not.toHaveBeenCalled()
})
it('does not reload when already loaded', async () => {
await initComponent()
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
const callCount = getDocumentSpy.mock.calls.length
await (component as any).loadDocument()
expect(getDocumentSpy).toHaveBeenCalledTimes(callCount)
})
it('runs applyScale on resize observer notifications', async () => {
await initComponent()
const applySpy = jest.spyOn(component as any, 'applyScale')
const resizeObserver = (component as any).resizeObserver as {
trigger: () => void
}
resizeObserver.trigger()
expect(applySpy).toHaveBeenCalled()
})
it('skips page work when no pages are available', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
viewer.pagesCount = 0
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
component.page = undefined
;(component as any).lastViewerPage = 1
;(component as any).applyViewerState()
expect(applyScaleSpy).not.toHaveBeenCalled()
expect((component as any).lastViewerPage).toBe(1)
})
it('falls back to a default zoom when input is invalid', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
viewer.currentScale = 3
component.zoom = 'not-a-number' as PdfZoomLevel
;(component as any).applyScale()
expect(viewer.currentScale).toBe(3)
})
it('re-initializes viewer on selectable or render mode changes', async () => {
await initComponent()
const initSpy = jest.spyOn(component as any, 'initViewer')
component.selectable = false
component.renderMode = PdfRenderMode.Single
component.ngOnChanges({
selectable: new SimpleChange(true, false, false),
renderMode: new SimpleChange(
PdfRenderMode.All,
PdfRenderMode.Single,
false
),
})
expect(initSpy).toHaveBeenCalled()
})
})

View File

@@ -1,266 +0,0 @@
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core'
import {
getDocument,
GlobalWorkerOptions,
PDFDocumentLoadingTask,
PDFDocumentProxy,
} from 'pdfjs-dist/legacy/build/pdf.mjs'
import {
EventBus,
PDFFindController,
PDFLinkService,
PDFSinglePageViewer,
PDFViewer,
} from 'pdfjs-dist/web/pdf_viewer.mjs'
import {
PdfRenderMode,
PdfSource,
PdfZoomLevel,
PdfZoomScale,
PngxPdfDocumentProxy,
} from './pdf-viewer.types'
@Component({
selector: 'pngx-pdf-viewer',
templateUrl: './pdf-viewer.component.html',
styleUrl: './pdf-viewer.component.scss',
})
export class PngxPdfViewerComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() src!: PdfSource
@Input() page?: number
@Output() pageChange = new EventEmitter<number>()
@Input() rotation?: number
@Input() renderMode: PdfRenderMode = PdfRenderMode.All
@Input() selectable = true
@Input() searchQuery = ''
@Input() zoom: PdfZoomLevel = PdfZoomLevel.One
@Input() zoomScale: PdfZoomScale = PdfZoomScale.PageWidth
@Output() afterLoadComplete = new EventEmitter<PngxPdfDocumentProxy>()
@Output() rendered = new EventEmitter<void>()
@Output() loadError = new EventEmitter<unknown>()
@ViewChild('container', { static: true })
private readonly container!: ElementRef<HTMLDivElement>
@ViewChild('viewer', { static: true })
private readonly viewer!: ElementRef<HTMLDivElement>
private hasLoaded = false
private loadingTask?: PDFDocumentLoadingTask
private resizeObserver?: ResizeObserver
private pdf?: PDFDocumentProxy
private pdfViewer?: PDFViewer | PDFSinglePageViewer
private hasRenderedPage = false
private lastFindQuery = ''
private lastViewerPage?: number
private readonly eventBus = new EventBus()
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
private readonly findController = new PDFFindController({
eventBus: this.eventBus,
linkService: this.linkService,
updateMatchesCountOnProgress: false,
})
private readonly onPageRendered = () => {
this.hasRenderedPage = true
this.dispatchFindIfReady()
this.rendered.emit()
}
private readonly onPagesInit = () => this.applyScale()
private readonly onPageChanging = (evt: { pageNumber: number }) => {
// Avoid [(page)] two-way binding re-triggers navigation
this.lastViewerPage = evt.pageNumber
this.pageChange.emit(evt.pageNumber)
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['src']) {
this.hasLoaded = false
this.loadDocument()
return
}
if (changes['zoomScale']) {
this.setupResizeObserver()
}
if (changes['selectable'] || changes['renderMode']) {
this.initViewer()
}
if (
changes['page'] ||
changes['zoom'] ||
changes['zoomScale'] ||
changes['rotation']
) {
this.applyViewerState()
}
if (changes['searchQuery']) {
this.dispatchFindIfReady()
}
}
ngAfterViewInit(): void {
this.setupResizeObserver()
this.initViewer()
if (!this.hasLoaded) {
this.loadDocument()
return
}
if (this.pdf) {
this.applyViewerState()
}
}
ngOnDestroy(): void {
this.eventBus.off('pagerendered', this.onPageRendered)
this.eventBus.off('pagesinit', this.onPagesInit)
this.eventBus.off('pagechanging', this.onPageChanging)
this.resizeObserver?.disconnect()
this.loadingTask?.destroy()
this.pdfViewer?.cleanup()
this.pdfViewer = undefined
}
private async loadDocument(): Promise<void> {
if (this.hasLoaded) {
return
}
this.hasLoaded = true
this.hasRenderedPage = false
this.lastFindQuery = ''
this.loadingTask?.destroy()
GlobalWorkerOptions.workerSrc = '/assets/js/pdf.worker.min.mjs'
this.loadingTask = getDocument(this.src)
try {
const pdf = await this.loadingTask.promise
this.pdf = pdf
this.linkService.setDocument(pdf)
this.findController.onIsPageVisible = () => true
this.pdfViewer?.setDocument(pdf)
this.applyViewerState()
this.afterLoadComplete.emit(pdf)
} catch (err) {
this.loadError.emit(err)
}
}
private setupResizeObserver(): void {
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.applyScale()
})
this.resizeObserver.observe(this.container.nativeElement)
}
private initViewer(): void {
this.viewer.nativeElement.innerHTML = ''
this.pdfViewer?.cleanup()
this.hasRenderedPage = false
this.lastFindQuery = ''
const textLayerMode = this.selectable === false ? 0 : 1
const options = {
container: this.container.nativeElement,
viewer: this.viewer.nativeElement,
eventBus: this.eventBus,
linkService: this.linkService,
findController: this.findController,
textLayerMode,
removePageBorders: true,
}
this.pdfViewer =
this.renderMode === PdfRenderMode.Single
? new PDFSinglePageViewer(options)
: new PDFViewer(options)
this.linkService.setViewer(this.pdfViewer)
this.eventBus.off('pagerendered', this.onPageRendered)
this.eventBus.off('pagesinit', this.onPagesInit)
this.eventBus.off('pagechanging', this.onPageChanging)
this.eventBus.on('pagerendered', this.onPageRendered)
this.eventBus.on('pagesinit', this.onPagesInit)
this.eventBus.on('pagechanging', this.onPageChanging)
if (this.pdf) {
this.pdfViewer.setDocument(this.pdf)
this.applyViewerState()
}
}
private applyViewerState(): void {
if (!this.pdfViewer) {
return
}
const hasPages = this.pdfViewer.pagesCount > 0
if (typeof this.rotation === 'number' && hasPages) {
this.pdfViewer.pagesRotation = this.rotation
}
if (
typeof this.page === 'number' &&
hasPages &&
this.page !== this.lastViewerPage
) {
this.pdfViewer.currentPageNumber = this.page
}
if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined
}
if (hasPages) {
this.applyScale()
}
this.dispatchFindIfReady()
}
private applyScale(): void {
if (!this.pdfViewer) {
return
}
if (this.pdfViewer.pagesCount === 0) {
return
}
const zoomFactor = Number(this.zoom) || 1
this.pdfViewer.currentScaleValue = this.zoomScale
if (zoomFactor !== 1) {
this.pdfViewer.currentScale = this.pdfViewer.currentScale * zoomFactor
}
}
private dispatchFindIfReady(): void {
if (!this.hasRenderedPage) {
return
}
const query = this.searchQuery.trim()
if (query === this.lastFindQuery) {
return
}
this.lastFindQuery = query
this.eventBus.dispatch('find', {
query,
caseSensitive: false,
highlightAll: query.length > 0,
phraseSearch: true,
})
}
}

View File

@@ -1,25 +0,0 @@
export type PngxPdfDocumentProxy = {
numPages: number
}
export type PdfSource = string | { url: string; password?: string }
export enum PdfRenderMode {
Single = 'single',
All = 'all',
}
export enum PdfZoomScale {
PageFit = 'page-fit',
PageWidth = 'page-width',
}
export enum PdfZoomLevel {
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}

View File

@@ -23,12 +23,14 @@
</div>
}
@if (!requiresPassword) {
<pngx-pdf-viewer
<pdf-viewer
[src]="previewUrl"
[renderMode]="PdfRenderMode.All"
[searchQuery]="documentService.searchQuery"
(loadError)="onError($event)">
</pngx-pdf-viewer>
[original-size]="false"
[show-borders]="false"
[show-all]="true"
(text-layer-rendered)="onPageRendered()"
(error)="onError($event)" #pdfViewer>
</pdf-viewer>
}
}
}

View File

@@ -12,7 +12,6 @@ import { of, throwError } from 'rxjs'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import { PreviewPopupComponent } from './preview-popup.component'
const doc = {
@@ -79,7 +78,7 @@ describe('PreviewPopupComponent', () => {
component.popover.open()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
})
it('should show lock icon on password error', () => {
@@ -160,15 +159,23 @@ describe('PreviewPopupComponent', () => {
expect(component.popover.isOpen()).toBeFalsy()
})
it('should pass searchQuery to viewer', () => {
it('should dispatch find event on viewer loaded if searchQuery set', () => {
documentService.searchQuery = 'test'
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
component.popover.open()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
const viewer = fixture.debugElement.query(
By.directive(PngxPdfViewerComponent)
)
expect(viewer).not.toBeNull()
expect(viewer.componentInstance.searchQuery).toBe('test')
// normally setup by pdf-viewer
jest.replaceProperty(component.pdfViewer, 'eventBus', {
dispatch: jest.fn(),
} as any)
const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch')
component.onPageRendered()
expect(dispatchSpy).toHaveBeenCalledWith('find', {
query: 'test',
caseSensitive: false,
highlightAll: true,
phraseSearch: true,
})
})
})

View File

@@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http'
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { PdfViewerComponent, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
@@ -9,8 +10,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
@Component({
selector: 'pngx-preview-popup',
@@ -19,15 +18,14 @@ import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
imports: [
NgbPopoverModule,
DocumentTitlePipe,
PngxPdfViewerComponent,
PdfViewerModule,
SafeUrlPipe,
NgxBootstrapIconsModule,
],
})
export class PreviewPopupComponent implements OnDestroy {
PdfRenderMode = PdfRenderMode
private settingsService = inject(SettingsService)
public readonly documentService = inject(DocumentService)
private documentService = inject(DocumentService)
private http = inject(HttpClient)
private _document: Document
@@ -63,6 +61,8 @@ export class PreviewPopupComponent implements OnDestroy {
@ViewChild('popover') popover: NgbPopover
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
mouseOnPreview: boolean = false
popoverClass: string = 'shadow popover-preview'
@@ -114,6 +114,18 @@ export class PreviewPopupComponent implements OnDestroy {
}
}
onPageRendered() {
// Only triggered by the pngx pdf viewer
if (this.documentService.searchQuery) {
this.pdfViewer.eventBus.dispatch('find', {
query: this.documentService.searchQuery,
caseSensitive: false,
highlightAll: true,
phraseSearch: true,
})
}
}
mouseEnterPreview() {
this.mouseOnPreview = true
if (!this.popover.isOpen()) {

View File

@@ -5,12 +5,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
provideUiTour,
TourNgBootstrap,
TourService,
} from 'ngx-ui-tour-ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
@@ -79,7 +75,7 @@ describe('DashboardComponent', () => {
imports: [
NgbAlertModule,
RouterTestingModule,
TourNgBootstrap,
TourNgBootstrapModule,
DragDropModule,
NgxBootstrapIconsModule.pick(allIcons),
DashboardComponent,
@@ -115,7 +111,6 @@ describe('DashboardComponent', () => {
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -8,7 +8,7 @@ import {
import { Component, inject } from '@angular/core'
import { RouterModule } from '@angular/router'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
@@ -36,7 +36,7 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
WelcomeWidgetComponent,
IfPermissionsDirective,
DragDropModule,
TourNgBootstrap,
TourNgBootstrapModule,
NgxBootstrapIconsModule,
RouterModule,
],

View File

@@ -9,7 +9,6 @@ import {
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -62,7 +61,6 @@ describe('UploadFileWidgetComponent', () => {
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -7,7 +7,7 @@ import {
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -33,7 +33,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
TourNgBootstrap,
TourNgBootstrapModule,
],
})
export class UploadFileWidgetComponent extends ComponentWithPermissions {

View File

@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { WelcomeWidgetComponent } from './welcome-widget.component'
@@ -12,7 +11,7 @@ describe('WelcomeWidgetComponent', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [PermissionsGuard, provideUiTour()],
providers: [PermissionsGuard],
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
}).compileComponents()

View File

@@ -456,15 +456,17 @@
@case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer
<pdf-viewer
[src]="{ url: previewUrl, password: password }"
[renderMode]="PdfRenderMode.All"
[original-size]="false"
[show-borders]="true"
[show-all]="true"
[(page)]="previewCurrentPage"
[zoomScale]="previewZoomScale"
[zoom-scale]="previewZoomScale"
[zoom]="previewZoomSetting"
(loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
(error)="onError($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
} @else {
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>

View File

@@ -5,15 +5,20 @@
}
.pdf-viewer-container {
padding: 8px;
padding-top: 10px;
background-color: gray;
pngx-pdf-viewer {
pdf-viewer {
width: 100%;
height: 100%;
}
}
::ng-deep .ng2-pdf-viewer-container .page {
--page-margin: 0 auto 10px;
--page-border: 0;
}
.btn-group .dropdown-toggle-split {
border-top-right-radius: inherit;
border-bottom-right-radius: inherit;

View File

@@ -69,11 +69,8 @@ import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import {
PdfZoomLevel,
PdfZoomScale,
} from '../common/pdf-viewer/pdf-viewer.types'
import { DocumentDetailComponent } from './document-detail.component'
import { ZoomSetting } from './zoom-setting'
const doc: Document = {
id: 3,
@@ -863,7 +860,7 @@ describe('DocumentDetailComponent', () => {
it('should support zoom controls', () => {
initNormally()
component.setZoom(PdfZoomLevel.One) // from select
component.setZoom(ZoomSetting.One) // from select
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
@@ -871,18 +868,18 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomSetting).toEqual('2')
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
component.setZoom(PdfZoomLevel.One) // from select
component.setZoom(ZoomSetting.One) // from select
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('.75')
component.setZoom(PdfZoomScale.PageFit) // from select
component.setZoom(ZoomSetting.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
expect(component.previewZoomScale).toEqual('page-width')
component.setZoom(PdfZoomScale.PageFit) // from select
component.setZoom(ZoomSetting.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.decreaseZoom()
@@ -892,10 +889,10 @@ describe('DocumentDetailComponent', () => {
it('should select correct zoom setting in dropdown', () => {
initNormally()
component.setZoom(PdfZoomScale.PageFit)
expect(component.currentZoom).toEqual(PdfZoomScale.PageFit)
component.setZoom(PdfZoomLevel.Quarter)
expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter)
component.setZoom(ZoomSetting.PageFit)
expect(component.currentZoom).toEqual(ZoomSetting.PageFit)
component.setZoom(ZoomSetting.Quarter)
expect(component.currentZoom).toEqual(ZoomSetting.Quarter)
})
it('should support updating notes dynamically', () => {
@@ -1020,7 +1017,7 @@ describe('DocumentDetailComponent', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
})
it('should display native pdf viewer if enabled', () => {

View File

@@ -18,6 +18,7 @@ import {
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap'
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
@@ -107,19 +108,13 @@ import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import {
PdfRenderMode,
PdfZoomLevel,
PdfZoomScale,
PngxPdfDocumentProxy,
} from '../common/pdf-viewer/pdf-viewer.types'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
import { ZoomSetting } from './zoom-setting'
enum DocumentDetailNavIDs {
Details = 1,
@@ -173,17 +168,16 @@ enum ContentRenderType {
NgbNavModule,
NgbDropdownModule,
NgxBootstrapIconsModule,
PdfViewerModule,
TextAreaComponent,
RouterModule,
PngxPdfViewerComponent,
],
})
export class DocumentDetailComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy, DirtyComponent
{
PdfRenderMode = PdfRenderMode
documentsService = inject(DocumentService)
private documentsService = inject(DocumentService)
private route = inject(ActivatedRoute)
private tagService = inject(TagService)
private correspondentService = inject(CorrespondentService)
@@ -252,8 +246,8 @@ export class DocumentDetailComponent
previewCurrentPage: number = 1
previewNumPages: number
previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One
previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth
previewZoomSetting: ZoomSetting = ZoomSetting.One
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
store: BehaviorSubject<any>
isDirty$: Observable<boolean>
@@ -509,9 +503,7 @@ export class DocumentDetailComponent
}
ngOnInit(): void {
this.setZoom(
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
)
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((values) => {
@@ -1212,7 +1204,7 @@ export class DocumentDetailComponent
})
}
pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) {
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.previewNumPages = pdf.numPages
if (this.password) this.requiresPassword = false
setTimeout(() => {
@@ -1233,33 +1225,31 @@ export class DocumentDetailComponent
}
}
setZoom(setting: PdfZoomScale | PdfZoomLevel) {
if (
setting === PdfZoomScale.PageFit ||
setting === PdfZoomScale.PageWidth
) {
setZoom(setting: ZoomSetting) {
if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
this.previewZoomScale = setting
this.previewZoomSetting = PdfZoomLevel.One
return
this.previewZoomSetting = ZoomSetting.One
} else {
this.previewZoomSetting = setting
this.previewZoomScale = ZoomSetting.PageWidth
}
this.previewZoomSetting = setting
this.previewZoomScale = PdfZoomScale.PageWidth
}
get zoomSettings() {
return [PdfZoomScale.PageFit, ...Object.values(PdfZoomLevel)]
return Object.values(ZoomSetting).filter(
(setting) => setting !== ZoomSetting.PageWidth
)
}
get currentZoom() {
if (this.previewZoomScale === PdfZoomScale.PageFit) {
return PdfZoomScale.PageFit
}
return this.previewZoomSetting
if (this.previewZoomScale === ZoomSetting.PageFit) {
return ZoomSetting.PageFit
} else return this.previewZoomSetting
}
getZoomSettingTitle(setting: PdfZoomScale | PdfZoomLevel): string {
getZoomSettingTitle(setting: ZoomSetting): string {
switch (setting) {
case PdfZoomScale.PageFit:
case ZoomSetting.PageFit:
return $localize`Page Fit`
default:
return `${parseFloat(setting) * 100}%`
@@ -1267,24 +1257,25 @@ export class DocumentDetailComponent
}
increaseZoom(): void {
const zoomLevels = Object.values(PdfZoomLevel)
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
if (this.previewZoomScale === PdfZoomScale.PageFit) {
currentIndex = zoomLevels.indexOf(PdfZoomLevel.One)
}
this.previewZoomScale = PdfZoomScale.PageWidth
let currentIndex = Object.values(ZoomSetting).indexOf(
this.previewZoomSetting
)
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting =
zoomLevels[Math.min(zoomLevels.length - 1, currentIndex + 1)]
Object.values(ZoomSetting)[
Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1)
]
}
decreaseZoom(): void {
const zoomLevels = Object.values(PdfZoomLevel)
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
if (this.previewZoomScale === PdfZoomScale.PageFit) {
currentIndex = zoomLevels.indexOf(PdfZoomLevel.ThreeQuarters)
}
this.previewZoomScale = PdfZoomScale.PageWidth
this.previewZoomSetting = zoomLevels[Math.max(0, currentIndex - 1)]
let currentIndex = Object.values(ZoomSetting).indexOf(
this.previewZoomSetting
)
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting =
Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)]
}
get showPermissions(): boolean {

View File

@@ -0,0 +1,11 @@
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}

View File

@@ -16,7 +16,6 @@ import {
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { Subject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import {
@@ -106,7 +105,6 @@ describe('DocumentListComponent', () => {
PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -21,7 +21,7 @@ import {
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
import {
DEFAULT_DISPLAY_FIELDS,
@@ -99,7 +99,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
NgbPaginationModule,
NgClass,
RouterModule,
TourNgBootstrap,
TourNgBootstrapModule,
],
})
export class DocumentListComponent

View File

@@ -21,7 +21,6 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -252,7 +251,6 @@ describe('FilterEditorComponent', () => {
SettingsService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -16,7 +16,7 @@ import {
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { Observable, Subject, from } from 'rxjs'
import {
catchError,
@@ -251,7 +251,7 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
NgbTypeaheadModule,
FormsModule,
ReactiveFormsModule,
TourNgBootstrap,
TourNgBootstrapModule,
],
})
export class FilterEditorComponent

View File

@@ -129,7 +129,7 @@
<div class="row fade" [class.show]="showRules">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
<div class="col-2 d-flex align-items-center">{{ mailAccountsById.get(rule.account)?.name }}</div>
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
<div class="form-check form-switch mb-0">
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">

View File

@@ -1,3 +1,4 @@
import { AsyncPipe } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
@@ -36,6 +37,7 @@ import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-
PageHeaderComponent,
IfPermissionsDirective,
IfOwnerDirective,
AsyncPipe,
FormsModule,
ReactiveFormsModule,
NgbDropdownModule,
@@ -46,8 +48,8 @@ export class MailComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
private readonly mailAccountService = inject(MailAccountService)
private readonly mailRuleService = inject(MailRuleService)
mailAccountService = inject(MailAccountService)
mailRuleService = inject(MailRuleService)
private toastService = inject(ToastService)
private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService)
@@ -56,19 +58,8 @@ export class MailComponent
public MailAccountType = MailAccountType
private _mailAccounts: MailAccount[] = []
public get mailAccounts() {
return this._mailAccounts
}
private set mailAccounts(accounts: MailAccount[]) {
this._mailAccounts = accounts
this.mailAccountsById = new Map(
accounts.map((account) => [account.id, account])
)
}
public mailAccountsById: Map<number, MailAccount> = new Map()
public mailRules: MailRule[] = []
mailAccounts: MailAccount[] = []
mailRules: MailRule[] = []
unsubscribeNotifier: Subject<any> = new Subject()
oAuthAccountId: number

View File

@@ -1,4 +1,4 @@
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
@@ -46,30 +46,14 @@
</pngx-page-header>
<div class="row mb-3">
<div class="col mb-2 mb-xl-0">
<div class="col-md mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center">
<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">
<span class="input-group-text border-0" i18n>Show:</span>
</div>
<div class="input-group input-group-sm w-auto me-3">
<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>
</select>
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
</div>
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
</div>
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
<div class="card border table-responsive mb-3">
@@ -92,7 +76,7 @@
</tr>
</thead>
<tbody>
@if (loading && data.length === 0) {
@if (loading) {
<tr>
<td colspan="5">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@@ -107,7 +91,7 @@
</table>
</div>
@if (!loading || data.length > 0) {
@if (!loading) {
<div class="d-flex mb-2">
@if (collectionSize > 0) {
<div>
@@ -118,7 +102,7 @@
</div>
}
@if (collectionSize > 20) {
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
}
</div>
}

View File

@@ -24,7 +24,3 @@ td.name-cell {
margin-left: .5rem;
}
}
select.small {
font-size: 0.875rem !important; // 14px
}

View File

@@ -31,7 +31,6 @@ import {
MATCH_NONE,
} from 'src/app/data/matching-model'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
@@ -42,7 +41,6 @@ import {
} from 'src/app/services/permissions.service'
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
@@ -81,7 +79,6 @@ describe('ManagementListComponent', () => {
let toastService: ToastService
let documentListViewService: DocumentListViewService
let permissionsService: PermissionsService
let settingsService: SettingsService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -133,7 +130,6 @@ describe('ManagementListComponent', () => {
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(TagListComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -451,66 +447,4 @@ describe('ManagementListComponent', () => {
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
expect(ids).toEqual([1, 5])
})
it('pageSize getter should return stored page size or default to 25', () => {
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 50 })
component.typeNamePlural = 'tags'
expect(component.pageSize).toBe(50)
})
it('pageSize getter should return 25 when no size is stored', () => {
const settingsService = TestBed.inject(SettingsService)
jest.spyOn(settingsService, 'get').mockReturnValue({})
component.typeNamePlural = 'tags'
expect(component.pageSize).toBe(25)
})
it('pageSize setter should update settings, reset page and reload data on success', fakeAsync(() => {
const reloadSpy = jest.spyOn(component, 'reloadData')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(of({ success: true }))
component.typeNamePlural = 'tags'
component.page = 2
component.pageSize = 100
tick()
expect(settingsService.set).toHaveBeenCalledWith(
SETTINGS_KEYS.OBJECT_LIST_SIZES,
{ tags: 100 }
)
expect(component.page).toBe(1)
expect(reloadSpy).toHaveBeenCalled()
expect(toastErrorSpy).not.toHaveBeenCalled()
}))
it('pageSize setter should show error toast on settings store failure', fakeAsync(() => {
const reloadSpy = jest.spyOn(component, 'reloadData')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('error storing settings')))
component.typeNamePlural = 'tags'
component.pageSize = 50
tick()
expect(toastErrorSpy).toHaveBeenCalledWith(
'Error saving settings',
expect.any(Error)
)
expect(reloadSpy).not.toHaveBeenCalled()
}))
})

View File

@@ -23,7 +23,6 @@ import {
MatchingModel,
} from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import {
SortableDirective,
SortEvent,
@@ -38,7 +37,6 @@ import {
AbstractNameFilterService,
BulkEditObjectOperation,
} from 'src/app/services/rest/abstract-name-filter-service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
@@ -82,8 +80,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public permissionType: PermissionType
public extraColumns: ManagementListColumn[]
private readonly settingsService = inject(SettingsService)
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
public data: T[] = []
@@ -164,7 +160,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.service
.listFiltered(
this.page,
this.pageSize,
null,
this.sortField,
this.sortReverse,
this._nameFilter,
@@ -284,30 +280,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
if (event.code == 'Escape') this.nameFilterDebounce.next(null)
}
public get pageSize(): number {
return (
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)[
this.typeNamePlural
] || 25
)
}
public set pageSize(newPageSize: number) {
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
[this.typeNamePlural]: newPageSize,
})
this.settingsService.storeSettings().subscribe({
next: () => {
this.page = 1
this.reloadData()
},
error: (error) => {
this.toastService.showError($localize`Error saving settings`, error)
},
})
}
userCanDelete(object: ObjectWithPermissions): boolean {
return this.permissionsService.currentUserOwnsObject(object)
}

View File

@@ -101,7 +101,7 @@ describe('TagListComponent', () => {
it('should request only parent tags when no name filter is applied', () => {
expect(tagService.listFiltered).toHaveBeenCalledWith(
1,
25,
null,
undefined,
undefined,
undefined,
@@ -116,7 +116,7 @@ describe('TagListComponent', () => {
component.reloadData()
expect(tagService.listFiltered).toHaveBeenCalledWith(
1,
25,
null,
undefined,
undefined,
'Tag',

View File

@@ -1,5 +1,5 @@
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
import { ZoomSetting } from '../components/document-detail/zoom-setting'
import { User } from './user'
export interface UiSettings {
@@ -63,7 +63,6 @@ export const SETTINGS_KEYS = {
SIDEBAR_VIEWS_SHOW_COUNT:
'general-settings:saved-views:sidebar-views-show-count',
TOUR_COMPLETE: 'general-settings:tour-complete',
OBJECT_LIST_SIZES: 'general-settings:object-list-sizes',
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
@@ -202,16 +201,6 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.OBJECT_LIST_SIZES,
type: 'object',
default: {
correspondents: 25,
document_types: 25,
tags: 25,
storage_paths: 25,
},
},
{
key: SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
type: 'number',
@@ -310,7 +299,7 @@ export const SETTINGS: UiSetting[] = [
{
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
type: 'string',
default: PdfZoomScale.PageWidth,
default: ZoomSetting.PageWidth,
},
{
key: SETTINGS_KEYS.AI_ENABLED,

View File

@@ -5,7 +5,6 @@ export enum WorkflowActionType {
Removal = 2,
Email = 3,
Webhook = 4,
PasswordRemoval = 5,
}
export interface WorkflowActionEmail extends ObjectWithId {
@@ -98,6 +97,4 @@ export interface WorkflowAction extends ObjectWithId {
email?: WorkflowActionEmail
webhook?: WorkflowActionWebhook
passwords?: string[]
}

View File

@@ -4,7 +4,6 @@ import { TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { routes } from '../app-routing.module'
import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
import { DocumentListComponent } from '../components/document-list/document-list.component'
@@ -31,7 +30,6 @@ describe('DirtySavedViewGuard', () => {
DocumentListComponent,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
})

View File

@@ -1,10 +1,10 @@
import { TestBed } from '@angular/core/testing'
import { ActivatedRoute, RouterState } from '@angular/router'
import { provideUiTour, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import {
PermissionAction,
PermissionsService,
PermissionType,
PermissionsService,
} from '../services/permissions.service'
import { ToastService } from '../services/toast.service'
import { PermissionsGuard } from './permissions.guard'
@@ -45,7 +45,6 @@ describe('PermissionsGuard', () => {
},
TourService,
ToastService,
provideUiTour(),
],
})

View File

@@ -1,7 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { map, shareReplay, tap } from 'rxjs/operators'
import { map, publishReplay, refCount, tap } from 'rxjs/operators'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { Results } from 'src/app/data/results'
import { environment } from 'src/environments/environment'
@@ -90,7 +90,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
sortField,
sortReverse,
extraParams
).pipe(shareReplay({ bufferSize: 1, refCount: true }))
).pipe(publishReplay(1), refCount())
}
return this._listAll
}

View File

@@ -70,26 +70,4 @@ describe('LocalizedDateParserFormatter', () => {
dateStr = dateParserFormatter.format(dateStruct)
expect(dateStr).toEqual('04.05.2023')
})
it('should handle years when current year % 100 < 50', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date(2026, 5, 15))
let val = dateParserFormatter.parse('5/4/26')
expect(val).toEqual({ day: 4, month: 5, year: 2026 })
val = dateParserFormatter.parse('5/4/75')
expect(val).toEqual({ day: 4, month: 5, year: 2075 })
val = dateParserFormatter.parse('5/4/99')
expect(val).toEqual({ day: 4, month: 5, year: 1999 })
jest.useRealTimers()
})
it('should handle years when current year % 100 >= 50', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date(2076, 5, 15))
const val = dateParserFormatter.parse('5/4/00')
expect(val).toEqual({ day: 4, month: 5, year: 2100 })
jest.useRealTimers()
})
})

View File

@@ -106,25 +106,15 @@ export class LocalizedDateParserFormatter extends NgbDateParserFormatter {
value = this.preformatDateInput(value)
let match = this.getDateParseRegex().exec(value)
if (match) {
const currentYear = new Date().getFullYear()
const currentCentury = currentYear - (currentYear % 100)
let year = +match.groups.year
if (year < 100) {
let fourDigitYear = currentCentury + year
// Mimic python-dateutil: keep result within -50/+49 years of current year
if (fourDigitYear > currentYear + 49) {
fourDigitYear -= 100
} else if (fourDigitYear <= currentYear - 50) {
fourDigitYear += 100
}
year = fourDigitYear
}
let dateStruct = {
day: +match.groups.day,
month: +match.groups.month,
year,
year: +match.groups.year,
}
if (dateStruct.year <= new Date().getFullYear() - 2000) {
dateStruct.year += 2000
} else if (dateStruct.year < 100) {
dateStruct.year += 1900
}
return dateStruct
} else {

View File

@@ -21,6 +21,7 @@ import {
NgbModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import {
NgxBootstrapIconsModule,
airplane,
@@ -144,6 +145,7 @@ import {
} from 'ngx-bootstrap-icons'
import { ColorSliderModule } from 'ngx-color/slider'
import { CookieService } from 'ngx-cookie-service'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { AppRoutingModule } from './app/app-routing.module'
import { AppComponent } from './app/app.component'
import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
@@ -193,7 +195,6 @@ import localeUk from '@angular/common/locales/uk'
import localeVi from '@angular/common/locales/vi'
import localeZh from '@angular/common/locales/zh'
import localeZhHant from '@angular/common/locales/zh-Hant'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { CorrespondentNamePipe } from './app/pipes/correspondent-name.pipe'
import { DocumentTypeNamePipe } from './app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from './app/pipes/storage-path-name.pipe'
@@ -370,8 +371,10 @@ bootstrapApplication(AppComponent, {
NgbModule,
FormsModule,
ReactiveFormsModule,
PdfViewerModule,
NgSelectModule,
ColorSliderModule,
TourNgBootstrapModule,
DragDropModule,
NgxBootstrapIconsModule.pick(icons)
),
@@ -394,16 +397,5 @@ bootstrapApplication(AppComponent, {
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
withFetch()
),
provideUiTour({
enableBackdrop: true,
backdropConfig: {
offset: 10,
},
prevBtnTitle: $localize`Prev`,
nextBtnTitle: $localize`Next`,
endBtnTitle: $localize`End`,
isOptional: true,
useLegacyTitle: true,
}),
],
}).catch((err) => console.error(err))

View File

@@ -1,24 +0,0 @@
export class PDFDocumentProxy {
numPages = 1
}
export class PDFDocumentLoadingTask {
promise: Promise<PDFDocumentProxy>
destroyed = false
constructor(promise: Promise<PDFDocumentProxy>) {
this.promise = promise
}
destroy(): void {
this.destroyed = true
}
}
export const GlobalWorkerOptions = {
workerSrc: '',
}
export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
}

View File

@@ -1,79 +0,0 @@
type EventHandler = (event?: unknown) => void
export class EventBus {
private readonly listeners = new Map<string, Set<EventHandler>>()
on(eventName: string, listener: EventHandler): void {
let listeners = this.listeners.get(eventName)
if (!listeners) {
listeners = new Set()
this.listeners.set(eventName, listeners)
}
listeners.add(listener)
}
off(eventName: string, listener: EventHandler): void {
this.listeners.get(eventName)?.delete(listener)
}
dispatch(eventName: string, event?: unknown): void {
this.listeners.get(eventName)?.forEach((listener) => listener(event))
}
}
export class PDFFindController {
onIsPageVisible?: () => boolean
}
export class PDFLinkService {
private document?: unknown
private viewer?: unknown
setDocument(document: unknown): void {
this.document = document
}
setViewer(viewer: unknown): void {
this.viewer = viewer
}
}
class BaseViewer {
pagesCount = 0
currentScale = 1
currentScaleValue: string | number = 1
pagesRotation = 0
readonly options: Record<string, unknown>
private readonly eventBus?: EventBus
private _currentPageNumber = 1
constructor(options: { eventBus?: EventBus }) {
this.options = options
this.eventBus = options.eventBus
}
setDocument(document: { numPages?: number } | null | undefined): void {
this.pagesCount = document?.numPages ?? 1
this.eventBus?.dispatch('pagesinit', {})
this.eventBus?.dispatch('pagerendered', {
pageNumber: this._currentPageNumber,
})
}
cleanup(): void {
this.pagesCount = 0
}
get currentPageNumber(): number {
return this._currentPageNumber
}
set currentPageNumber(value: number) {
this._currentPageNumber = value
this.eventBus?.dispatch('pagechanging', { pageNumber: value })
}
}
export class PDFViewer extends BaseViewer {}
export class PDFSinglePageViewer extends BaseViewer {}

View File

@@ -17,7 +17,6 @@
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts",
"src/test/**/*.ts"
"src/**/*.d.ts"
]
}

View File

@@ -7,7 +7,7 @@ class DocumentsConfig(AppConfig):
verbose_name = _("Documents")
def ready(self) -> None:
def ready(self):
from documents.signals import document_consumption_finished
from documents.signals import document_updated
from documents.signals.handlers import add_inbox_tags

View File

@@ -16,7 +16,6 @@ from pikepdf import Pdf
from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.models import Document
from documents.models import Tag
from documents.plugins.base import ConsumeTaskPlugin
from documents.plugins.base import StopConsumeTaskError
@@ -28,6 +27,8 @@ from documents.utils import maybe_override_pixel_limit
from paperless.config import BarcodeConfig
if TYPE_CHECKING:
from collections.abc import Callable
from PIL import Image
logger = logging.getLogger("paperless.barcodes")
@@ -128,24 +129,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
self._tiff_conversion_done = False
self.barcodes: list[Barcode] = []
def _apply_detected_asn(self, detected_asn: int) -> None:
"""
Apply a detected ASN to metadata if allowed.
"""
if (
self.metadata.skip_asn_if_exists
and Document.global_objects.filter(
archive_serial_number=detected_asn,
).exists()
):
logger.info(
f"Found ASN in barcode {detected_asn} but skipping because it already exists.",
)
return
logger.info(f"Found ASN in barcode: {detected_asn}")
self.metadata.asn = detected_asn
def run(self) -> None:
# Some operations may use PIL, override pixel setting if needed
maybe_override_pixel_limit()
@@ -223,8 +206,13 @@ class BarcodePlugin(ConsumeTaskPlugin):
# Update/overwrite an ASN if possible
# After splitting, as otherwise each split document gets the same ASN
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
self._apply_detected_asn(located_asn)
if (
self.settings.barcode_enable_asn
and not self.metadata.skip_asn
and (located_asn := self.asn) is not None
):
logger.info(f"Found ASN in barcode: {located_asn}")
self.metadata.asn = located_asn
def cleanup(self) -> None:
self.temp_dir.cleanup()
@@ -260,6 +248,26 @@ class BarcodePlugin(ConsumeTaskPlugin):
return barcodes
@staticmethod
def read_barcodes_pyzbar(image: Image.Image) -> list[str]:
barcodes = []
from pyzbar import pyzbar
# Decode the barcode image
detected_barcodes = pyzbar.decode(image)
# Traverse through all the detected barcodes in image
for barcode in detected_barcodes:
if barcode.data:
decoded_barcode = barcode.data.decode("utf-8")
barcodes.append(decoded_barcode)
logger.debug(
f"Barcode of type {barcode.type} found: {decoded_barcode}",
)
return barcodes
def detect(self) -> None:
"""
Scan all pages of the PDF as images, updating barcodes and the pages
@@ -272,6 +280,14 @@ class BarcodePlugin(ConsumeTaskPlugin):
# No op if not a TIFF
self.convert_from_tiff_to_pdf()
# Choose the library for reading
if settings.CONSUMER_BARCODE_SCANNER == "PYZBAR":
reader: Callable[[Image.Image], list[str]] = self.read_barcodes_pyzbar
logger.debug("Scanning for barcodes using PYZBAR")
else:
reader = self.read_barcodes_zxing
logger.debug("Scanning for barcodes using ZXING")
try:
# Read number of pages from pdf
with Pdf.open(self.pdf_file) as pdf:
@@ -319,7 +335,7 @@ class BarcodePlugin(ConsumeTaskPlugin):
)
# Detect barcodes
for barcode_value in self.read_barcodes_zxing(page):
for barcode_value in reader(page):
self.barcodes.append(
Barcode(current_page_number, barcode_value, self.settings),
)

View File

@@ -7,6 +7,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from typing import Literal
from celery import chain
from celery import chord
from celery import group
from celery import shared_task
@@ -37,42 +38,6 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
@shared_task(bind=True)
def restore_archive_serial_numbers_task(
self,
backup: dict[int, int | None],
*args,
**kwargs,
) -> None:
restore_archive_serial_numbers(backup)
def release_archive_serial_numbers(doc_ids: list[int]) -> dict[int, int | None]:
"""
Clears ASNs on documents that are about to be replaced so new documents
can be assigned ASNs without uniqueness collisions. Returns a backup map
of doc_id -> previous ASN for potential restoration.
"""
qs = Document.objects.filter(
id__in=doc_ids,
archive_serial_number__isnull=False,
).only("pk", "archive_serial_number")
backup = dict(qs.values_list("pk", "archive_serial_number"))
qs.update(archive_serial_number=None)
logger.info(f"Released archive serial numbers for documents {list(backup.keys())}")
return backup
def restore_archive_serial_numbers(backup: dict[int, int | None]) -> None:
"""
Restores ASNs using the provided backup map, intended for
rollback when replacement consumption fails.
"""
for doc_id, asn in backup.items():
Document.objects.filter(pk=doc_id).update(archive_serial_number=asn)
logger.info(f"Restored archive serial numbers for documents {list(backup.keys())}")
def set_correspondent(
doc_ids: list[int],
correspondent: Correspondent,
@@ -340,10 +305,10 @@ def reprocess(doc_ids: list[int]) -> Literal["OK"]:
def set_permissions(
doc_ids: list[int],
set_permissions: dict,
set_permissions,
*,
owner: User | None = None,
merge: bool = False,
owner=None,
merge=False,
) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
@@ -421,7 +386,6 @@ def merge(
merged_pdf = pikepdf.new()
version: str = merged_pdf.pdf_version
handoff_asn: int | None = None
# use doc_ids to preserve order
for doc_id in doc_ids:
doc = qs.get(id=doc_id)
@@ -437,8 +401,6 @@ def merge(
version = max(version, pdf.pdf_version)
merged_pdf.pages.extend(pdf.pages)
affected_docs.append(doc.id)
if handoff_asn is None and doc.archive_serial_number is not None:
handoff_asn = doc.archive_serial_number
except Exception as e:
logger.exception(
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
@@ -464,8 +426,6 @@ def merge(
DocumentMetadataOverrides.from_document(metadata_document)
)
overrides.title = metadata_document.title + " (merged)"
if metadata_document.archive_serial_number is not None:
handoff_asn = metadata_document.archive_serial_number
else:
overrides = DocumentMetadataOverrides()
else:
@@ -473,11 +433,8 @@ def merge(
if user is not None:
overrides.owner_id = user.id
if not delete_originals:
overrides.skip_asn_if_exists = True
if delete_originals and handoff_asn is not None:
overrides.asn = handoff_asn
# Avoid copying or detecting ASN from merged PDFs to prevent collision
overrides.skip_asn = True
logger.info("Adding merged document to the task queue.")
@@ -490,20 +447,12 @@ def merge(
)
if delete_originals:
backup = release_archive_serial_numbers(affected_docs)
logger.info(
"Queueing removal of original documents after consumption of merged document",
)
try:
consume_task.apply_async(
link=[delete.si(affected_docs)],
link_error=[restore_archive_serial_numbers_task.s(backup)],
)
except Exception:
restore_archive_serial_numbers(backup)
raise
else:
consume_task.delay()
chain(consume_task, delete.si(affected_docs)).delay()
else:
consume_task.delay()
return "OK"
@@ -545,8 +494,6 @@ def split(
overrides.title = f"{doc.title} (split {idx + 1})"
if user is not None:
overrides.owner_id = user.id
if not delete_originals:
overrides.skip_asn_if_exists = True
logger.info(
f"Adding split document with pages {split_doc} to the task queue.",
)
@@ -561,20 +508,10 @@ def split(
)
if delete_originals:
backup = release_archive_serial_numbers([doc.id])
logger.info(
"Queueing removal of original document after consumption of the split documents",
)
try:
chord(
header=consume_tasks,
body=delete.si([doc.id]),
).apply_async(
link_error=[restore_archive_serial_numbers_task.s(backup)],
)
except Exception:
restore_archive_serial_numbers(backup)
raise
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
else:
group(consume_tasks).delay()
@@ -614,7 +551,7 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
def edit_pdf(
doc_ids: list[int],
operations: list[dict[str, int]],
operations: list[dict],
*,
delete_original: bool = False,
update_document: bool = False,
@@ -677,10 +614,7 @@ def edit_pdf(
)
if user is not None:
overrides.owner_id = user.id
if not delete_original:
overrides.skip_asn_if_exists = True
if delete_original and len(pdf_docs) == 1:
overrides.asn = doc.archive_serial_number
for idx, pdf in enumerate(pdf_docs, start=1):
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
@@ -699,17 +633,7 @@ def edit_pdf(
)
if delete_original:
backup = release_archive_serial_numbers([doc.id])
try:
chord(
header=consume_tasks,
body=delete.si([doc.id]),
).apply_async(
link_error=[restore_archive_serial_numbers_task.s(backup)],
)
except Exception:
restore_archive_serial_numbers(backup)
raise
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
else:
group(consume_tasks).delay()
@@ -797,7 +721,7 @@ def reflect_doclinks(
document: Document,
field: CustomField,
target_doc_ids: list[int],
) -> None:
):
"""
Add or remove 'symmetrical' links to `document` on all `target_doc_ids`
"""
@@ -860,7 +784,7 @@ def remove_doclink(
document: Document,
field: CustomField,
target_doc_id: int,
) -> None:
):
"""
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
"""

View File

@@ -122,7 +122,7 @@ class DocumentClassifier:
)
self._stop_words = None
def _update_data_vectorizer_hash(self) -> None:
def _update_data_vectorizer_hash(self):
self.data_vectorizer_hash = sha256(
pickle.dumps(self.data_vectorizer),
).hexdigest()

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