mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-14 03:57:13 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b420fc1954 | ||
|
|
05321b10d3 | ||
|
|
d7a9894400 | ||
|
|
ee34a6598e | ||
|
|
3a3b3ef66a |
No files matched your search
@@ -80,7 +80,7 @@ jobs:
|
|||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.backend_changed == 'true'
|
if: needs.changes.outputs.backend_changed == 'true'
|
||||||
name: "Python ${{ matrix.python-version }}"
|
name: "Python ${{ matrix.python-version }}"
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
||||||
- name: Configure ImageMagick
|
- name: Configure ImageMagick
|
||||||
run: |
|
run: |
|
||||||
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.backend_changed == 'true'
|
if: needs.changes.outputs.backend_changed == 'true'
|
||||||
name: Check project typing
|
name: Check project typing
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runner: ubuntu-24.04
|
- runner: ubuntu-26.04
|
||||||
arch: amd64
|
arch: amd64
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
- runner: ubuntu-24.04-arm
|
- runner: ubuntu-26.04-arm
|
||||||
arch: arm64
|
arch: arm64
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
@@ -163,7 +163,7 @@ jobs:
|
|||||||
archive: false
|
archive: false
|
||||||
merge-and-push:
|
merge-and-push:
|
||||||
name: Merge and Push Manifest
|
name: Merge and Push Manifest
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
needs: build-arch
|
needs: build-arch
|
||||||
if: needs.build-arch.outputs.should-push == 'true'
|
if: needs.build-arch.outputs.should-push == 'true'
|
||||||
environment: image-publishing
|
environment: image-publishing
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.docs_changed == 'true'
|
if: needs.changes.outputs.docs_changed == 'true'
|
||||||
name: Build Documentation
|
name: Build Documentation
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
name: Deploy Documentation
|
name: Deploy Documentation
|
||||||
needs: [changes, build]
|
needs: [changes, build]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.docs_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
name: Lint
|
name: Lint
|
||||||
needs: [changes, install-dependencies]
|
needs: [changes, install-dependencies]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
needs: [changes, install-dependencies]
|
needs: [changes, install-dependencies]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
@@ -188,10 +188,10 @@ jobs:
|
|||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
needs: [changes, install-dependencies]
|
needs: [changes, install-dependencies]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.62.1-noble
|
container: mcr.microsoft.com/playwright:v1.62.1-resolute
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
@@ -246,7 +246,7 @@ jobs:
|
|||||||
name: Frontend Build
|
name: Frontend Build
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [changes, unit-tests, e2e-tests]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
wait-for-docker:
|
wait-for-docker:
|
||||||
name: Wait for Docker Build
|
name: Wait for Docker Build
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
checks: read
|
checks: read
|
||||||
statuses: read
|
statuses: read
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
build-release:
|
build-release:
|
||||||
name: Build Release
|
name: Build Release
|
||||||
needs: wait-for-docker
|
needs: wait-for-docker
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
timeout-minutes: 12
|
timeout-minutes: 12
|
||||||
uses: $/.github/actions/apt-install
|
uses: $/.github/actions/apt-install
|
||||||
with:
|
with:
|
||||||
packages: gettext liblept5
|
packages: gettext libleptonica6
|
||||||
# ---- Build Documentation ----
|
# ---- Build Documentation ----
|
||||||
- name: Build documentation
|
- name: Build documentation
|
||||||
env:
|
env:
|
||||||
@@ -145,7 +145,7 @@ jobs:
|
|||||||
publish-release:
|
publish-release:
|
||||||
name: Publish Release
|
name: Publish Release
|
||||||
needs: build-release
|
needs: build-release
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -197,7 +197,7 @@ jobs:
|
|||||||
name: Append Changelog
|
name: Append Changelog
|
||||||
needs: publish-release
|
needs: publish-release
|
||||||
if: needs.publish-release.outputs.prerelease == 'false'
|
if: needs.publish-release.outputs.prerelease == 'false'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
zizmor:
|
zizmor:
|
||||||
name: Run zizmor
|
name: Run zizmor
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
semgrep:
|
semgrep:
|
||||||
name: Semgrep CE
|
name: Semgrep CE
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
container:
|
container:
|
||||||
image: semgrep/semgrep:1.155.0@sha256:cc869c685dcc0fe497c86258da9f205397d8108e56d21a86082ea4886e52784d
|
image: semgrep/semgrep:1.155.0@sha256:cc869c685dcc0fe497c86258da9f205397d8108e56d21a86082ea4886e52784d
|
||||||
if: github.actor != 'dependabot[bot]'
|
if: github.actor != 'dependabot[bot]'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
cleanup-images:
|
cleanup-images:
|
||||||
name: Cleanup Image Tags for ${{ matrix.primary-name }}
|
name: Cleanup Image Tags for ${{ matrix.primary-name }}
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
environment: registry-maintenance
|
environment: registry-maintenance
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
cleanup-untagged-images:
|
cleanup-untagged-images:
|
||||||
name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }}
|
name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }}
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
needs:
|
needs:
|
||||||
- cleanup-images
|
- cleanup-images
|
||||||
environment: registry-maintenance
|
environment: registry-maintenance
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
synchronize-with-crowdin:
|
synchronize-with-crowdin:
|
||||||
name: Crowdin Sync
|
name: Crowdin Sync
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
environment: translation-sync
|
environment: translation-sync
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
# Note: peakoss/anti-slop does not support the `issues` event yet (all of its
|
# Note: peakoss/anti-slop does not support the `issues` event yet (all of its
|
||||||
# issue inputs are still commented out upstream), so the checks that the PR Bot
|
# issue inputs are still commented out upstream), so the checks that the PR Bot
|
||||||
# workflow gets from the action are implemented manually here.
|
# workflow gets from the action are implemented manually here.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-slim
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
types: [opened]
|
types: [opened]
|
||||||
jobs:
|
jobs:
|
||||||
Anti-slop:
|
Anti-slop:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-slim
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: read
|
issues: read
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
ASLOP-PR-VERIFY
|
ASLOP-PR-VERIFY
|
||||||
pr-bot:
|
pr-bot:
|
||||||
name: Automated PR Bot
|
name: Automated PR Bot
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-slim
|
||||||
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
|
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
|
||||||
# instead of racing it. Still runs if that job fails, so labeling is not lost.
|
# instead of racing it. Still runs if that job fails, so labeling is not lost.
|
||||||
needs: Anti-slop
|
needs: Anti-slop
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
pr_opened_or_reopened:
|
pr_opened_or_reopened:
|
||||||
name: pr_opened_or_reopened
|
name: pr_opened_or_reopened
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-slim
|
||||||
permissions:
|
permissions:
|
||||||
# write permission is required for autolabeler
|
# write permission is required for autolabeler
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
name: 'Stale'
|
name: 'Stale'
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
lock-threads:
|
lock-threads:
|
||||||
name: 'Lock Old Threads'
|
name: 'Lock Old Threads'
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
close-answered-discussions:
|
close-answered-discussions:
|
||||||
name: 'Close Answered Discussions'
|
name: 'Close Answered Discussions'
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-slim
|
||||||
permissions:
|
permissions:
|
||||||
discussions: write
|
discussions: write
|
||||||
steps:
|
steps:
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
close-outdated-discussions:
|
close-outdated-discussions:
|
||||||
name: 'Close Outdated Discussions'
|
name: 'Close Outdated Discussions'
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-slim
|
||||||
permissions:
|
permissions:
|
||||||
discussions: write
|
discussions: write
|
||||||
steps:
|
steps:
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
close-unsupported-feature-requests:
|
close-unsupported-feature-requests:
|
||||||
name: 'Close Unsupported Feature Requests'
|
name: 'Close Unsupported Feature Requests'
|
||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-slim
|
||||||
permissions:
|
permissions:
|
||||||
discussions: write
|
discussions: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
generate-translate-strings:
|
generate-translate-strings:
|
||||||
name: Generate Translation Strings
|
name: Generate Translation Strings
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-26.04
|
||||||
environment: translation-sync
|
environment: translation-sync
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
+1
-3
@@ -76,9 +76,7 @@ is not supported by any of the available parsers.
|
|||||||
|
|
||||||
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
||||||
consumed, and the duplicate is flagged in the UI — open the document and check the
|
consumed, and the duplicate is flagged in the UI — open the document and check the
|
||||||
**Duplicates** tab to review documents that share the same content, or filter the document
|
**Duplicates** tab to review documents that share the same content. If you prefer the old
|
||||||
list by **Duplicates** to find all of them (see
|
|
||||||
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
|
|
||||||
behavior of rejecting duplicates during consumption, set
|
behavior of rejecting duplicates during consumption, set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||||
to `true`.
|
to `true`.
|
||||||
|
|||||||
+8
-7
@@ -299,18 +299,19 @@ for details.
|
|||||||
### Duplicate documents
|
### Duplicate documents
|
||||||
|
|
||||||
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
||||||
contents match an existing document (same original or archive checksum), the new copy is
|
contents exactly match an existing document (same checksum), the new copy is still
|
||||||
still consumed and a warning is logged.
|
consumed and a warning is logged. The task entry for the upload also flags that a
|
||||||
|
duplicate was detected and links to the existing document(s).
|
||||||
|
|
||||||
When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
|
To review duplicates, open a document and switch to the **Duplicates** tab on the
|
||||||
the other documents you can view that share the same content (including any in the trash).
|
document detail page. It lists other documents that share the same content, including any
|
||||||
To find all documents with duplicates, choose **Duplicates** in the document list's text
|
that are in the trash (shown with a badge), and links to each so you can decide which to
|
||||||
filter dropdown, or use `has_duplicates=true` in the REST API.
|
keep.
|
||||||
|
|
||||||
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
||||||
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
||||||
a "Document already exists" message linking to the existing document.
|
a "document already exists" message.
|
||||||
|
|
||||||
## Document Suggestions
|
## Document Suggestions
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.3.0"
|
version = "3.2.1"
|
||||||
description = """\
|
description = """\
|
||||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.3.0",
|
"version": "3.2.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -29,8 +29,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 header-search mx-auto">
|
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search class="flex-grow-1"></pngx-global-search>
|
||||||
|
<button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }"
|
||||||
|
class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center flex-shrink-0 py-0 px-2" type="button"
|
||||||
|
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
|
||||||
|
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
@@ -70,6 +75,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
|
||||||
|
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
|
|||||||
@@ -532,7 +532,23 @@ main {
|
|||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 55rem;
|
max-width: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search pngx-global-search {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// light w shadow to stand out against search
|
||||||
|
.header-upload {
|
||||||
|
--bs-btn-bg: rgba(255, 255, 255, .15);
|
||||||
|
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
|
||||||
|
--bs-btn-active-bg: rgba(255, 255, 255, .3);
|
||||||
|
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
|
||||||
|
|
||||||
|
&:not(:focus) {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2); // leave room for the .btn:focus ring
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -662,3 +678,7 @@ main {
|
|||||||
::ng-deep .navItemDrag .position-absolute i-bs {
|
::ng-deep .navItemDrag .position-absolute i-bs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.smaller {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { SearchService } from 'src/app/services/rest/search.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
@@ -462,6 +463,29 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(routerSpy).toHaveBeenCalled()
|
expect(routerSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should upload files selected from the navbar upload button', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest
|
||||||
|
.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const file = new File(['testing'], 'file.pdf')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
|
||||||
|
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith([file])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not upload when no files are selected from the navbar', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(uploadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should close all documents on logout', () => {
|
it('should close all documents on logout', () => {
|
||||||
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
||||||
component.onLogout()
|
component.onLogout()
|
||||||
|
|||||||
@@ -48,12 +48,13 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -101,6 +102,7 @@ export class AppFrameComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
|
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||||
|
|
||||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
@@ -511,6 +513,15 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = Array.from(input.files ?? [])
|
||||||
|
input.value = ''
|
||||||
|
if (!files.length) return
|
||||||
|
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||||
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
onLogout() {
|
onLogout() {
|
||||||
this.openDocumentsService.closeAll()
|
this.openDocumentsService.closeAll()
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-8
@@ -85,15 +85,11 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
'file.pdf'
|
'file.pdf'
|
||||||
)
|
)
|
||||||
const fileInput = fixture.debugElement.query(By.css('input'))
|
const fileInput = fixture.debugElement.query(By.css('input'))
|
||||||
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
|
jest
|
||||||
item: () => file,
|
.spyOn(fileInput.nativeElement, 'files', 'get')
|
||||||
length: 1,
|
.mockReturnValue([file] as any)
|
||||||
[Symbol.iterator]: () => ({
|
|
||||||
next: () => ({ done: false, value: file }),
|
|
||||||
}),
|
|
||||||
} as any)
|
|
||||||
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
||||||
expect(uploadSpy).toHaveBeenCalled()
|
expect(uploadSpy).toHaveBeenCalledWith(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should generate stats summary', () => {
|
it('should generate stats summary', () => {
|
||||||
|
|||||||
+3
-5
@@ -130,11 +130,9 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onFileSelected(event: Event) {
|
public onFileSelected(event: Event) {
|
||||||
const files = (event.target as HTMLInputElement).files
|
const input = event.target as HTMLInputElement
|
||||||
for (let i = 0; i < files?.length; i++) {
|
this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
|
||||||
const file = files.item(i)
|
input.value = ''
|
||||||
file && this.uploadDocumentsService.uploadFile(file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export class FileDropComponent {
|
|||||||
.then((results) => {
|
.then((results) => {
|
||||||
files.push(...[].concat(...results))
|
files.push(...[].concat(...results))
|
||||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -144,7 +144,7 @@ export class FileDropComponent {
|
|||||||
})
|
})
|
||||||
} else if (files.length) {
|
} else if (files.length) {
|
||||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onDragLeave(event, true)
|
this.onDragLeave(event, true)
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ describe('UploadDocumentsService', () => {
|
|||||||
req[0].flush('123-456')
|
req[0].flush('123-456')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uploads each file when given multiple files', () => {
|
||||||
|
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
|
||||||
|
const file1 = new File(['one'], 'one.pdf')
|
||||||
|
const file2 = new File(['two'], 'two.pdf')
|
||||||
|
uploadDocumentsService.uploadFiles([file1, file2])
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith(file1)
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith(file2)
|
||||||
|
httpTestingController
|
||||||
|
.match(`${environment.apiBaseUrl}documents/post_document/`)
|
||||||
|
.forEach((req) => req.flush('123-456'))
|
||||||
|
})
|
||||||
|
|
||||||
it('updates progress during upload and failure', () => {
|
it('updates progress during upload and failure', () => {
|
||||||
const file = new File(
|
const file = new File(
|
||||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export class UploadDocumentsService {
|
|||||||
|
|
||||||
private uploadSubscriptions: Array<Subscription> = []
|
private uploadSubscriptions: Array<Subscription> = []
|
||||||
|
|
||||||
|
public uploadFiles(files: Iterable<File>) {
|
||||||
|
for (const file of files) {
|
||||||
|
this.uploadFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public uploadFile(file: File) {
|
public uploadFile(file: File) {
|
||||||
let formData = new FormData()
|
let formData = new FormData()
|
||||||
formData.append('document', file, file.name)
|
formData.append('document', file, file.name)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const environment = {
|
|||||||
apiVersion: '10', // match src/paperless/settings.py
|
apiVersion: '10', // match src/paperless/settings.py
|
||||||
appTitle: DEFAULT_APP_TITLE,
|
appTitle: DEFAULT_APP_TITLE,
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '3.3.0',
|
version: '3.2.1',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
|
fileEarmarkArrowUp,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
@@ -341,6 +342,7 @@ const icons = {
|
|||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
|
fileEarmarkArrowUp,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from documents.search._query import parse_user_query
|
|||||||
from documents.search._schema import _write_sentinels
|
from documents.search._schema import _write_sentinels
|
||||||
from documents.search._schema import build_schema
|
from documents.search._schema import build_schema
|
||||||
from documents.search._schema import open_or_rebuild_index
|
from documents.search._schema import open_or_rebuild_index
|
||||||
|
from documents.search._schema import rebuild_in_progress
|
||||||
from documents.search._schema import wipe_index
|
from documents.search._schema import wipe_index
|
||||||
from documents.search._tokenizer import ascii_fold
|
from documents.search._tokenizer import ascii_fold
|
||||||
from documents.search._tokenizer import autocomplete_tokens
|
from documents.search._tokenizer import autocomplete_tokens
|
||||||
@@ -1110,6 +1111,9 @@ class TantivyBackend:
|
|||||||
flushing a segment, deferring merge work; they do not avoid it.
|
flushing a segment, deferring merge work; they do not avoid it.
|
||||||
"""
|
"""
|
||||||
wipe_index(self._path)
|
wipe_index(self._path)
|
||||||
|
# The marker covers the window where the empty index is already stamped
|
||||||
|
# as current but not yet populated, so an interrupted rebuild is retried.
|
||||||
|
with rebuild_in_progress(self._path):
|
||||||
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
new_index = tantivy.Index(build_schema(), path=str(self._path))
|
||||||
_write_sentinels(self._path)
|
_write_sentinels(self._path)
|
||||||
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
register_tokenizers(new_index, settings.SEARCH_LANGUAGE)
|
||||||
@@ -1119,8 +1123,9 @@ class TantivyBackend:
|
|||||||
self._raw_index = new_index
|
self._raw_index = new_index
|
||||||
self._raw_schema = new_index.schema
|
self._raw_schema = new_index.schema
|
||||||
# Stream documents one-by-one (so the progress bar advances per
|
# Stream documents one-by-one (so the progress bar advances per
|
||||||
# document) while fetching viewer permissions one SQL query per chunk.
|
# document) while fetching viewer permissions one SQL query per
|
||||||
# The stream is Sized, so iter_wrapper can still discover the total.
|
# chunk. The stream is Sized, so iter_wrapper can still discover
|
||||||
|
# the total.
|
||||||
documents_stream = _DocumentViewerStream(documents, chunk_size=1000)
|
documents_stream = _DocumentViewerStream(documents, chunk_size=1000)
|
||||||
try:
|
try:
|
||||||
writer = new_index.writer(heap_size=writer_heap_bytes)
|
writer = new_index.writer(heap_size=writer_heap_bytes)
|
||||||
@@ -1134,8 +1139,9 @@ class TantivyBackend:
|
|||||||
)
|
)
|
||||||
writer.add_document(doc)
|
writer.add_document(doc)
|
||||||
writer.commit()
|
writer.commit()
|
||||||
# Wait for background merge threads to finish so all segments are
|
# Wait for background merge threads to finish so all segments
|
||||||
# fully merged and persisted before the index is considered rebuilt.
|
# are fully merged and persisted before the index is considered
|
||||||
|
# rebuilt.
|
||||||
writer.wait_merging_threads()
|
writer.wait_merging_threads()
|
||||||
new_index.reload()
|
new_index.reload()
|
||||||
except BaseException: # pragma: no cover
|
except BaseException: # pragma: no cover
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
from contextlib import contextmanager
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
@@ -16,6 +17,7 @@ from whoosh_compat import FieldKind
|
|||||||
from documents.search._fields import PUBLIC_FIELDS
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.search")
|
logger = logging.getLogger("paperless.search")
|
||||||
@@ -28,6 +30,11 @@ logger = logging.getLogger("paperless.search")
|
|||||||
# v3 - barcodes JSON field for stored barcode contents
|
# v3 - barcodes JSON field for stored barcode contents
|
||||||
SCHEMA_VERSION: Final[int] = 3
|
SCHEMA_VERSION: Final[int] = 3
|
||||||
|
|
||||||
|
# Present in the index directory from the moment a full rebuild starts until it
|
||||||
|
# finishes. If a rebuild is interrupted it is left behind, so the half-built
|
||||||
|
# index is not mistaken for a complete one.
|
||||||
|
REBUILD_MARKER: Final[str] = ".rebuilding"
|
||||||
|
|
||||||
|
|
||||||
class FieldDescriptor(NamedTuple):
|
class FieldDescriptor(NamedTuple):
|
||||||
"""One tantivy field, in declaration order.
|
"""One tantivy field, in declaration order.
|
||||||
@@ -255,9 +262,9 @@ def needs_rebuild(index_dir: Path) -> bool:
|
|||||||
"""
|
"""
|
||||||
Check if the search index needs rebuilding.
|
Check if the search index needs rebuilding.
|
||||||
|
|
||||||
Reads .index_settings.json to compare the stored schema version, search
|
True if a previous full rebuild never finished (the rebuild marker is still
|
||||||
language and schema fingerprint against the current configuration. Returns
|
present), or if the index's stamped settings no longer match the current
|
||||||
True if the file is missing, unparsable, or any value mismatches.
|
configuration. See _settings_mismatch().
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index_dir: Path to the search index directory
|
index_dir: Path to the search index directory
|
||||||
@@ -265,6 +272,40 @@ def needs_rebuild(index_dir: Path) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if the index needs rebuilding, False if it's up to date
|
True if the index needs rebuilding, False if it's up to date
|
||||||
"""
|
"""
|
||||||
|
if (index_dir / REBUILD_MARKER).exists():
|
||||||
|
logger.warning("Previous search index rebuild did not finish - rebuilding.")
|
||||||
|
return True
|
||||||
|
return _settings_mismatch(index_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def rebuild_in_progress(index_dir: Path) -> Iterator[None]:
|
||||||
|
"""
|
||||||
|
Flag the index as incomplete for the duration of a full rebuild.
|
||||||
|
|
||||||
|
The marker is cleared only if the block exits cleanly. There is deliberately
|
||||||
|
no try/finally: an exception must leave the marker behind so the next
|
||||||
|
needs_rebuild() check retries the rebuild.
|
||||||
|
"""
|
||||||
|
marker = index_dir / REBUILD_MARKER
|
||||||
|
marker.touch()
|
||||||
|
yield
|
||||||
|
marker.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_mismatch(index_dir: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Check the stamped settings against the current configuration.
|
||||||
|
|
||||||
|
Reads .index_settings.json to compare the stored schema version, search
|
||||||
|
language and schema fingerprint. Returns True if the file is missing,
|
||||||
|
unparsable, or any value mismatches.
|
||||||
|
|
||||||
|
This deliberately ignores the rebuild marker: open_or_rebuild_index() uses it
|
||||||
|
so that a process opening the index while another process is mid-rebuild
|
||||||
|
(or after one died) does not wipe the partial index out from under it.
|
||||||
|
Repopulating is the job of ``document_index reindex``.
|
||||||
|
"""
|
||||||
settings_file = index_dir / ".index_settings.json"
|
settings_file = index_dir / ".index_settings.json"
|
||||||
if not settings_file.exists():
|
if not settings_file.exists():
|
||||||
return True
|
return True
|
||||||
@@ -333,7 +374,7 @@ def open_or_rebuild_index(index_dir: Path | None = None) -> tantivy.Index:
|
|||||||
index_dir = cast("Path", settings.INDEX_DIR)
|
index_dir = cast("Path", settings.INDEX_DIR)
|
||||||
if not index_dir.exists():
|
if not index_dir.exists():
|
||||||
return tantivy.Index(build_schema())
|
return tantivy.Index(build_schema())
|
||||||
if needs_rebuild(index_dir):
|
if _settings_mismatch(index_dir):
|
||||||
wipe_index(index_dir)
|
wipe_index(index_dir)
|
||||||
idx = tantivy.Index(build_schema(), path=str(index_dir))
|
idx = tantivy.Index(build_schema(), path=str(index_dir))
|
||||||
_write_sentinels(index_dir)
|
_write_sentinels(index_dir)
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ from documents.search._backend import TantivyBackend
|
|||||||
from documents.search._backend import WriteBatch
|
from documents.search._backend import WriteBatch
|
||||||
from documents.search._backend import get_backend
|
from documents.search._backend import get_backend
|
||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
|
from documents.search._schema import REBUILD_MARKER
|
||||||
|
from documents.search._schema import needs_rebuild
|
||||||
from documents.signals.handlers import add_to_index
|
from documents.signals.handlers import add_to_index
|
||||||
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
from paperless_testing.factories import CorrespondentFactory
|
from paperless_testing.factories import CorrespondentFactory
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import DocumentTypeFactory
|
from paperless_testing.factories import DocumentTypeFactory
|
||||||
@@ -823,6 +826,53 @@ class TestRebuild:
|
|||||||
backend.rebuild(Document.objects.all(), iter_wrapper=wrapper)
|
backend.rebuild(Document.objects.all(), iter_wrapper=wrapper)
|
||||||
assert 30 in seen
|
assert 30 in seen
|
||||||
|
|
||||||
|
def test_successful_rebuild_leaves_index_up_to_date(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
paperless_dirs: PaperlessDirs,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A backend and one document
|
||||||
|
WHEN:
|
||||||
|
- rebuild() completes
|
||||||
|
THEN:
|
||||||
|
- needs_rebuild() is False and no rebuild marker remains
|
||||||
|
"""
|
||||||
|
DocumentFactory.create()
|
||||||
|
|
||||||
|
backend.rebuild(Document.objects.all())
|
||||||
|
|
||||||
|
assert needs_rebuild(paperless_dirs.index_dir) is False
|
||||||
|
assert not (paperless_dirs.index_dir / REBUILD_MARKER).exists()
|
||||||
|
|
||||||
|
def test_interrupted_rebuild_is_retried(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
paperless_dirs: PaperlessDirs,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A rebuild that dies while indexing documents (e.g. the database
|
||||||
|
connection is lost)
|
||||||
|
WHEN:
|
||||||
|
- needs_rebuild() is checked afterwards
|
||||||
|
THEN:
|
||||||
|
- It is True, even though the empty index was already stamped with
|
||||||
|
current settings, so the next start rebuilds instead of reporting
|
||||||
|
the index as up to date
|
||||||
|
"""
|
||||||
|
DocumentFactory.create()
|
||||||
|
|
||||||
|
def die(pairs):
|
||||||
|
raise RuntimeError("terminating connection due to administrator command")
|
||||||
|
yield # pragma: no cover
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
backend.rebuild(Document.objects.all(), iter_wrapper=die)
|
||||||
|
|
||||||
|
assert needs_rebuild(paperless_dirs.index_dir) is True
|
||||||
|
|
||||||
def test_includes_group_granted_viewers(self, backend: TantivyBackend) -> None:
|
def test_includes_group_granted_viewers(self, backend: TantivyBackend) -> None:
|
||||||
"""Rebuild must index viewer ids for group-only grants, not just direct ones.
|
"""Rebuild must index viewer ids for group-only grants, not just direct ones.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-10-05 16:26+0000\n"
|
"POT-Creation-Date: 2026-10-06 15:12+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1941,25 +1941,8 @@ msgstr ""
|
|||||||
msgid "As a final step, please complete the following form:"
|
msgid "As a final step, please complete the following form:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/validators.py:24
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Unable to parse URI {value}, missing scheme"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: documents/validators.py:29
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Unable to parse URI {value}, missing net location or path"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: documents/validators.py:36
|
#: documents/validators.py:36
|
||||||
msgid ""
|
msgid ", "
|
||||||
"URI scheme '{parts.scheme}' is not allowed. Allowed schemes: {', '."
|
|
||||||
"join(allowed_schemes)}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: documents/validators.py:45
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Unable to parse URI {value}"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:336 documents/views.py:2729
|
#: documents/views.py:336 documents/views.py:2729
|
||||||
|
|||||||
@@ -137,9 +137,20 @@ class TestNginxService:
|
|||||||
reason="No Gotenberg/Tika servers to test with",
|
reason="No Gotenberg/Tika servers to test with",
|
||||||
)
|
)
|
||||||
class TestParserLive:
|
class TestParserLive:
|
||||||
@staticmethod
|
# Rasterizer versions shift a few pixels, so compare perceptual hashes by
|
||||||
def imagehash(file: Path, hash_size: int = 18) -> str:
|
# Hamming distance (out of 18 * 18 = 324 bits) rather than for equality
|
||||||
return f"{average_hash(Image.open(file), hash_size)}"
|
MAX_HASH_DISTANCE = 8
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def assert_thumbnails_similar(cls, generated: Path, expected: Path) -> None:
|
||||||
|
distance = average_hash(Image.open(generated), 18) - average_hash(
|
||||||
|
Image.open(expected),
|
||||||
|
18,
|
||||||
|
)
|
||||||
|
assert distance <= cls.MAX_HASH_DISTANCE, (
|
||||||
|
f"Thumbnail {generated} differs from {expected} by {distance} bits "
|
||||||
|
f"(max {cls.MAX_HASH_DISTANCE})"
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_thumbnail(
|
def test_get_thumbnail(
|
||||||
self,
|
self,
|
||||||
@@ -168,12 +179,7 @@ class TestParserLive:
|
|||||||
assert thumb.exists()
|
assert thumb.exists()
|
||||||
assert thumb.is_file()
|
assert thumb.is_file()
|
||||||
|
|
||||||
assert self.imagehash(thumb) == self.imagehash(
|
self.assert_thumbnails_similar(thumb, simple_txt_email_thumbnail_file)
|
||||||
simple_txt_email_thumbnail_file,
|
|
||||||
), (
|
|
||||||
f"Created thumbnail {thumb} differs from expected file "
|
|
||||||
f"{simple_txt_email_thumbnail_file}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_tika_parse_successful(self, mail_parser: MailDocumentParser) -> None:
|
def test_tika_parse_successful(self, mail_parser: MailDocumentParser) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -255,7 +261,7 @@ class TestParserLive:
|
|||||||
THEN:
|
THEN:
|
||||||
- Gotenberg shall be called to generate the PDF
|
- Gotenberg shall be called to generate the PDF
|
||||||
- The archive PDF shall contain the expected content
|
- The archive PDF shall contain the expected content
|
||||||
- The generated thumbnail shall match the expected image hash
|
- The generated thumbnail shall be perceptually close to the expected image
|
||||||
"""
|
"""
|
||||||
util_call_with_backoff(mail_parser.parse, [html_email_file, "message/rfc822"])
|
util_call_with_backoff(mail_parser.parse, [html_email_file, "message/rfc822"])
|
||||||
|
|
||||||
@@ -272,14 +278,4 @@ class TestParserLive:
|
|||||||
html_email_file,
|
html_email_file,
|
||||||
"message/rfc822",
|
"message/rfc822",
|
||||||
)
|
)
|
||||||
generated_thumbnail_hash = self.imagehash(generated_thumbnail)
|
self.assert_thumbnails_similar(generated_thumbnail, html_email_thumbnail_file)
|
||||||
|
|
||||||
# The created PDF is not reproducible, but the converted image
|
|
||||||
# should always look the same
|
|
||||||
expected_hash = self.imagehash(html_email_thumbnail_file)
|
|
||||||
|
|
||||||
assert generated_thumbnail_hash == expected_hash, (
|
|
||||||
f"PDF thumbnail differs from expected. "
|
|
||||||
f"Generated: {generated_thumbnail}, "
|
|
||||||
f"Hash: {generated_thumbnail_hash} vs {expected_hash}"
|
|
||||||
)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
__version__: Final[tuple[int, int, int]] = (3, 3, 0)
|
__version__: Final[tuple[int, int, int]] = (3, 2, 1)
|
||||||
# Version string like X.Y.Z
|
# Version string like X.Y.Z
|
||||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||||
# Version string like X.Y
|
# Version string like X.Y
|
||||||
|
|||||||
@@ -2971,7 +2971,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.3.0"
|
version = "3.2.1"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "azure-ai-documentintelligence" },
|
{ name = "azure-ai-documentintelligence" },
|
||||||
|
|||||||
Reference in new issue
Block a user