mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-07 23:42:46 -06:00
Compare commits
30 Commits
copilot/su
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5a0a6b7b9 | ||
|
|
30b1d3c6d7 | ||
|
|
d3ff856202 | ||
|
|
3bc4631a0f | ||
|
|
ab328e0212 | ||
|
|
5c3d02e6d4 | ||
|
|
1d89ec402b | ||
|
|
6192915be7 | ||
|
|
b9b90ec9f7 | ||
|
|
0dc58cf686 | ||
|
|
505ff31748 | ||
|
|
3c51b3f9cd | ||
|
|
dfbac35f9c | ||
|
|
8f917555b1 | ||
|
|
734b5b9a45 | ||
|
|
0f1cae03ec | ||
|
|
71663fdbe2 | ||
|
|
1188a89369 | ||
|
|
b8e3b6590e | ||
|
|
4a5116adf8 | ||
|
|
bbf2e63f10 | ||
|
|
33cbe2ad54 | ||
|
|
261e10ebeb | ||
|
|
585c28b460 | ||
|
|
e77ab3357c | ||
|
|
05ab091ea4 | ||
|
|
fb7abf7a6e | ||
|
|
6ad2fc0356 | ||
|
|
2ec8ec96c8 | ||
|
|
276dc13e3f |
@@ -91,12 +91,12 @@ Additional tasks are available for common maintenance operations:
|
||||
|
||||
## Committing from the Host Machine
|
||||
|
||||
The DevContainer automatically installs pre-commit hooks during setup. However, these hooks are configured for use inside the container.
|
||||
The DevContainer automatically installs Git 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 pre-commit 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 prek on your host. This installs it as a standalone tool.
|
||||
|
||||
```bash
|
||||
uv tool install pre-commit && pre-commit install
|
||||
uv tool install prek && prek install
|
||||
```
|
||||
|
||||
After this, you can commit either from inside the DevContainer or from your host machine.
|
||||
|
||||
@@ -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 pre-commit install'",
|
||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run prek install'",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
||||
@@ -116,9 +116,9 @@
|
||||
},
|
||||
{
|
||||
"label": "Maintenance: Build Documentation",
|
||||
"description": "Build the documentation with MkDocs",
|
||||
"description": "Build the documentation with Zensical",
|
||||
"type": "shell",
|
||||
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
|
||||
"command": "uv run zensical build && uv run zensical serve",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
|
||||
@@ -28,3 +28,4 @@
|
||||
./resources
|
||||
# Other stuff
|
||||
**/*.drawio.png
|
||||
.mypy_baseline
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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 `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
||||
- [ ] I have run all Git `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.
|
||||
|
||||
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -46,8 +46,8 @@ updates:
|
||||
patterns:
|
||||
- "*pytest*"
|
||||
- "ruff"
|
||||
- "mkdocs-material"
|
||||
- "pre-commit*"
|
||||
- "zensical"
|
||||
- "prek*"
|
||||
# Django & DRF Ecosystem
|
||||
django-ecosystem:
|
||||
patterns:
|
||||
|
||||
49
.github/workflows/ci-backend.yml
vendored
49
.github/workflows/ci-backend.yml
vendored
@@ -99,3 +99,52 @@ 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
|
||||
|
||||
56
.github/workflows/ci-docs.yml
vendored
56
.github/workflows/ci-docs.yml
vendored
@@ -6,17 +6,25 @@ on:
|
||||
- dev
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'zensical.toml'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/ci-docs.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'zensical.toml'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.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.9.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.11"
|
||||
@@ -25,6 +33,7 @@ jobs:
|
||||
name: Build Documentation
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/configure-pages@v5
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
@@ -47,42 +56,23 @@ jobs:
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
mkdocs build --config-file ./mkdocs.yml
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
zensical build --clean
|
||||
- name: Upload GitHub Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
name: documentation
|
||||
path: site/
|
||||
retention-days: 7
|
||||
path: site
|
||||
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
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: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Deploy GitHub Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
with:
|
||||
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
|
||||
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
16
.github/workflows/ci-lint.yml
vendored
16
.github/workflows/ci-lint.yml
vendored
@@ -10,15 +10,15 @@ concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
pre-commit:
|
||||
name: Pre-commit Checks
|
||||
runs-on: ubuntu-24.04
|
||||
lint:
|
||||
name: Linting via prek
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
python-version: "3.14"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@v1.1.0
|
||||
|
||||
4
.github/workflows/ci-release.yml
vendored
4
.github/workflows/ci-release.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
mkdocs build --config-file ./mkdocs.yml
|
||||
zensical build --clean
|
||||
# ---- Prepare Release ----
|
||||
- name: Generate requirements file
|
||||
run: |
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
pre-commit run --files changelog.md || true
|
||||
prek 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
2
.gitignore
vendored
@@ -54,7 +54,7 @@ junit.xml
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# MkDocs documentation
|
||||
# Zensical documentation
|
||||
site/
|
||||
|
||||
# PyBuilder
|
||||
|
||||
2470
.mypy-baseline.txt
Normal file
2470
.mypy-baseline.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
# 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
|
||||
@@ -49,12 +50,12 @@ repos:
|
||||
- 'prettier-plugin-organize-imports@4.1.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.14
|
||||
rev: v0.15.0
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.11.1"
|
||||
rev: "v2.12.1"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
# Dockerfile hooks
|
||||
|
||||
17368
.pyrefly-baseline.json
Normal file
17368
.pyrefly-baseline.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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.9.26-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.9.29-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
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-alpine
|
||||
image: docker.io/nginx:1.29.5-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
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.
|
||||
|
||||
@@ -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.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
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.
|
||||
|
||||
@@ -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.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The REST API
|
||||
# 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
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
:root > * {
|
||||
--md-primary-fg-color: #17541f;
|
||||
--md-primary-fg-color--dark: #17541f;
|
||||
--md-primary-fg-color--light: #17541f;
|
||||
--md-accent-fg-color: #2b8a38;
|
||||
: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);
|
||||
--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) {
|
||||
@@ -69,8 +87,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 .md-nav__link[href*="PAPERLESS_"],
|
||||
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] {
|
||||
.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_"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -83,18 +101,3 @@ 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: "/";
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ first-time setup.
|
||||
5. Install pre-commit hooks:
|
||||
|
||||
```bash
|
||||
$ uv run pre-commit install
|
||||
$ uv run prek 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 pre-commit run prettier --files
|
||||
$ git ls-files -- '*.ts' | xargs prek 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 material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
|
||||
The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
|
||||
If you want to build the documentation locally, this is how you do it:
|
||||
|
||||
1. Build the documentation
|
||||
|
||||
```bash
|
||||
$ uv run mkdocs build --config-file mkdocs.yml
|
||||
$ uv run zensical build
|
||||
```
|
||||
|
||||
_alternatively..._
|
||||
@@ -355,7 +355,7 @@ If you want to build the documentation locally, this is how you do it:
|
||||
something.
|
||||
|
||||
```bash
|
||||
$ uv run mkdocs serve
|
||||
$ uv run zensical serve
|
||||
```
|
||||
|
||||
## Building the Docker image
|
||||
|
||||
10
docs/faq.md
10
docs/faq.md
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: FAQs
|
||||
---
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
## _What's the general plan for Paperless-ngx?_
|
||||
@@ -63,8 +67,10 @@ 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.
|
||||
The file extensions do not matter.
|
||||
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.
|
||||
|
||||
## _Will paperless-ngx run on Raspberry Pi?_
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Home
|
||||
---
|
||||
|
||||
<div class="grid-left" markdown>
|
||||
{.index-logo}
|
||||
{.index-logo}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
## Installation
|
||||
---
|
||||
title: Setup
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
You can go multiple routes to setup and run Paperless:
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Usage Overview
|
||||
---
|
||||
title: Basic Usage
|
||||
---
|
||||
|
||||
# Usage
|
||||
|
||||
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
|
||||
|
||||
87
mkdocs.yml
87
mkdocs.yml
@@ -1,87 +0,0 @@
|
||||
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 © 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
|
||||
@@ -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.13.1",
|
||||
"django-allauth[mfa,socialaccount]~=65.14.0",
|
||||
"django-auditlog~=3.4.1",
|
||||
"django-cachalot~=2.8.0",
|
||||
"django-cachalot~=2.9.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~=2025.10.1",
|
||||
"drf-spectacular-sidecar~=2026.1.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"faiss-cpu>=1.10",
|
||||
"filelock~=3.20.0",
|
||||
@@ -76,7 +76,7 @@ dependencies = [
|
||||
"sentence-transformers>=4.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"tika-client~=0.10.0",
|
||||
"torch~=2.9.1",
|
||||
"torch~=2.10.0",
|
||||
"tqdm~=4.67.1",
|
||||
"watchfiles>=1.1.1",
|
||||
"whitenoise~=6.11",
|
||||
@@ -94,7 +94,7 @@ optional-dependencies.postgres = [
|
||||
"psycopg-pool==3.3",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian[uvloop]~=2.6.0",
|
||||
"granian[uvloop]~=2.7.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -106,8 +106,7 @@ dev = [
|
||||
]
|
||||
|
||||
docs = [
|
||||
"mkdocs-glightbox~=0.5.1",
|
||||
"mkdocs-material~=9.7.0",
|
||||
"zensical>=0.0.21",
|
||||
]
|
||||
|
||||
testing = [
|
||||
@@ -127,9 +126,8 @@ testing = [
|
||||
]
|
||||
|
||||
lint = [
|
||||
"pre-commit~=4.5.1",
|
||||
"pre-commit-uv~=4.2.0",
|
||||
"ruff~=0.14.0",
|
||||
"prek~=0.3.0",
|
||||
"ruff~=0.15.0",
|
||||
]
|
||||
|
||||
typing = [
|
||||
@@ -138,8 +136,12 @@ 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",
|
||||
@@ -159,6 +161,11 @@ 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 = [
|
||||
@@ -174,11 +181,6 @@ 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
|
||||
@@ -313,6 +315,13 @@ markers = [
|
||||
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/",
|
||||
@@ -324,19 +333,8 @@ 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"
|
||||
files = [
|
||||
"src/documents/plugins/date_parsing",
|
||||
"src/documents/tests/date_parsing",
|
||||
]
|
||||
plugins = [
|
||||
"mypy_django_plugin.main",
|
||||
"mypy_drf_plugin.main",
|
||||
@@ -348,28 +346,15 @@ disallow_untyped_defs = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
|
||||
# This prevents errors from imports, but allows type-checking logic to work
|
||||
follow_imports = "silent"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"documents.*",
|
||||
"paperless.*",
|
||||
"paperless_ai.*",
|
||||
"paperless_mail.*",
|
||||
"paperless_tesseract.*",
|
||||
"paperless_remote.*",
|
||||
"paperless_text.*",
|
||||
"paperless_tika.*",
|
||||
]
|
||||
ignore_errors = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"documents.plugins.date_parsing.*",
|
||||
"documents.tests.date_parsing.*",
|
||||
]
|
||||
ignore_errors = false
|
||||
[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" ]
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<trans-unit id="ngb.alert.close" datatype="html">
|
||||
<source>Close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/alert/alert.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/alert/alert.ts</context>
|
||||
<context context-type="linenumber">50</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
||||
<source> Slide <x id="INTERPOLATION" equiv-text="ueryList<NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">131,135</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||
@@ -20,114 +20,114 @@
|
||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||
<source>Previous</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">159,162</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.carousel.next" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">202,203</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
||||
<source>Select month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">91</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">91</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
||||
<source>Select year</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">91</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">91</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
||||
<source>Previous month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">83,85</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">112</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
||||
<source>Next month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">112</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">112</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.first" datatype="html">
|
||||
<source>««</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.previous" datatype="html">
|
||||
<source>«</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.next" datatype="html">
|
||||
<source>»</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.last" datatype="html">
|
||||
<source>»»</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
||||
<source>First</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
||||
<source>Previous</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
||||
<source>Last</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/pagination/pagination-config.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
@@ -135,105 +135,105 @@
|
||||
<source><x id="INTERPOLATION" equiv-text="barConfig);
|
||||
pu"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/progressbar/progressbar.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/progressbar/progressbar.ts</context>
|
||||
<context context-type="linenumber">41,42</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
||||
<source>HH</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
||||
<source>Hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
||||
<source>MM</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
||||
<source>Minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
||||
<source>Increment hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
||||
<source>Decrement hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
||||
<source>Increment minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
||||
<source>Decrement minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
||||
<source>SS</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
||||
<source>Seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
||||
<source>Increment seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
||||
<source>Decrement seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
||||
<source><x id="INTERPOLATION"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/timepicker/timepicker-config.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
||||
<source>Close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.2_@angular+core@21.1.2_@angular+_0ae6084f45398d2eea75800b78d6d6df/node_modules/src/toast/toast-config.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@20.0.0_@angular+common@21.1.3_@angular+core@21.1.3_@angular+_1ede04b1f6b65fa8e34a28e44afe1de9/node_modules/src/toast/toast-config.ts</context>
|
||||
<context context-type="linenumber">54</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
@@ -241,18 +241,18 @@
|
||||
<source>Document <x id="PH" equiv-text="status.filename"/> was added to Paperless-ngx.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">95</context>
|
||||
<context context-type="linenumber">90</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">104</context>
|
||||
<context context-type="linenumber">99</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1931214133925051574" datatype="html">
|
||||
<source>Open document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">97</context>
|
||||
<context context-type="linenumber">92</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
|
||||
@@ -279,21 +279,21 @@
|
||||
<source>Could not add <x id="PH" equiv-text="status.filename"/>: <x id="PH_1" equiv-text="status.message"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">119</context>
|
||||
<context context-type="linenumber">114</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1218124467712564468" datatype="html">
|
||||
<source>Document <x id="PH" equiv-text="status.filename"/> is being processed by Paperless-ngx.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">134</context>
|
||||
<context context-type="linenumber">129</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6570363013146073520" datatype="html">
|
||||
<source>Dashboard</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">141</context>
|
||||
<context context-type="linenumber">136</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
@@ -312,7 +312,7 @@
|
||||
<source>Documents</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">152</context>
|
||||
<context context-type="linenumber">147</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
@@ -367,7 +367,7 @@
|
||||
<source>Settings</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">164</context>
|
||||
<context context-type="linenumber">159</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
@@ -386,78 +386,53 @@
|
||||
<context context-type="linenumber">260</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2501522447884928778" datatype="html">
|
||||
<source>Prev</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">170</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3885497195825665706" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">171</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">109</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1241348629231510663" datatype="html">
|
||||
<source>End</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">172</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5890330709052835856" datatype="html">
|
||||
<source>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.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">178</context>
|
||||
<context context-type="linenumber">168</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9075755296812854717" datatype="html">
|
||||
<source>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.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">185</context>
|
||||
<context context-type="linenumber">175</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7495498057594070122" datatype="html">
|
||||
<source>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.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">190</context>
|
||||
<context context-type="linenumber">180</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1334220418719920556" datatype="html">
|
||||
<source>The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">197</context>
|
||||
<context context-type="linenumber">187</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5427326625898532358" datatype="html">
|
||||
<source>Any combination of filters can be saved as a 'view' which can then be displayed on the dashboard and / or sidebar.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">203</context>
|
||||
<context context-type="linenumber">193</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2804886236408698479" datatype="html">
|
||||
<source>Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">208</context>
|
||||
<context context-type="linenumber">198</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7851939076947092983" datatype="html">
|
||||
<source>Manage e-mail accounts and rules for automatically importing documents.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">216</context>
|
||||
<context context-type="linenumber">206</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||
@@ -468,14 +443,14 @@
|
||||
<source>Workflows give you more control over the document pipeline.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">224</context>
|
||||
<context context-type="linenumber">214</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4680387114119209483" datatype="html">
|
||||
<source>File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">232</context>
|
||||
<context context-type="linenumber">222</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
@@ -486,28 +461,28 @@
|
||||
<source>Check out the settings for various tweaks to the web app.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">240</context>
|
||||
<context context-type="linenumber">230</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7172877665285340082" datatype="html">
|
||||
<source>Thank you! 🙏</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">248</context>
|
||||
<context context-type="linenumber">238</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7354947513482088740" datatype="html">
|
||||
<source>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.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">250</context>
|
||||
<context context-type="linenumber">240</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4270528545616947218" datatype="html">
|
||||
<source>Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||
<context context-type="linenumber">252</context>
|
||||
<context context-type="linenumber">242</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9063918187161876141" datatype="html">
|
||||
@@ -7460,6 +7435,17 @@
|
||||
<context context-type="linenumber">106</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3885497195825665706" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">109</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/main.ts</context>
|
||||
<context context-type="linenumber">405</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5028777105388019087" datatype="html">
|
||||
<source>Details</source>
|
||||
<context-group purpose="location">
|
||||
@@ -11244,6 +11230,20 @@
|
||||
<context context-type="linenumber">39</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2501522447884928778" datatype="html">
|
||||
<source>Prev</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/main.ts</context>
|
||||
<context context-type="linenumber">404</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1241348629231510663" datatype="html">
|
||||
<source>End</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/main.ts</context>
|
||||
<context context-type="linenumber">406</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||
"@ng-select/ng-select": "^21.2.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@@ -32,7 +32,7 @@
|
||||
"ngx-color": "^10.1.0",
|
||||
"ngx-cookie-service": "^21.1.0",
|
||||
"ngx-device-detector": "^11.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
||||
"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.2",
|
||||
"@angular-devkit/schematics": "^21.1.2",
|
||||
"@angular-devkit/core": "^21.1.3",
|
||||
"@angular-devkit/schematics": "^21.1.3",
|
||||
"@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.2",
|
||||
"@angular/cli": "~21.1.2",
|
||||
"@angular/compiler-cli": "~21.1.2",
|
||||
"@angular/build": "^21.1.3",
|
||||
"@angular/cli": "~21.1.3",
|
||||
"@angular/compiler-cli": "~21.1.3",
|
||||
"@codecov/webpack-plugin": "^1.9.1",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/node": "^25.2.1",
|
||||
"@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.103.0"
|
||||
"webpack": "^5.105.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"pnpm": {
|
||||
|
||||
1149
src-ui/pnpm-lock.yaml
generated
1149
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@ import {
|
||||
import { Router, RouterModule } from '@angular/router'
|
||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import {
|
||||
provideUiTour,
|
||||
TourNgBootstrap,
|
||||
TourService,
|
||||
} from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { routes } from './app-routing.module'
|
||||
import { AppComponent } from './app.component'
|
||||
@@ -40,12 +44,12 @@ describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TourNgBootstrapModule,
|
||||
RouterModule.forRoot(routes),
|
||||
NgbModalModule,
|
||||
AppComponent,
|
||||
ToastsComponent,
|
||||
FileDropComponent,
|
||||
TourNgBootstrap,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
@@ -53,6 +57,7 @@ describe('AppComponent', () => {
|
||||
DirtySavedViewGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
||||
import { Router, RouterOutlet } from '@angular/router'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap, 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,12 +21,7 @@ import { WebsocketStatusService } from './services/websocket-status.service'
|
||||
selector: 'pngx-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
imports: [
|
||||
FileDropComponent,
|
||||
ToastsComponent,
|
||||
TourNgBootstrapModule,
|
||||
RouterOutlet,
|
||||
],
|
||||
imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
|
||||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private settings = inject(SettingsService)
|
||||
@@ -167,108 +162,91 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
}
|
||||
|
||||
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.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,
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
this.tourService.initialize([
|
||||
{
|
||||
enableBackdrop: true,
|
||||
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: 10,
|
||||
offset: 0,
|
||||
},
|
||||
prevBtnTitle,
|
||||
nextBtnTitle,
|
||||
endBtnTitle,
|
||||
isOptional: true,
|
||||
useLegacyTitle: true,
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
this.tourService.start$.subscribe(() => {
|
||||
this.renderer.addClass(document.body, 'tour-active')
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 {
|
||||
@@ -147,6 +148,7 @@ describe('SettingsComponent', () => {
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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'
|
||||
@@ -157,6 +158,7 @@ describe('AppFrameComponent', () => {
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } 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,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class AppFrameComponent
|
||||
|
||||
@@ -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 { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } 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, TourNgBootstrapModule],
|
||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
|
||||
})
|
||||
export class PageHeaderComponent {
|
||||
private titleService = inject(Title)
|
||||
|
||||
@@ -5,8 +5,12 @@ 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 { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
provideUiTour,
|
||||
TourNgBootstrap,
|
||||
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'
|
||||
@@ -75,7 +79,7 @@ describe('DashboardComponent', () => {
|
||||
imports: [
|
||||
NgbAlertModule,
|
||||
RouterTestingModule,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
DragDropModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
DashboardComponent,
|
||||
@@ -111,6 +115,7 @@ describe('DashboardComponent', () => {
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap, 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,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
@@ -61,6 +62,7 @@ describe('UploadFileWidgetComponent', () => {
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NgbProgressbarModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } 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,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -11,7 +12,7 @@ describe('WelcomeWidgetComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [PermissionsGuard],
|
||||
providers: [PermissionsGuard, provideUiTour()],
|
||||
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 {
|
||||
@@ -105,6 +106,7 @@ describe('DocumentListComponent', () => {
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } 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,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class DocumentListComponent
|
||||
|
||||
@@ -21,6 +21,7 @@ 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'
|
||||
@@ -251,6 +252,7 @@ describe('FilterEditorComponent', () => {
|
||||
SettingsService,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } 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,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class FilterEditorComponent
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -30,6 +31,7 @@ describe('DirtySavedViewGuard', () => {
|
||||
DocumentListComponent,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { ActivatedRoute, RouterState } from '@angular/router'
|
||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { provideUiTour, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from '../services/permissions.service'
|
||||
import { ToastService } from '../services/toast.service'
|
||||
import { PermissionsGuard } from './permissions.guard'
|
||||
@@ -45,6 +45,7 @@ describe('PermissionsGuard', () => {
|
||||
},
|
||||
TourService,
|
||||
ToastService,
|
||||
provideUiTour(),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ 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'
|
||||
@@ -195,6 +194,7 @@ 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'
|
||||
@@ -374,7 +374,6 @@ bootstrapApplication(AppComponent, {
|
||||
PdfViewerModule,
|
||||
NgSelectModule,
|
||||
ColorSliderModule,
|
||||
TourNgBootstrapModule,
|
||||
DragDropModule,
|
||||
NgxBootstrapIconsModule.pick(icons)
|
||||
),
|
||||
@@ -397,5 +396,16 @@ 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))
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -129,6 +130,24 @@ 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()
|
||||
@@ -206,13 +225,8 @@ 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 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
|
||||
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
|
||||
self._apply_detected_asn(located_asn)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -38,6 +37,42 @@ 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,
|
||||
@@ -305,10 +340,10 @@ def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
||||
|
||||
def set_permissions(
|
||||
doc_ids: list[int],
|
||||
set_permissions,
|
||||
set_permissions: dict,
|
||||
*,
|
||||
owner=None,
|
||||
merge=False,
|
||||
owner: User | None = None,
|
||||
merge: bool = False,
|
||||
) -> Literal["OK"]:
|
||||
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
||||
|
||||
@@ -386,6 +421,7 @@ 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)
|
||||
@@ -401,6 +437,8 @@ 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}",
|
||||
@@ -426,6 +464,8 @@ 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:
|
||||
@@ -433,8 +473,11 @@ def merge(
|
||||
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
# Avoid copying or detecting ASN from merged PDFs to prevent collision
|
||||
overrides.skip_asn = True
|
||||
if not delete_originals:
|
||||
overrides.skip_asn_if_exists = True
|
||||
|
||||
if delete_originals and handoff_asn is not None:
|
||||
overrides.asn = handoff_asn
|
||||
|
||||
logger.info("Adding merged document to the task queue.")
|
||||
|
||||
@@ -447,12 +490,20 @@ def merge(
|
||||
)
|
||||
|
||||
if delete_originals:
|
||||
backup = release_archive_serial_numbers(affected_docs)
|
||||
logger.info(
|
||||
"Queueing removal of original documents after consumption of merged document",
|
||||
)
|
||||
chain(consume_task, delete.si(affected_docs)).delay()
|
||||
else:
|
||||
consume_task.delay()
|
||||
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()
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -494,6 +545,8 @@ 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.",
|
||||
)
|
||||
@@ -508,10 +561,20 @@ 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",
|
||||
)
|
||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
||||
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
|
||||
else:
|
||||
group(consume_tasks).delay()
|
||||
|
||||
@@ -551,7 +614,7 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
|
||||
|
||||
def edit_pdf(
|
||||
doc_ids: list[int],
|
||||
operations: list[dict],
|
||||
operations: list[dict[str, int]],
|
||||
*,
|
||||
delete_original: bool = False,
|
||||
update_document: bool = False,
|
||||
@@ -614,7 +677,10 @@ 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))
|
||||
@@ -633,7 +699,17 @@ def edit_pdf(
|
||||
)
|
||||
|
||||
if delete_original:
|
||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
||||
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
|
||||
else:
|
||||
group(consume_tasks).delay()
|
||||
|
||||
|
||||
@@ -697,7 +697,7 @@ class ConsumerPlugin(
|
||||
pk=self.metadata.storage_path_id,
|
||||
)
|
||||
|
||||
if self.metadata.asn is not None and not self.metadata.skip_asn:
|
||||
if self.metadata.asn is not None:
|
||||
document.archive_serial_number = self.metadata.asn
|
||||
|
||||
if self.metadata.owner_id:
|
||||
@@ -865,8 +865,8 @@ class AsnCheckPlugin(
|
||||
"""
|
||||
Check that if override_asn is given, it is unique and within a valid range
|
||||
"""
|
||||
if self.metadata.skip_asn or self.metadata.asn is None:
|
||||
# if skip is set or ASN is None
|
||||
if self.metadata.asn is None:
|
||||
# if ASN is None
|
||||
return
|
||||
# Validate the range is above zero and less than uint32_t max
|
||||
# otherwise, Whoosh can't handle it in the index
|
||||
|
||||
@@ -30,7 +30,7 @@ class DocumentMetadataOverrides:
|
||||
change_users: list[int] | None = None
|
||||
change_groups: list[int] | None = None
|
||||
custom_fields: dict | None = None
|
||||
skip_asn: bool = False
|
||||
skip_asn_if_exists: bool = False
|
||||
|
||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||
"""
|
||||
@@ -50,8 +50,8 @@ class DocumentMetadataOverrides:
|
||||
self.storage_path_id = other.storage_path_id
|
||||
if other.owner_id is not None:
|
||||
self.owner_id = other.owner_id
|
||||
if other.skip_asn:
|
||||
self.skip_asn = True
|
||||
if other.skip_asn_if_exists:
|
||||
self.skip_asn_if_exists = True
|
||||
|
||||
# merge
|
||||
if self.tag_ids is None:
|
||||
|
||||
@@ -65,8 +65,12 @@ def match_correspondents(document: Document, classifier: DocumentClassifier, use
|
||||
|
||||
return list(
|
||||
filter(
|
||||
lambda o: matches(o, document)
|
||||
or (o.pk == pred_id and o.matching_algorithm == MatchingModel.MATCH_AUTO),
|
||||
lambda o: (
|
||||
matches(o, document)
|
||||
or (
|
||||
o.pk == pred_id and o.matching_algorithm == MatchingModel.MATCH_AUTO
|
||||
)
|
||||
),
|
||||
correspondents,
|
||||
),
|
||||
)
|
||||
@@ -92,8 +96,12 @@ def match_document_types(document: Document, classifier: DocumentClassifier, use
|
||||
|
||||
return list(
|
||||
filter(
|
||||
lambda o: matches(o, document)
|
||||
or (o.pk == pred_id and o.matching_algorithm == MatchingModel.MATCH_AUTO),
|
||||
lambda o: (
|
||||
matches(o, document)
|
||||
or (
|
||||
o.pk == pred_id and o.matching_algorithm == MatchingModel.MATCH_AUTO
|
||||
)
|
||||
),
|
||||
document_types,
|
||||
),
|
||||
)
|
||||
@@ -114,10 +122,12 @@ def match_tags(document: Document, classifier: DocumentClassifier, user=None):
|
||||
|
||||
return list(
|
||||
filter(
|
||||
lambda o: matches(o, document)
|
||||
or (
|
||||
o.matching_algorithm == MatchingModel.MATCH_AUTO
|
||||
and o.pk in predicted_tag_ids
|
||||
lambda o: (
|
||||
matches(o, document)
|
||||
or (
|
||||
o.matching_algorithm == MatchingModel.MATCH_AUTO
|
||||
and o.pk in predicted_tag_ids
|
||||
)
|
||||
),
|
||||
tags,
|
||||
),
|
||||
@@ -145,8 +155,12 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
|
||||
|
||||
return list(
|
||||
filter(
|
||||
lambda o: matches(o, document)
|
||||
or (o.pk == pred_id and o.matching_algorithm == MatchingModel.MATCH_AUTO),
|
||||
lambda o: (
|
||||
matches(o, document)
|
||||
or (
|
||||
o.pk == pred_id and o.matching_algorithm == MatchingModel.MATCH_AUTO
|
||||
)
|
||||
),
|
||||
storage_paths,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-07 19:06
|
||||
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0009_workflowaction_passwords_alter_workflowaction_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="content_length",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.functions.text.Length("content"),
|
||||
help_text="Length of the content field in characters. Automatically maintained by the database for faster statistics computation.",
|
||||
output_field=models.PositiveIntegerField(default=0),
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -252,8 +252,10 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
"""
|
||||
self._assert_query_match_predicate(
|
||||
["url_field", "exact", "https://docs.paperless-ngx.com/"],
|
||||
lambda document: "url_field" in document
|
||||
and document["url_field"] == "https://docs.paperless-ngx.com/",
|
||||
lambda document: (
|
||||
"url_field" in document
|
||||
and document["url_field"] == "https://docs.paperless-ngx.com/"
|
||||
),
|
||||
)
|
||||
|
||||
def test_filter_by_multiple_fields(self) -> None:
|
||||
@@ -277,22 +279,26 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
def test_exact(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["string_field", "exact", "paperless"],
|
||||
lambda document: "string_field" in document
|
||||
and document["string_field"] == "paperless",
|
||||
lambda document: (
|
||||
"string_field" in document and document["string_field"] == "paperless"
|
||||
),
|
||||
)
|
||||
|
||||
def test_in(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["string_field", "in", ["paperless", "Paperless"]],
|
||||
lambda document: "string_field" in document
|
||||
and document["string_field"] in ("paperless", "Paperless"),
|
||||
lambda document: (
|
||||
"string_field" in document
|
||||
and document["string_field"] in ("paperless", "Paperless")
|
||||
),
|
||||
)
|
||||
|
||||
def test_isnull(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["string_field", "isnull", True],
|
||||
lambda document: "string_field" in document
|
||||
and document["string_field"] is None,
|
||||
lambda document: (
|
||||
"string_field" in document and document["string_field"] is None
|
||||
),
|
||||
)
|
||||
|
||||
def test_exists(self) -> None:
|
||||
@@ -312,14 +318,16 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
# or the name of the option. They function exactly the same.
|
||||
self._assert_query_match_predicate(
|
||||
["select_field", "exact", "def-456"],
|
||||
lambda document: "select_field" in document
|
||||
and document["select_field"] == "def-456",
|
||||
lambda document: (
|
||||
"select_field" in document and document["select_field"] == "def-456"
|
||||
),
|
||||
)
|
||||
# This is the same as:
|
||||
self._assert_query_match_predicate(
|
||||
["select_field", "exact", "B"],
|
||||
lambda document: "select_field" in document
|
||||
and document["select_field"] == "def-456",
|
||||
lambda document: (
|
||||
"select_field" in document and document["select_field"] == "def-456"
|
||||
),
|
||||
)
|
||||
|
||||
# ==========================================================#
|
||||
@@ -328,25 +336,31 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
def test_icontains(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["string_field", "icontains", "aper"],
|
||||
lambda document: "string_field" in document
|
||||
and document["string_field"] is not None
|
||||
and "aper" in document["string_field"].lower(),
|
||||
lambda document: (
|
||||
"string_field" in document
|
||||
and document["string_field"] is not None
|
||||
and "aper" in document["string_field"].lower()
|
||||
),
|
||||
)
|
||||
|
||||
def test_istartswith(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["string_field", "istartswith", "paper"],
|
||||
lambda document: "string_field" in document
|
||||
and document["string_field"] is not None
|
||||
and document["string_field"].lower().startswith("paper"),
|
||||
lambda document: (
|
||||
"string_field" in document
|
||||
and document["string_field"] is not None
|
||||
and document["string_field"].lower().startswith("paper")
|
||||
),
|
||||
)
|
||||
|
||||
def test_iendswith(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["string_field", "iendswith", "less"],
|
||||
lambda document: "string_field" in document
|
||||
and document["string_field"] is not None
|
||||
and document["string_field"].lower().endswith("less"),
|
||||
lambda document: (
|
||||
"string_field" in document
|
||||
and document["string_field"] is not None
|
||||
and document["string_field"].lower().endswith("less")
|
||||
),
|
||||
)
|
||||
|
||||
def test_url_field_istartswith(self) -> None:
|
||||
@@ -354,9 +368,11 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
# Just showing one of them here.
|
||||
self._assert_query_match_predicate(
|
||||
["url_field", "istartswith", "http://"],
|
||||
lambda document: "url_field" in document
|
||||
and document["url_field"] is not None
|
||||
and document["url_field"].startswith("http://"),
|
||||
lambda document: (
|
||||
"url_field" in document
|
||||
and document["url_field"] is not None
|
||||
and document["url_field"].startswith("http://")
|
||||
),
|
||||
)
|
||||
|
||||
# ==========================================================#
|
||||
@@ -365,41 +381,51 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
def test_gt(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["date_field", "gt", date(2024, 8, 22).isoformat()],
|
||||
lambda document: "date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"] > date(2024, 8, 22),
|
||||
lambda document: (
|
||||
"date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"] > date(2024, 8, 22)
|
||||
),
|
||||
)
|
||||
|
||||
def test_gte(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["date_field", "gte", date(2024, 8, 22).isoformat()],
|
||||
lambda document: "date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"] >= date(2024, 8, 22),
|
||||
lambda document: (
|
||||
"date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"] >= date(2024, 8, 22)
|
||||
),
|
||||
)
|
||||
|
||||
def test_lt(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["integer_field", "lt", 0],
|
||||
lambda document: "integer_field" in document
|
||||
and document["integer_field"] is not None
|
||||
and document["integer_field"] < 0,
|
||||
lambda document: (
|
||||
"integer_field" in document
|
||||
and document["integer_field"] is not None
|
||||
and document["integer_field"] < 0
|
||||
),
|
||||
)
|
||||
|
||||
def test_lte(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["integer_field", "lte", 0],
|
||||
lambda document: "integer_field" in document
|
||||
and document["integer_field"] is not None
|
||||
and document["integer_field"] <= 0,
|
||||
lambda document: (
|
||||
"integer_field" in document
|
||||
and document["integer_field"] is not None
|
||||
and document["integer_field"] <= 0
|
||||
),
|
||||
)
|
||||
|
||||
def test_range(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["float_field", "range", [-0.05, 0.05]],
|
||||
lambda document: "float_field" in document
|
||||
and document["float_field"] is not None
|
||||
and -0.05 <= document["float_field"] <= 0.05,
|
||||
lambda document: (
|
||||
"float_field" in document
|
||||
and document["float_field"] is not None
|
||||
and -0.05 <= document["float_field"] <= 0.05
|
||||
),
|
||||
)
|
||||
|
||||
def test_date_modifier(self) -> None:
|
||||
@@ -407,19 +433,23 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
# with the part of the date you are comparing with.
|
||||
self._assert_query_match_predicate(
|
||||
["date_field", "year__gte", 2024],
|
||||
lambda document: "date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"].year >= 2024,
|
||||
lambda document: (
|
||||
"date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"].year >= 2024
|
||||
),
|
||||
)
|
||||
|
||||
def test_gt_monetary(self) -> None:
|
||||
self._assert_query_match_predicate(
|
||||
["monetary_field", "gt", "99"],
|
||||
lambda document: "monetary_field" in document
|
||||
and document["monetary_field"] is not None
|
||||
and (
|
||||
document["monetary_field"] == "USD100.00" # With currency symbol
|
||||
or document["monetary_field"] == "101.00" # No currency symbol
|
||||
lambda document: (
|
||||
"monetary_field" in document
|
||||
and document["monetary_field"] is not None
|
||||
and (
|
||||
document["monetary_field"] == "USD100.00" # With currency symbol
|
||||
or document["monetary_field"] == "101.00" # No currency symbol
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -430,24 +460,30 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
# Document link field "contains" performs a subset check.
|
||||
self._assert_query_match_predicate(
|
||||
["documentlink_field", "contains", [1, 2]],
|
||||
lambda document: "documentlink_field" in document
|
||||
and document["documentlink_field"] is not None
|
||||
and set(document["documentlink_field"]) >= {1, 2},
|
||||
lambda document: (
|
||||
"documentlink_field" in document
|
||||
and document["documentlink_field"] is not None
|
||||
and set(document["documentlink_field"]) >= {1, 2}
|
||||
),
|
||||
)
|
||||
# The order of IDs don't matter - this is the same as above.
|
||||
self._assert_query_match_predicate(
|
||||
["documentlink_field", "contains", [2, 1]],
|
||||
lambda document: "documentlink_field" in document
|
||||
and document["documentlink_field"] is not None
|
||||
and set(document["documentlink_field"]) >= {1, 2},
|
||||
lambda document: (
|
||||
"documentlink_field" in document
|
||||
and document["documentlink_field"] is not None
|
||||
and set(document["documentlink_field"]) >= {1, 2}
|
||||
),
|
||||
)
|
||||
|
||||
def test_document_link_contains_empty_set(self) -> None:
|
||||
# An empty set is a subset of any set.
|
||||
self._assert_query_match_predicate(
|
||||
["documentlink_field", "contains", []],
|
||||
lambda document: "documentlink_field" in document
|
||||
and document["documentlink_field"] is not None,
|
||||
lambda document: (
|
||||
"documentlink_field" in document
|
||||
and document["documentlink_field"] is not None
|
||||
),
|
||||
)
|
||||
|
||||
def test_document_link_contains_no_reverse_link(self) -> None:
|
||||
@@ -455,9 +491,11 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
# doesn't have a document link field and thus has no reverse link.
|
||||
self._assert_query_match_predicate(
|
||||
["documentlink_field", "contains", [self.documents[6].id]],
|
||||
lambda document: "documentlink_field" in document
|
||||
and document["documentlink_field"] is not None
|
||||
and set(document["documentlink_field"]) >= {self.documents[6].id},
|
||||
lambda document: (
|
||||
"documentlink_field" in document
|
||||
and document["documentlink_field"] is not None
|
||||
and set(document["documentlink_field"]) >= {self.documents[6].id}
|
||||
),
|
||||
match_nothing_ok=True,
|
||||
)
|
||||
|
||||
@@ -470,10 +508,12 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
"AND",
|
||||
[["date_field", "year__exact", 2024], ["date_field", "month__lt", 9]],
|
||||
],
|
||||
lambda document: "date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"].year == 2024
|
||||
and document["date_field"].month < 9,
|
||||
lambda document: (
|
||||
"date_field" in document
|
||||
and document["date_field"] is not None
|
||||
and document["date_field"].year == 2024
|
||||
and document["date_field"].month < 9
|
||||
),
|
||||
)
|
||||
|
||||
def test_logical_or(self) -> None:
|
||||
@@ -483,8 +523,9 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
"OR",
|
||||
[["string_field", "exact", ""], ["string_field", "isnull", True]],
|
||||
],
|
||||
lambda document: "string_field" in document
|
||||
and not bool(document["string_field"]),
|
||||
lambda document: (
|
||||
"string_field" in document and not bool(document["string_field"])
|
||||
),
|
||||
)
|
||||
|
||||
def test_logical_not(self) -> None:
|
||||
@@ -495,8 +536,11 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
"NOT",
|
||||
["string_field", "exact", "paperless"],
|
||||
],
|
||||
lambda document: not (
|
||||
"string_field" in document and document["string_field"] == "paperless"
|
||||
lambda document: (
|
||||
not (
|
||||
"string_field" in document
|
||||
and document["string_field"] == "paperless"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -603,23 +603,21 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
expected_filename,
|
||||
)
|
||||
self.assertEqual(consume_file_args[1].title, None)
|
||||
self.assertTrue(consume_file_args[1].skip_asn)
|
||||
# No metadata_document_id, delete_originals False, so ASN should be None
|
||||
self.assertIsNone(consume_file_args[1].asn)
|
||||
|
||||
# With metadata_document_id overrides
|
||||
result = bulk_edit.merge(doc_ids, metadata_document_id=metadata_document_id)
|
||||
consume_file_args, _ = mock_consume_file.call_args
|
||||
self.assertEqual(consume_file_args[1].title, "B (merged)")
|
||||
self.assertEqual(consume_file_args[1].created, self.doc2.created)
|
||||
self.assertTrue(consume_file_args[1].skip_asn)
|
||||
|
||||
self.assertEqual(result, "OK")
|
||||
|
||||
@mock.patch("documents.bulk_edit.delete.si")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
@mock.patch("documents.bulk_edit.chain")
|
||||
def test_merge_and_delete_originals(
|
||||
self,
|
||||
mock_chain,
|
||||
mock_consume_file,
|
||||
mock_delete_documents,
|
||||
):
|
||||
@@ -633,6 +631,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
- Document deletion task should be called
|
||||
"""
|
||||
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||
self.doc1.archive_serial_number = 101
|
||||
self.doc2.archive_serial_number = 102
|
||||
self.doc3.archive_serial_number = 103
|
||||
self.doc1.save()
|
||||
self.doc2.save()
|
||||
self.doc3.save()
|
||||
|
||||
result = bulk_edit.merge(doc_ids, delete_originals=True)
|
||||
self.assertEqual(result, "OK")
|
||||
@@ -643,7 +647,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
|
||||
mock_consume_file.assert_called()
|
||||
mock_delete_documents.assert_called()
|
||||
mock_chain.assert_called_once()
|
||||
consume_sig = mock_consume_file.return_value
|
||||
consume_sig.apply_async.assert_called_once()
|
||||
|
||||
consume_file_args, _ = mock_consume_file.call_args
|
||||
self.assertEqual(
|
||||
@@ -651,7 +656,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
expected_filename,
|
||||
)
|
||||
self.assertEqual(consume_file_args[1].title, None)
|
||||
self.assertTrue(consume_file_args[1].skip_asn)
|
||||
self.assertEqual(consume_file_args[1].asn, 101)
|
||||
|
||||
delete_documents_args, _ = mock_delete_documents.call_args
|
||||
self.assertEqual(
|
||||
@@ -659,6 +664,92 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
doc_ids,
|
||||
)
|
||||
|
||||
self.doc1.refresh_from_db()
|
||||
self.doc2.refresh_from_db()
|
||||
self.doc3.refresh_from_db()
|
||||
self.assertIsNone(self.doc1.archive_serial_number)
|
||||
self.assertIsNone(self.doc2.archive_serial_number)
|
||||
self.assertIsNone(self.doc3.archive_serial_number)
|
||||
|
||||
@mock.patch("documents.bulk_edit.delete.si")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_merge_and_delete_originals_restore_on_failure(
|
||||
self,
|
||||
mock_consume_file,
|
||||
mock_delete_documents,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing documents
|
||||
WHEN:
|
||||
- Merge action with deleting documents is called with 1 document
|
||||
- Error occurs when queuing consume file task
|
||||
THEN:
|
||||
- Archive serial numbers are restored
|
||||
"""
|
||||
doc_ids = [self.doc1.id]
|
||||
self.doc1.archive_serial_number = 111
|
||||
self.doc1.save()
|
||||
sig = mock.Mock()
|
||||
sig.apply_async.side_effect = Exception("boom")
|
||||
mock_consume_file.return_value = sig
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
bulk_edit.merge(doc_ids, delete_originals=True)
|
||||
|
||||
self.doc1.refresh_from_db()
|
||||
self.assertEqual(self.doc1.archive_serial_number, 111)
|
||||
|
||||
@mock.patch("documents.bulk_edit.delete.si")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_merge_and_delete_originals_metadata_handoff(
|
||||
self,
|
||||
mock_consume_file,
|
||||
mock_delete_documents,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing documents with ASNs
|
||||
WHEN:
|
||||
- Merge with delete_originals=True and metadata_document_id set
|
||||
THEN:
|
||||
- Handoff ASN uses metadata document ASN
|
||||
"""
|
||||
doc_ids = [self.doc1.id, self.doc2.id]
|
||||
self.doc1.archive_serial_number = 101
|
||||
self.doc2.archive_serial_number = 202
|
||||
self.doc1.save()
|
||||
self.doc2.save()
|
||||
|
||||
result = bulk_edit.merge(
|
||||
doc_ids,
|
||||
metadata_document_id=self.doc2.id,
|
||||
delete_originals=True,
|
||||
)
|
||||
self.assertEqual(result, "OK")
|
||||
|
||||
consume_file_args, _ = mock_consume_file.call_args
|
||||
self.assertEqual(consume_file_args[1].asn, 202)
|
||||
|
||||
def test_restore_archive_serial_numbers_task(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document with no archive serial number
|
||||
WHEN:
|
||||
- Restore archive serial number task is called with backup data
|
||||
THEN:
|
||||
- Document archive serial number is restored
|
||||
"""
|
||||
self.doc1.archive_serial_number = 444
|
||||
self.doc1.save()
|
||||
Document.objects.filter(pk=self.doc1.id).update(archive_serial_number=None)
|
||||
|
||||
backup: dict[int, int | None] = {self.doc1.id: 444}
|
||||
bulk_edit.restore_archive_serial_numbers_task(backup)
|
||||
|
||||
self.doc1.refresh_from_db()
|
||||
self.assertEqual(self.doc1.archive_serial_number, 444)
|
||||
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_merge_with_archive_fallback(self, mock_consume_file) -> None:
|
||||
"""
|
||||
@@ -727,6 +818,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(mock_consume_file.call_count, 2)
|
||||
consume_file_args, _ = mock_consume_file.call_args
|
||||
self.assertEqual(consume_file_args[1].title, "B (split 2)")
|
||||
self.assertIsNone(consume_file_args[1].asn)
|
||||
|
||||
self.assertEqual(result, "OK")
|
||||
|
||||
@@ -751,6 +843,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
"""
|
||||
doc_ids = [self.doc2.id]
|
||||
pages = [[1, 2], [3]]
|
||||
self.doc2.archive_serial_number = 200
|
||||
self.doc2.save()
|
||||
|
||||
result = bulk_edit.split(doc_ids, pages, delete_originals=True)
|
||||
self.assertEqual(result, "OK")
|
||||
@@ -768,6 +862,42 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
doc_ids,
|
||||
)
|
||||
|
||||
self.doc2.refresh_from_db()
|
||||
self.assertIsNone(self.doc2.archive_serial_number)
|
||||
|
||||
@mock.patch("documents.bulk_edit.delete.si")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
@mock.patch("documents.bulk_edit.chord")
|
||||
def test_split_restore_on_failure(
|
||||
self,
|
||||
mock_chord,
|
||||
mock_consume_file,
|
||||
mock_delete_documents,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing documents
|
||||
WHEN:
|
||||
- Split action with deleting documents is called with 1 document and 2 page groups
|
||||
- Error occurs when queuing chord task
|
||||
THEN:
|
||||
- Archive serial numbers are restored
|
||||
"""
|
||||
doc_ids = [self.doc2.id]
|
||||
pages = [[1, 2]]
|
||||
self.doc2.archive_serial_number = 222
|
||||
self.doc2.save()
|
||||
|
||||
sig = mock.Mock()
|
||||
sig.apply_async.side_effect = Exception("boom")
|
||||
mock_chord.return_value = sig
|
||||
|
||||
result = bulk_edit.split(doc_ids, pages, delete_originals=True)
|
||||
self.assertEqual(result, "OK")
|
||||
|
||||
self.doc2.refresh_from_db()
|
||||
self.assertEqual(self.doc2.archive_serial_number, 222)
|
||||
|
||||
@mock.patch("documents.tasks.consume_file.delay")
|
||||
@mock.patch("pikepdf.Pdf.save")
|
||||
def test_split_with_errors(self, mock_save_pdf, mock_consume_file) -> None:
|
||||
@@ -977,13 +1107,55 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
mock_chord.return_value.delay.return_value = None
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [{"page": 1}, {"page": 2}]
|
||||
self.doc2.archive_serial_number = 250
|
||||
self.doc2.save()
|
||||
|
||||
result = bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
|
||||
self.assertEqual(result, "OK")
|
||||
mock_chord.assert_called_once()
|
||||
consume_file_args, _ = mock_consume_file.call_args
|
||||
self.assertEqual(consume_file_args[1].asn, 250)
|
||||
self.doc2.refresh_from_db()
|
||||
self.assertIsNone(self.doc2.archive_serial_number)
|
||||
|
||||
@mock.patch("documents.bulk_edit.delete.si")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
@mock.patch("documents.bulk_edit.chord")
|
||||
def test_edit_pdf_restore_on_failure(
|
||||
self,
|
||||
mock_chord: mock.Mock,
|
||||
mock_consume_file: mock.Mock,
|
||||
mock_delete_documents: mock.Mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- edit_pdf is called with delete_original=True
|
||||
- Error occurs when queuing chord task
|
||||
THEN:
|
||||
- Archive serial numbers are restored
|
||||
"""
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [{"page": 1}]
|
||||
self.doc2.archive_serial_number = 333
|
||||
self.doc2.save()
|
||||
|
||||
sig = mock.Mock()
|
||||
sig.apply_async.side_effect = Exception("boom")
|
||||
mock_chord.return_value = sig
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
|
||||
|
||||
self.doc2.refresh_from_db()
|
||||
self.assertEqual(self.doc2.archive_serial_number, 333)
|
||||
|
||||
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
|
||||
def test_edit_pdf_with_update_document(self, mock_update_document) -> None:
|
||||
def test_edit_pdf_with_update_document(
|
||||
self,
|
||||
mock_update_document: mock.Mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A single existing PDF document
|
||||
@@ -1013,7 +1185,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_without_metadata(self, mock_consume_file, mock_group) -> None:
|
||||
def test_edit_pdf_without_metadata(
|
||||
self,
|
||||
mock_consume_file: mock.Mock,
|
||||
mock_group: mock.Mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
@@ -1032,7 +1208,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_open_failure(self, mock_consume_file, mock_group) -> None:
|
||||
def test_edit_pdf_open_failure(
|
||||
self,
|
||||
mock_consume_file: mock.Mock,
|
||||
mock_group: mock.Mock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
|
||||
from documents.barcodes import BarcodePlugin
|
||||
from documents.consumer import ConsumerError
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.data_models import DocumentSource
|
||||
@@ -412,14 +413,6 @@ class TestConsumer(
|
||||
self.assertEqual(document.archive_serial_number, 123)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testMetadataOverridesSkipAsnPropagation(self) -> None:
|
||||
overrides = DocumentMetadataOverrides()
|
||||
incoming = DocumentMetadataOverrides(skip_asn=True)
|
||||
|
||||
overrides.update(incoming)
|
||||
|
||||
self.assertTrue(overrides.skip_asn)
|
||||
|
||||
def testOverrideTitlePlaceholders(self) -> None:
|
||||
c = Correspondent.objects.create(name="Correspondent Name")
|
||||
dt = DocumentType.objects.create(name="DocType Name")
|
||||
@@ -1271,3 +1264,46 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
|
||||
r"sample\.pdf: Error while executing post-consume script: Command '\[.*\]' returned non-zero exit status \d+\.",
|
||||
):
|
||||
consumer.run_post_consume_script(doc)
|
||||
|
||||
|
||||
class TestMetadataOverrides(TestCase):
|
||||
def test_update_skip_asn_if_exists(self) -> None:
|
||||
base = DocumentMetadataOverrides()
|
||||
incoming = DocumentMetadataOverrides(skip_asn_if_exists=True)
|
||||
base.update(incoming)
|
||||
self.assertTrue(base.skip_asn_if_exists)
|
||||
|
||||
|
||||
class TestBarcodeApplyDetectedASN(TestCase):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing Documents with ASN 123
|
||||
WHEN:
|
||||
- A BarcodePlugin which detected an ASN
|
||||
THEN:
|
||||
- If skip_asn_if_exists is set, and ASN exists, do not set ASN
|
||||
- If skip_asn_if_exists is set, and ASN does not exist, set ASN
|
||||
"""
|
||||
|
||||
def test_apply_detected_asn_skips_existing_when_flag_set(self) -> None:
|
||||
doc = Document.objects.create(
|
||||
checksum="X1",
|
||||
title="D1",
|
||||
archive_serial_number=123,
|
||||
)
|
||||
metadata = DocumentMetadataOverrides(skip_asn_if_exists=True)
|
||||
plugin = BarcodePlugin(
|
||||
input_doc=mock.Mock(),
|
||||
metadata=metadata,
|
||||
status_mgr=mock.Mock(),
|
||||
base_tmp_dir=Path(tempfile.gettempdir()),
|
||||
task_id="test-task",
|
||||
)
|
||||
|
||||
plugin._apply_detected_asn(123)
|
||||
self.assertIsNone(plugin.metadata.asn)
|
||||
|
||||
doc.hard_delete()
|
||||
|
||||
plugin._apply_detected_asn(123)
|
||||
self.assertEqual(plugin.metadata.asn, 123)
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-03 17:32+0000\n"
|
||||
"POT-Creation-Date: 2026-02-03 20:10+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1323,7 +1323,7 @@ msgstr ""
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2330 documents/views.py:2838
|
||||
#: documents/serialisers.py:2330 documents/views.py:2839
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1587,20 +1587,20 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2850
|
||||
#: documents/views.py:2851
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2893
|
||||
#: documents/views.py:2894
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2950
|
||||
#: documents/views.py:2951
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2960
|
||||
#: documents/views.py:2961
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -1362,7 +1362,7 @@ def _get_nltk_language_setting(ocr_lang: str) -> str | None:
|
||||
The common intersection between all languages in those 3 is handled here
|
||||
|
||||
"""
|
||||
ocr_lang = ocr_lang.split("+")[0]
|
||||
ocr_lang = ocr_lang.split("+", maxsplit=1)[0]
|
||||
iso_code_to_nltk = {
|
||||
"dan": "danish",
|
||||
"nld": "dutch",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dataclasses
|
||||
import email.contentmanager
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from collections import namedtuple
|
||||
@@ -148,11 +147,7 @@ class BogusMailBox(AbstractContextManager):
|
||||
|
||||
if "TO" in criteria:
|
||||
to_ = criteria[criteria.index("TO") + 1].strip('"')
|
||||
msg = []
|
||||
for m in self.messages:
|
||||
for to_addrs in m.to:
|
||||
if to_ in to_addrs:
|
||||
msg.append(m)
|
||||
msg = filter(lambda m: any(to_ in to_addr for to_addr in m.to), msg)
|
||||
|
||||
if "UNFLAGGED" in criteria:
|
||||
msg = filter(lambda m: not m.flagged, msg)
|
||||
@@ -204,7 +199,7 @@ def fake_magic_from_buffer(buffer, *, mime=False):
|
||||
|
||||
class MessageBuilder:
|
||||
def __init__(self) -> None:
|
||||
self._used_uids = set()
|
||||
self._next_uid = 1
|
||||
|
||||
def create_message(
|
||||
self,
|
||||
@@ -257,10 +252,8 @@ class MessageBuilder:
|
||||
# TODO: Unsure how to add a uid to the actual EmailMessage. This hacks it in,
|
||||
# based on how imap_tools uses regex to extract it.
|
||||
# This should be a large enough pool
|
||||
uid = random.randint(1, 10000)
|
||||
while uid in self._used_uids:
|
||||
uid = random.randint(1, 10000)
|
||||
self._used_uids.add(uid)
|
||||
uid = self._next_uid
|
||||
self._next_uid += 1
|
||||
|
||||
imap_msg._raw_uid_data = f"UID {uid}".encode()
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ class TestParserSettingsFromDb(DirectoriesMixin, FileSystemAssertsMixin, TestCas
|
||||
"""
|
||||
with override_settings(OCR_ROTATE_PAGES=False, OCR_ROTATE_PAGES_THRESHOLD=30.0):
|
||||
instance = ApplicationConfiguration.objects.all().first()
|
||||
assert instance is not None
|
||||
instance.rotate_pages = True
|
||||
instance.rotate_pages_threshold = 15.0
|
||||
instance.save()
|
||||
|
||||
@@ -102,6 +102,7 @@ class TestTikaParserAgainstServer:
|
||||
[sample_doc_file, "application/msword"],
|
||||
)
|
||||
|
||||
assert tika_parser.text is not None
|
||||
assert (
|
||||
"This is a test document, saved in the older .doc format"
|
||||
in tika_parser.text
|
||||
|
||||
128
zensical.toml
Normal file
128
zensical.toml
Normal file
@@ -0,0 +1,128 @@
|
||||
[project]
|
||||
|
||||
site_name = "Paperless-ngx"
|
||||
site_description = "Documentation for the Paperless-ngx document management system software."
|
||||
site_author = "the Paperless-ngx team"
|
||||
|
||||
site_url = "https://docs.paperless-ngx.com/"
|
||||
|
||||
copyright = """
|
||||
Copyright © 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
|
||||
"""
|
||||
|
||||
repo_url = "https://github.com/paperless-ngx/paperless-ngx"
|
||||
repo_name = "paperless-ngx/paperless-ngx"
|
||||
|
||||
nav = [
|
||||
"index.md",
|
||||
"setup.md",
|
||||
"usage.md",
|
||||
"configuration.md",
|
||||
"administration.md",
|
||||
"advanced_usage.md",
|
||||
"api.md",
|
||||
"development.md",
|
||||
"faq.md",
|
||||
"troubleshooting.md",
|
||||
"changelog.md",
|
||||
]
|
||||
|
||||
edit_uri = "blob/main/docs/"
|
||||
|
||||
extra_css = ["assets/extra.css"]
|
||||
|
||||
[project.theme]
|
||||
|
||||
logo = "assets/logo.svg"
|
||||
favicon = "assets/favicon.png"
|
||||
|
||||
language = "en"
|
||||
|
||||
features = [
|
||||
"announce.dismiss",
|
||||
"content.code.annotate",
|
||||
"content.code.copy",
|
||||
"content.code.select",
|
||||
"content.footnote.tooltips",
|
||||
"navigation.footer",
|
||||
"navigation.instant",
|
||||
"navigation.instant.prefetch",
|
||||
"navigation.path",
|
||||
"navigation.sections",
|
||||
"navigation.tabs",
|
||||
"navigation.top",
|
||||
"navigation.tracking",
|
||||
"search.highlight",
|
||||
"toc.integrate",
|
||||
]
|
||||
|
||||
# Palette toggle for automatic mode
|
||||
[[project.theme.palette]]
|
||||
media = "(prefers-color-scheme)"
|
||||
primary = "green"
|
||||
accent = "green"
|
||||
toggle.icon = "lucide/sun-moon"
|
||||
toggle.name = "Switch to light mode"
|
||||
|
||||
# Palette toggle for light mode
|
||||
[[project.theme.palette]]
|
||||
media = "(prefers-color-scheme: light)"
|
||||
scheme = "default"
|
||||
primary = "green"
|
||||
accent = "green"
|
||||
toggle.icon = "lucide/sun"
|
||||
toggle.name = "Switch to dark mode"
|
||||
|
||||
# Palette toggle for dark mode
|
||||
[[project.theme.palette]]
|
||||
media = "(prefers-color-scheme: dark)"
|
||||
scheme = "slate"
|
||||
primary = "green"
|
||||
accent = "green"
|
||||
toggle.icon = "lucide/moon"
|
||||
toggle.name = "Switch to system preference"
|
||||
|
||||
[project.theme.font]
|
||||
text = "Roboto"
|
||||
code = "Roboto Mono"
|
||||
|
||||
# Extras
|
||||
[[project.extra.social]]
|
||||
icon = "fontawesome/brands/github"
|
||||
link = "https://github.com/paperless-ngx/paperless-ngx"
|
||||
|
||||
[[project.extra.social]]
|
||||
icon = "fontawesome/brands/docker"
|
||||
link = "https://hub.docker.com/r/paperlessngx/paperless-ngx"
|
||||
|
||||
[[project.extra.social]]
|
||||
icon = "material/chat"
|
||||
link = "https://matrix.to/#/#paperless:matrix.org"
|
||||
|
||||
# Extensions
|
||||
[project.markdown_extensions.attr_list]
|
||||
[project.markdown_extensions.md_in_html]
|
||||
[project.markdown_extensions.pymdownx.blocks.caption]
|
||||
[project.markdown_extensions.def_list]
|
||||
|
||||
[project.markdown_extensions.pymdownx.highlight]
|
||||
anchor_linenums = true
|
||||
|
||||
[project.markdown_extensions.pymdownx.inlinehilite]
|
||||
|
||||
[project.markdown_extensions.pymdownx.tilde]
|
||||
|
||||
[project.markdown_extensions.pymdownx.snippets]
|
||||
|
||||
[project.markdown_extensions.admonition]
|
||||
|
||||
[project.markdown_extensions.pymdownx.details]
|
||||
|
||||
[project.markdown_extensions.pymdownx.superfences]
|
||||
custom_fences = [
|
||||
{ name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }
|
||||
]
|
||||
|
||||
[project.markdown_extensions.pymdownx.emoji]
|
||||
emoji_index = "zensical.extensions.emoji.twemoji"
|
||||
emoji_generator = "zensical.extensions.emoji.to_svg"
|
||||
Reference in New Issue
Block a user