Compare commits

..
Author SHA1 Message Date
Crowdin Bot 4bf3e6ed91 New Crowdin translations by GitHub Action 2026-10-17 00:48:56 +00:00
Trenton H 0cb2cdbfdd Fix: handle single-class classifier in threshold prediction (#14471) (#14473)
scikit-learn treats a single-class fit as binary, so predict_proba returns
[1 - p, p] for one class and argmax could index past classes_. Use only
column 0, the real class, so the confidence threshold still applies.
2026-10-16 10:47:58 -07:00
GitHub Actions 1f3d451a3a Auto translate strings 2026-10-16 17:12:50 +00:00
Trenton H 183cfd2da4 Fix: keep counter and extension when shortening long public filenames (#14472)
get_public_filename relied on sanitize_filename to truncate, which cuts
from the end and so dropped the counter and extension of long titles.
Multi-byte titles could also exceed the 255 byte filesystem limit.

Shorten the title by UTF-8 bytes before appending the counter and
extension. The exporter reserves room for its longer derived thumbnail
and archive suffixes.
2026-10-16 10:11:34 -07:00
shamoon 30a24ee02e Chore: disable deprecated ts-jest isolation override 2026-10-15 22:51:55 -07:00
github-actions[bot]andCrowdin Bot 1943c99b27 New Crowdin translations by GitHub Action (#14449)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-15 22:51:00 -07:00
shamoon 8b26ecafb6 Fix: prevent ownerless docs leaking others in to LLM prompts 2026-10-15 22:42:34 -07:00
Trenton H d4a57204eb Fix: copy only direct user grants in DocumentMetadataOverrides.from_document (#14452)
get_users_with_perms includes group members by default, so PDF actions such
as merge and split gave them direct grants on the new documents, which
outlived their group membership.
2026-10-15 15:11:28 -07:00
dependabot[bot] c50c6f5517 Chore(deps): Bump filelock from 3.32.6 to 4.0.12 (#14336)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.32.6 to 4.0.12.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.32.6...4.0.12)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 16:11:12 -07:00
GitHub Actions 34a6c718fa Auto translate strings 2026-10-14 15:18:54 +00:00
shamoon 14aaa42777 Fix: support combined text/query searches (#14443) 2026-10-14 08:17:22 -07:00
shamoon c86c822bc7 Fix ai auto-close message 2026-10-13 12:15:28 -07:00
dependabot[bot] b23b118a49 Chore(deps): Bump the uv group across 1 directory with 3 updates (#14418)
Bumps the uv group with 3 updates in the / directory: [banks](https://github.com/masci/banks), [fsspec](https://github.com/fsspec/filesystem_spec) and [multidict](https://github.com/aio-libs/multidict).


Updates `banks` from 2.4.5 to 2.5.1
- [Release notes](https://github.com/masci/banks/releases)
- [Commits](https://github.com/masci/banks/compare/v2.4.5...v2.5.1)

Updates `fsspec` from 2026.1.0 to 2026.6.0
- [Commits](https://github.com/fsspec/filesystem_spec/compare/2026.1.0...2026.6.0)

Updates `multidict` from 6.7.1 to 6.9.1
- [Release notes](https://github.com/aio-libs/multidict/releases)
- [Changelog](https://github.com/aio-libs/multidict/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/multidict/compare/v6.7.1...v6.9.1)

---
updated-dependencies:
- dependency-name: banks
  dependency-version: 2.5.1
  dependency-type: indirect
- dependency-name: fsspec
  dependency-version: 2026.6.0
  dependency-type: indirect
- dependency-name: multidict
  dependency-version: 6.9.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 17:25:40 +00:00
Trenton H 6e717817c6 Fix: make exporter derived file names unique when originals differ only by extension (#14429) 2026-10-12 15:14:08 -07:00
shamoon 3e0fa73771 Chore: adjust issue auto-triage 2026-10-12 14:16:45 -07:00
github-actions[bot]andshamoon 2b00cf6a95 Documentation: Add v3.3.1 changelog (#14427)
* Changelog v3.3.1 - GHA

* Update changelog for paperless-ngx 3.3.1

Removed MARIADB_HOST documentation from MariaDB compose files.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-12 13:22:46 -07:00
shamoon c4d1b0c732 Bump version to 3.3.1 2026-10-12 13:01:03 -07:00
shamoon 333cbf0ae6 Merge branch 'dev' 2026-10-12 13:00:15 -07:00
github-actions[bot]andCrowdin Bot 49a298894e New Crowdin translations by GitHub Action (#14368)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-12 12:59:46 -07:00
GitHub Actions 551ebad6f1 Auto translate strings 2026-10-12 19:13:03 +00:00
Trenton H 4171ecf166 Chore: share the authorizing owner rule between the id sets and the trash filter (#14425)
The trash list needs to show a version to whoever owns its root document, but
it deliberately shows only owned and unowned documents and ignores explicit
shares, so it cannot use permitted_object_ids. #14384 gave it its own
filter_queryset with a hand-built condition through the root, which describes
the same rule as the annotations in permitted_object_ids a second time.

Move those annotations into annotate_authorizing_fields. PermittedObjectsFilter
gains a parent_field attribute that its owner-only and granted paths both use,
and the trash filter shrinks to setting include_granted and parent_field.
2026-10-12 12:11:42 -07:00
GitHub Actions 8289dd0bae Auto translate strings 2026-10-12 18:12:14 +00:00
Trenton H b195423f1d Fix: use a root document's effective content for the LLM index and pr… (#14423)
* Fix: use a root document's effective content for the LLM index and prompts

The search index and the document views use the newest version's content as a
root document's content, but everything the LLM side reads used the root's own
content: the text embedded in the LLM index, the content in the classification
prompt, the context blocks from similar documents, and the text that is
searched for similar documents. After a document was replaced by a new
version the LLM kept answering from the old text.

Read get_effective_content() in those four places. The index update annotates
the content in the query like the search index does, and the similar-document
lookup annotates it for the documents it fetches, so no query is made per
document. Entries already in the LLM index keep the old text until the
document next changes or the index is rebuilt.

* Chore: test the LLM query text and bulk annotation use effective content

* Removes the query count testing.  It's flakey from local to CI and workers, doesn't really add much imo
2026-10-12 11:10:50 -07:00
Trenton H 9f2f809131 Fix: index document versions as their root document (#14422)
* Fix: index document versions as their root document

The search index and the LLM index hold root documents only, a root being
indexed with its newest version's content. Every write path therefore had to
remember to hand them the root. Several did not.

WriteBatch.add_or_update, WriteBatch.add_or_update_ids and
llm_index_add_or_update_document now resolve a version to its root themselves.
The reindex command and update_llm_index only walk root documents, and an
incremental LLM update is scoped to the roots of the given ids through
versioning.root_document_ids, which add_or_update_ids shares. more_like_id
returns the root's id for a version. The reprocess task no longer picks the root
for the indexes and only still clears the caches of both documents.

* Fix: rebuild old search indexes and look versions up as their root for similar documents

An index built before versions were indexed as their root can still hold
version entries under their own id and metadata, and only a rebuild clears
them. Bump the schema version so the index is rebuilt on upgrade.

more_like_this_ids looked the given id up in the index as is, so asking for
documents like a version, as the AI suggestions endpoint can, silently found
nothing. It now looks the root up instead.
2026-10-12 11:10:50 -07:00
GitHub Actions 96535991a3 Auto translate strings 2026-10-12 17:18:18 +00:00
Trenton H c2deb69ac5 Fix: authorize document versions by their root in the single-object permission check (#14421)
* Fix: authorize document versions by their root in the single-object permission check

has_perms_owner_aware judged a document by its own owner and grants, so each
endpoint that fetches a document itself had to remember to map a version to
its root document first, and one that forgot, like the more-like-this search
filter, authorized by a stale version owner.

The check now maps a Document to its root before looking at the owner and the
guardian grants, matching what permitted_document_ids does for id sets. The
eight call sites that mapped the document themselves pass it straight through.
The DRF object permission class needs no change because the document viewset
only ever serves root documents.
2026-10-12 17:17:00 +00:00
GitHub Actions 070f614929 Auto translate strings 2026-10-12 17:06:18 +00:00
Trenton H 6f496f741b Chore: New helper for JSON parsing during settings, to provide a better error message to users (#14419) 2026-10-12 17:04:56 +00:00
Sven Lankes 1fb463b55c Chore: silence gotenberg startup warning (#14405) 2026-10-12 16:54:47 +00:00
shamoon 9a136ecb62 Chore: drop PR size labeling 2026-10-12 09:41:48 -07:00
shamoon 192ddbe45e Documentation: tweak demo hosting attribution 2026-10-12 09:18:19 -07:00
shamoon aac5e49034 Merge branch 'main' into dev 2026-10-12 09:13:04 -07:00
shamoon 716d310eb3 Documentation: credit Fyra Stack for hosting the demo (#14399) 2026-10-12 09:10:40 -07:00
dependabot[bot] 71d2edb055 Chore(deps): Bump the npm_and_yarn group across 1 directory with 2 updates (#14417)
Bumps the npm_and_yarn group with 2 updates in the /src/paperless_mail/templates directory: [source-map-js](https://github.com/7rulnik/source-map-js) and [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser).


Updates `source-map-js` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

Removes `postcss-selector-parser`

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss-selector-parser
  dependency-version:
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-12 06:52:11 -07:00
shamoon 973af89f61 Chore: update frontend TS libs to ES2022 2026-10-11 15:39:56 -07:00
GitHub Actions 937bf2fa7a Auto translate strings 2026-10-10 22:05:15 +00:00
Trenton HandClaude Sonnet 5.5 2f2381578c Fix: authorize document versions by their root and speed up permission id sets (#14396)
* Fix: authorize document versions by their root and speed up permission id sets

permitted_document_ids judged a version by its own owner and grants, so a
version whose owner had drifted from its root's was visible to the wrong
people and hidden from the right ones. Callers patched this individually by
mapping each document to its root first. The query itself was also slow on
MariaDB: the guardian grants were a UNION cast to integers and tested with
IN inside an OR with the owner checks, which MariaDB cannot materialize, so it
re-scans the user's grants for every document. At 20k documents that took
seconds for a user with a couple of hundred grants.

permitted_object_ids now casts the row key to a string, as guardian stores
object_pk, and tests it against a single uncorrelated UNION ALL of the user's
and groups' grants. No integer cast is needed and the user's groups are
matched with an IN subquery rather than a join through the membership table.
Postgres and SQLite build the grant set once, and MariaDB probes guardian's
unique indexes per row, which is cheap. A correlated EXISTS per grant also
fixed MariaDB but was up to 3x slower than before on Postgres and SQLite.

permitted_object_ids also takes an optional parent_field naming a
self-referencing foreign key whose target authorizes the row, and
permitted_document_ids passes root_document, so a version is visible exactly
when its root is. The helper that mapped documents to their roots at the call
sites is no longer needed, so the email, selection data, share link bundle,
trash, bulk download and bulk edit checks use the id set directly.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 15:03:52 -07:00
shamoon f8876c55e3 Documentation: remove MARIADB_HOST from MariaDB compose files (#14378) 2026-10-07 07:32:37 -07:00
Trenton H e5b215f9d3 Chore: Updates the troubleshooting with 2 common database collation things (#14370) 2026-10-06 11:02:42 -07:00
github-actions[bot]andshamoon 94f10b8622 Documentation: Add v3.3.0 changelog (#14362)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-05 21:14:19 -07:00
shamoon 6f7ed1d4a7 Bump version to 3.3.0 2026-10-05 20:34:24 -07:00
shamoon 0428bf6955 Merge branch 'dev' 2026-10-05 20:33:36 -07:00
shamoon c63afb47b2 Documentation: correct duplicates info (#14243) 2026-09-23 08:27:18 -07:00
143 changed files with 25122 additions and 25617 deletions

No files matched your search

+16
View File
@@ -39,6 +39,7 @@ ARG RUNTIME_PACKAGES="\
ghostscript \ ghostscript \
gnupg \ gnupg \
icc-profiles-free \ icc-profiles-free \
imagemagick \
# PostgreSQL # PostgreSQL
postgresql-client \ postgresql-client \
# MySQL / MariaDB # MySQL / MariaDB
@@ -92,6 +93,21 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
# setup docker-specific things
# These change sometimes, but rarely
WORKDIR /usr/src/paperless/src/docker/
COPY [ \
"docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
"./" \
]
RUN set -eux \
&& echo "Configuring ImageMagick" \
&& mkdir -p /etc/ImageMagick-6 \
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
# Packages needed only for building a few quick Python # Packages needed only for building a few quick Python
# dependencies # dependencies
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
@@ -66,7 +66,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
+11 -1
View File
@@ -31,9 +31,19 @@ body:
value: | value: |
#### 🤖 Use of AI tools #### 🤖 Use of AI tools
If this report was generated, in whole or in part, by an AI tool or agent you **must** clearly say so in the description below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md). If this report was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
Reports written by an AI agent must describe the _observed behavior only_: what happened, how to reproduce it, logs etc. Do **not** include code analysis, speculation about the root cause, or suggest code fixes. Reports written by an AI agent must describe the _observed behavior only_: what happened, how to reproduce it, logs etc. Do **not** include code analysis, speculation about the root cause, or suggest code fixes.
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Was any part of this report written by an AI tool or agent?
options:
- No AI tools were used
- Yes, AI tools helped write this report
validations:
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
+4 -1
View File
@@ -111,7 +111,10 @@ jobs:
timeout-minutes: 12 timeout-minutes: 12
uses: $/.github/actions/apt-install uses: $/.github/actions/apt-install
with: with:
packages: unpaper tesseract-ocr ghostscript poppler-utils qpdf packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
- name: Configure ImageMagick
run: |
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 }}
+24 -7
View File
@@ -43,6 +43,12 @@ jobs:
const haystack = `${issue.title}\n${issue.body ?? ''}`; const haystack = `${issue.title}\n${issue.body ?? ''}`;
const honeypot = haystack.toUpperCase().includes('ASLOP-PR-VERIFY'); const honeypot = haystack.toUpperCase().includes('ASLOP-PR-VERIFY');
// The form's required "AI usage" dropdown renders as a `### AI usage` section. An agent
// that follows the template trips the honeypot even when it discloses, so only treat
// the honeypot as undisclosed AI use when the reporter did not answer "Yes" here.
const aiUsage = (issue.body ?? '').match(/^###\s+AI usage\s*\n+\s*(.+?)\s*$/m)?.[1] ?? '';
const disclosedAI = aiUsage.toLowerCase().startsWith('yes');
// Issues opened through the form always get the template's default labels. GitHub // Issues opened through the form always get the template's default labels. GitHub
// applies them a second or two *after* creation, so re-read them instead of trusting // applies them a second or two *after* creation, so re-read them instead of trusting
// the webhook payload, and retry before concluding that there are none. // the webhook payload, and retry before concluding that there are none.
@@ -55,19 +61,30 @@ jobs:
if (labels.some((label) => templateLabels.includes(label))) break; if (labels.some((label) => templateLabels.includes(label))) break;
} }
const bypassedTemplate = !labels.some((label) => templateLabels.includes(label)); const bypassedTemplate = !labels.some((label) => templateLabels.includes(label));
core.info(`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, bypassed template: ${bypassedTemplate}`); core.info(
`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, AI usage: "${aiUsage}", ` +
`bypassed template: ${bypassedTemplate}`
);
if (!honeypot && !bypassedTemplate) { if (!bypassedTemplate && (disclosedAI || !honeypot)) {
core.info('No slop signals found'); if (disclosedAI) {
core.info('AI use disclosed, labeling only');
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
} else {
core.info('No slop signals found');
}
return; return;
} }
// The honeypot is only ever tripped deliberately, so that message can name the cause.
// A missing template label only tells us the issue did not come from the form, which // A missing template label only tells us the issue did not come from the form, which
// is reason enough to close it, but not proof of how it was written. // is reason enough to close it, but not proof of how it was written. It takes priority:
const body = honeypot // a hand-built body can carry the honeypot *and* a disclosure, and the form message is
// the only one that is accurate for both. Reaching the honeypot message means the issue
// came from the form without disclosing AI usage, so that message can name the cause.
const body = honeypot && !bypassedTemplate
? 'This issue was automatically closed because it contains a marker that is only visible to ' + ? 'This issue was automatically closed because it contains a marker that is only visible to ' +
'automated tools, which indicates it was generated by an AI agent without being disclosed as such.\n\n' + 'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
'disclose this.\n\n' +
`Please see our [contributing guidelines](${contributing}) and [Code of Conduct](${codeOfConduct}). ` + `Please see our [contributing guidelines](${contributing}) and [Code of Conduct](${codeOfConduct}). ` +
'You are welcome to open a new issue that describes the problem you observed in your own words.' 'You are welcome to open a new issue that describes the problem you observed in your own words.'
: 'This issue was automatically closed because it was not opened using our bug report form. ' + : 'This issue was automatically closed because it was not opened using our bug report form. ' +
-10
View File
@@ -38,16 +38,6 @@ jobs:
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size
uses: Gascon1/pr-size-labeler@deff8ed00a76639a7c0f197525bafa3350ba4c36 # v1.3.0
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_label: 'small-change'
xs_diff: '9'
s_label: 'non-trivial'
s_diff: '99999'
fail_if_xl: 'false'
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
- name: Label by PR title - name: Label by PR title
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
+4 -13
View File
@@ -43,26 +43,17 @@ Once you have submitted a **P**ull **R**equest it will be reviewed, approved, an
Important: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers. Important: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers.
## Non-Trivial Requests ## PR Review Process
PRs deemed `non-trivial` will go through a stricter review process before being merged into `dev`. This is to ensure code quality and complete functionality (free of side effects). Our community review process for PRs is the following:
Examples of `non-trivial` PRs might include:
- Additional features
- Large changes to many distinct files
- Breaking or deprecation of existing features
Our community review process for `non-trivial` PRs is the following:
1. Must pass usual automated code tests and formatting checks. 1. Must pass usual automated code tests and formatting checks.
2. The PR will be assigned and pinged to the appropriately experienced team (i.e. @paperless-ngx/backend for backend changes). 2. The PR will be assigned and pinged to the appropriately experienced team (i.e. @paperless-ngx/backend for backend changes).
3. Development team will check and test code manually (possibly over several days). 3. Development team will check and test code manually (possibly over several days).
- You may be asked to make changes or rebase. - You may be asked to make changes or rebase.
- The team may ask for additional testing done by @paperless-ngx/test 4. One or more members of the team will approve and finally merge the request into `dev` 🎉.
4. **At least two** members of the team will approve and finally merge the request into `dev` 🎉.
This process might be slow as community members have different schedules and time to dedicate to the Paperless project. However it ensures community code reviews are as brilliantly thorough as they once were with @jonaswinkler. This process might be slow as community members have different schedules and time to dedicate to the Paperless project. However it ensures community code reviews are thorough and of high quality.
# Use of AI Tools # Use of AI Tools
+3
View File
@@ -129,6 +129,7 @@ ARG RUNTIME_PACKAGES="\
ghostscript \ ghostscript \
gnupg \ gnupg \
icc-profiles-free \ icc-profiles-free \
imagemagick \
# PostgreSQL # PostgreSQL
postgresql-client \ postgresql-client \
# MySQL / MariaDB # MySQL / MariaDB
@@ -166,6 +167,8 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \ && echo "Installing jbig2enc" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Configuring imagemagick" \
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Cleaning up image layer" \ && echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \ && rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \ && rm --recursive --force --verbose /var/lib/apt/lists/* \
+4 -8
View File
@@ -21,7 +21,7 @@ Paperless-ngx is a document management system that transforms your physical docu
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support) Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._ Thanks to the generous folks at [Fyra Stack](https://fyrastack.com), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
- [Features](#features) - [Features](#features)
- [Getting started](#getting-started) - [Getting started](#getting-started)
@@ -33,13 +33,9 @@ Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462),
- [Related Projects](#related-projects) - [Related Projects](#related-projects)
- [Important Note](#important-note) - [Important Note](#important-note)
<p align="right">This project is supported by:<br/> <p align="right">Demo hosting provided by:<br/>
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;"> <a href="https://fyrastack.com">
<picture> <img src="docs/assets/sponsors/fyra-stack.svg" alt="" width="28px" align="absmiddle"> Fyra Stack
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="140px">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
</picture>
</a> </a>
</p> </p>
+1 -1
View File
@@ -14,7 +14,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
- "--log-level=warn" - "--log-level=warn"
- "--log-format=text" - "--log-format=text"
tika: tika:
@@ -40,7 +40,6 @@ services:
volumes: volumes:
- dbdata:/var/lib/mysql - dbdata:/var/lib/mysql
environment: environment:
MARIADB_HOST: paperless
MARIADB_DATABASE: paperless MARIADB_DATABASE: paperless
MARIADB_USER: paperless MARIADB_USER: paperless
MARIADB_PASSWORD: paperless MARIADB_PASSWORD: paperless
@@ -79,7 +78,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
@@ -36,7 +36,6 @@ services:
volumes: volumes:
- dbdata:/var/lib/mysql - dbdata:/var/lib/mysql
environment: environment:
MARIADB_HOST: paperless
MARIADB_DATABASE: paperless MARIADB_DATABASE: paperless
MARIADB_USER: paperless MARIADB_USER: paperless
MARIADB_PASSWORD: paperless MARIADB_PASSWORD: paperless
@@ -74,7 +74,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
@@ -63,7 +63,7 @@ services:
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-javascript=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=^file:///tmp/.*"
tika: tika:
image: docker.io/apache/tika:3.3.1.0 image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped restart: unless-stopped
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)+>
<!ATTLIST policymap xmlns CDATA #FIXED ''>
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
]>
<!--
Configure ImageMagick policies.
Domains include system, delegate, coder, filter, path, or resource.
Rights include none, read, write, execute and all. Use | to combine them,
for example: "read | write" to permit read from, or write to, a path.
Use a glob expression as a pattern.
Suppose we do not want users to process MPEG video images:
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
Here we do not want users reading images from HTTP:
<policy domain="coder" rights="none" pattern="HTTP" />
The /repository file system is restricted to read only. We use a glob
expression to match all paths that start with /repository:
<policy domain="path" rights="read" pattern="/repository/*" />
Lets prevent users from executing any image filters:
<policy domain="filter" rights="none" pattern="*" />
Any large image is cached to disk rather than memory:
<policy domain="resource" name="area" value="1GP"/>
Define arguments for the memory, map, area, width, height and disk resources
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
exceeds policy maximum so memory limit is 1GB).
Rules are processed in order. Here we want to restrict ImageMagick to only
read or write a small subset of proven web-safe image types:
<policy domain="delegate" rights="none" pattern="*" />
<policy domain="filter" rights="none" pattern="*" />
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
-->
<policymap>
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- <policy domain="system" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="map" value="512MiB"/>
<policy domain="resource" name="width" value="16KP"/>
<policy domain="resource" name="height" value="16KP"/>
<!-- <policy domain="resource" name="list-length" value="128"/> -->
<policy domain="resource" name="area" value="128MB"/>
<policy domain="resource" name="disk" value="1GiB"/>
<!-- <policy domain="resource" name="file" value="768"/> -->
<!-- <policy domain="resource" name="thread" value="4"/> -->
<!-- <policy domain="resource" name="throttle" value="0"/> -->
<!-- <policy domain="resource" name="time" value="3600"/> -->
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
<policy domain="coder" rights="none" pattern="SVG" />
<policy domain="coder" rights="none" pattern="SVGZ" />
<policy domain="coder" rights="none" pattern="MSVG" />
<policy domain="coder" rights="none" pattern="RSVG" />
<policy domain="coder" rights="none" pattern="MSL" />
<policy domain="coder" rights="none" pattern="MVG" />
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> -->
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- use curl -->
<policy domain="delegate" rights="none" pattern="URL" />
<policy domain="delegate" rights="none" pattern="HTTPS" />
<policy domain="delegate" rights="none" pattern="HTTP" />
<!-- in order to avoid to get image with password text -->
<policy domain="path" rights="none" pattern="@*"/>
<!-- disable ghostscript format types -->
<policy domain="coder" rights="none" pattern="PS" />
<policy domain="coder" rights="none" pattern="PS2" />
<policy domain="coder" rights="none" pattern="PS3" />
<policy domain="coder" rights="none" pattern="EPS" />
<policy domain="coder" rights="read|write" pattern="PDF" />
<policy domain="coder" rights="none" pattern="XPS" />
</policymap>
+29
View File
@@ -101,3 +101,32 @@ h4 code {
border-top: .05rem solid var(--md-default-fg-color--lightest); border-top: .05rem solid var(--md-default-fg-color--lightest);
} }
} }
.md-typeset .demo-sponsor {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .35rem;
margin-top: -1rem;
margin-bottom: 2rem;
color: var(--md-default-fg-color--light);
font-size: .65rem;
}
.md-typeset .demo-sponsor a {
display: inline-flex;
align-items: center;
gap: .25rem;
color: inherit;
font-weight: 600;
}
.md-typeset .demo-sponsor a:hover {
color: var(--md-accent-fg-color);
}
.md-typeset .demo-sponsor img {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="666.667" height="666.667" viewBox="0 0 666.667 666.667"><g stroke-width="1.333"><path fill="#fef400" d="m332.66668 418.66668h-84.66667v-85.33334-85.33333h84.66667 84.66667v-85.33334-85.333334h85.33333 85.33334v85.333334 85.33334h-85.33334-85.33333v85.33333 85.33334z"/><path fill="#fe6565" d="m248.00001 589.33335h-170.666674v-170.66667-170.66667h85.333334 85.33334v-85.33334-85.333334h84.66667 84.66667v85.333334 85.33334h85.33333 85.33334v85.33333 85.33334h-84.66667-84.66667v85.33334 85.33333zm84.66667-170.66667h84.66667v-85.33334-85.33333h-84.66667-84.66667v85.33333 85.33334z"/></g></svg>

After

Width:  |  Height:  |  Size: 641 B

+174
View File
@@ -1,5 +1,179 @@
# Changelog # Changelog
## paperless-ngx 3.3.1
### Bug Fixes
- Fix: use a root document's effective content for the LLM index and pr… [@stumpylog](https://github.com/stumpylog) ([#14423](https://github.com/paperless-ngx/paperless-ngx/pull/14423))
- Fix: index document versions as their root document [@stumpylog](https://github.com/stumpylog) ([#14422](https://github.com/paperless-ngx/paperless-ngx/pull/14422))
- Fix: authorize document versions by their root in the single-object permission check [@stumpylog](https://github.com/stumpylog) ([#14421](https://github.com/paperless-ngx/paperless-ngx/pull/14421))
- Fix: Provide better error messages to users for invalid JSON [@stumpylog](https://github.com/stumpylog) ([#14419](https://github.com/paperless-ngx/paperless-ngx/pull/14419))
- Fix: authorize document versions by their root and speed up permission id sets [@stumpylog](https://github.com/stumpylog) ([#14396](https://github.com/paperless-ngx/paperless-ngx/pull/14396))
- Chore: update the db options warning [@shamoon](https://github.com/shamoon) ([#14398](https://github.com/paperless-ngx/paperless-ngx/pull/14398))
- Fix: bulk reprocess latest version for root documents [@shamoon](https://github.com/shamoon) ([#14385](https://github.com/paperless-ngx/paperless-ngx/pull/14385))
- Fix: consistently use root doc for version action permissions [@shamoon](https://github.com/shamoon) ([#14384](https://github.com/paperless-ngx/paperless-ngx/pull/14384))
- Fix: Don't attempt to index created dates which are not representable in the FTS index [@stumpylog](https://github.com/stumpylog) ([#14390](https://github.com/paperless-ngx/paperless-ngx/pull/14390))
- Fix: retry a search index rebuild that was interrupted [@stumpylog](https://github.com/stumpylog) ([#14379](https://github.com/paperless-ngx/paperless-ngx/pull/14379))
### Documentation
- Chore: silence gotenberg startup warning [@slankes](https://github.com/slankes) ([#14405](https://github.com/paperless-ngx/paperless-ngx/pull/14405))
- Documentation: remove MARIADB\_HOST from MariaDB compose files [@shamoon](https://github.com/shamoon) ([#14378](https://github.com/paperless-ngx/paperless-ngx/pull/14378))
- Documentation: Updates the troubleshooting with 2 common database collation issues [@stumpylog](https://github.com/stumpylog) ([#14370](https://github.com/paperless-ngx/paperless-ngx/pull/14370))
### Maintenance
- Chore: Upgrade runners to Ubuntu 26.04 [@stumpylog](https://github.com/stumpylog) ([#14347](https://github.com/paperless-ngx/paperless-ngx/pull/14347))
### Dependencies
- Chore(deps): Bump the npm\_and\_yarn group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14417](https://github.com/paperless-ngx/paperless-ngx/pull/14417))
### All App Changes
<details>
<summary>12 changes</summary>
- Chore: share the authorizing owner rule between the id sets and the trash filter [@stumpylog](https://github.com/stumpylog) ([#14425](https://github.com/paperless-ngx/paperless-ngx/pull/14425))
- Fix: use a root document's effective content for the LLM index and pr… [@stumpylog](https://github.com/stumpylog) ([#14423](https://github.com/paperless-ngx/paperless-ngx/pull/14423))
- Fix: index document versions as their root document [@stumpylog](https://github.com/stumpylog) ([#14422](https://github.com/paperless-ngx/paperless-ngx/pull/14422))
- Fix: authorize document versions by their root in the single-object permission check [@stumpylog](https://github.com/stumpylog) ([#14421](https://github.com/paperless-ngx/paperless-ngx/pull/14421))
- Fix: Provide better error messages to users for invalid JSON [@stumpylog](https://github.com/stumpylog) ([#14419](https://github.com/paperless-ngx/paperless-ngx/pull/14419))
- Chore(deps): Bump the npm\_and\_yarn group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14417](https://github.com/paperless-ngx/paperless-ngx/pull/14417))
- Fix: authorize document versions by their root and speed up permission id sets [@stumpylog](https://github.com/stumpylog) ([#14396](https://github.com/paperless-ngx/paperless-ngx/pull/14396))
- Chore: update the db options warning [@shamoon](https://github.com/shamoon) ([#14398](https://github.com/paperless-ngx/paperless-ngx/pull/14398))
- Fix: bulk reprocess latest version for root documents [@shamoon](https://github.com/shamoon) ([#14385](https://github.com/paperless-ngx/paperless-ngx/pull/14385))
- Fix: consistently use root doc for version action permissions [@shamoon](https://github.com/shamoon) ([#14384](https://github.com/paperless-ngx/paperless-ngx/pull/14384))
- Fix: Don't attempt to index created dates which are not representable in the FTS index [@stumpylog](https://github.com/stumpylog) ([#14390](https://github.com/paperless-ngx/paperless-ngx/pull/14390))
- Fix: retry a search index rebuild that was interrupted [@stumpylog](https://github.com/stumpylog) ([#14379](https://github.com/paperless-ngx/paperless-ngx/pull/14379))
</details>
## paperless-ngx 3.3.0
### Features / Enhancements
- Enhancement: more control over suggestion requests [@shamoon](https://github.com/shamoon) ([#14258](https://github.com/paperless-ngx/paperless-ngx/pull/14258))
- Chorehancement: set manifest CORS for credentials [@shamoon](https://github.com/shamoon) ([#14307](https://github.com/paperless-ngx/paperless-ngx/pull/14307))
- Enhancement: include Django admin with 2FA [@shamoon](https://github.com/shamoon) ([#14270](https://github.com/paperless-ngx/paperless-ngx/pull/14270))
- Feature: propagate resolved secrets to interactive container shells [@stumpylog](https://github.com/stumpylog) ([#14254](https://github.com/paperless-ngx/paperless-ngx/pull/14254))
- Enhancement: support separate embedding API key [@furkanural](https://github.com/furkanural) ([#14067](https://github.com/paperless-ngx/paperless-ngx/pull/14067))
- Enhancement: support passthrough extra params for LLMs [@shamoon](https://github.com/shamoon) ([#14202](https://github.com/paperless-ngx/paperless-ngx/pull/14202))
- Feature: store barcode contents, list and search them [@jurassicparkicecream](https://github.com/jurassicparkicecream) ([#14276](https://github.com/paperless-ngx/paperless-ngx/pull/14276))
### Bug Fixes
- Fix: Set the ProcessedMail owner based on the rule owner in all cases [@stumpylog](https://github.com/stumpylog) ([#14356](https://github.com/paperless-ngx/paperless-ngx/pull/14356))
- Fix: Ensure log rotation settings are converted to integers [@stumpylog](https://github.com/stumpylog) ([#14343](https://github.com/paperless-ngx/paperless-ngx/pull/14343))
- Fix: Wrap apt calls into a retry so we can ideally jump a slow mirror [@stumpylog](https://github.com/stumpylog) ([#14344](https://github.com/paperless-ngx/paperless-ngx/pull/14344))
- Fix: ship pdf.js CMaps so CJK documents render in the viewer [@MrOggy85](https://github.com/MrOggy85) ([#14318](https://github.com/paperless-ngx/paperless-ngx/pull/14318))
- Fix: use version page\_count for versioned document [@shamoon](https://github.com/shamoon) ([#14280](https://github.com/paperless-ngx/paperless-ngx/pull/14280))
- Fix: allow pointer events for pdf links in pngx viewer [@shamoon](https://github.com/shamoon) ([#14264](https://github.com/paperless-ngx/paperless-ngx/pull/14264))
- Fix: During a move to the trash directory, only attempt to copy metadata [@stumpylog](https://github.com/stumpylog) ([#14250](https://github.com/paperless-ngx/paperless-ngx/pull/14250))
- Fix: convert file mtime to the configured time zone directly [@stumpylog](https://github.com/stumpylog) ([#14249](https://github.com/paperless-ngx/paperless-ngx/pull/14249))
- Fix: ensure documentDeleted subscription is discarded [@shamoon](https://github.com/shamoon) ([#14247](https://github.com/paperless-ngx/paperless-ngx/pull/14247))
- Chore: Fix bugs in the test suite [@stumpylog](https://github.com/stumpylog) ([#14244](https://github.com/paperless-ngx/paperless-ngx/pull/14244))
- Fix: ensure bulk operations are checked against version root [@shamoon](https://github.com/shamoon) ([#14246](https://github.com/paperless-ngx/paperless-ngx/pull/14246))
- Fix: indexing after document-added workflows signal [@shamoon](https://github.com/shamoon) ([#14242](https://github.com/paperless-ngx/paperless-ngx/pull/14242))
- Fix: Record full tag and custom field lists in bulk edit audit log [@stumpylog](https://github.com/stumpylog) ([#14236](https://github.com/paperless-ngx/paperless-ngx/pull/14236))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Fix: handle legacy bulk edit split page range with missing page\_count [@shamoon](https://github.com/shamoon) ([#14212](https://github.com/paperless-ngx/paperless-ngx/pull/14212))
- Fix: ignore invalid EXIF orientation when generating image archives [@zhzy0077](https://github.com/zhzy0077) ([#14203](https://github.com/paperless-ngx/paperless-ngx/pull/14203))
### Documentation
- Documentation: correct duplicates info [@shamoon](https://github.com/shamoon) ([#14243](https://github.com/paperless-ngx/paperless-ngx/pull/14243))
### Maintenance
- Chore(deps): Bump the actions group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14332](https://github.com/paperless-ngx/paperless-ngx/pull/14332))
- Fix: Wrap apt calls into a retry so we can ideally jump a slow mirror [@stumpylog](https://github.com/stumpylog) ([#14344](https://github.com/paperless-ngx/paperless-ngx/pull/14344))
- Chore(deps): Bump the actions group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14301](https://github.com/paperless-ngx/paperless-ngx/pull/14301))
### Dependencies
<details>
<summary>27 changes</summary>
- Chore(deps): Bump django-filter from 25.2 to 26.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14337](https://github.com/paperless-ngx/paperless-ngx/pull/14337))
- Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14338](https://github.com/paperless-ngx/paperless-ngx/pull/14338))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14351](https://github.com/paperless-ngx/paperless-ngx/pull/14351))
- Chore(deps-dev): Bump types-channels from 4.3.0.20260408 to 4.3.0.20260518 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14335](https://github.com/paperless-ngx/paperless-ngx/pull/14335))
- docker(deps): Bump astral-sh/uv from 0.12.20-python3.14-trixie-slim to 0.12.23-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14327](https://github.com/paperless-ngx/paperless-ngx/pull/14327))
- Chore(deps): Bump the actions group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14332](https://github.com/paperless-ngx/paperless-ngx/pull/14332))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14325](https://github.com/paperless-ngx/paperless-ngx/pull/14325))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14329](https://github.com/paperless-ngx/paperless-ngx/pull/14329))
- Chore(deps-dev): Bump prettier from 3.9.8 to 3.9.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14331](https://github.com/paperless-ngx/paperless-ngx/pull/14331))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14330](https://github.com/paperless-ngx/paperless-ngx/pull/14330))
- Chore(deps-dev): Bump zensical from 0.0.64 to 0.0.65 in the development group @[dependabot[bot]](https://github.com/apps/dependabot) ([#14326](https://github.com/paperless-ngx/paperless-ngx/pull/14326))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14314](https://github.com/paperless-ngx/paperless-ngx/pull/14314))
- Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14305](https://github.com/paperless-ngx/paperless-ngx/pull/14305))
- docker-compose(deps): bump greenmail/standalone from 2.1.13 to 2.1.14 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14281](https://github.com/paperless-ngx/paperless-ngx/pull/14281))
- docker(deps): Bump astral-sh/uv from 0.12.16-python3.14-trixie-slim to 0.12.20-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14282](https://github.com/paperless-ngx/paperless-ngx/pull/14282))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14283](https://github.com/paperless-ngx/paperless-ngx/pull/14283))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14297](https://github.com/paperless-ngx/paperless-ngx/pull/14297))
- Chore(deps): Bump the actions group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14301](https://github.com/paperless-ngx/paperless-ngx/pull/14301))
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14286](https://github.com/paperless-ngx/paperless-ngx/pull/14286))
- Chore(deps-dev): Bump eslint from 10.10.0 to 10.11.0 in /src-ui in the frontend-eslint-dependencies group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14287](https://github.com/paperless-ngx/paperless-ngx/pull/14287))
- Chore(deps-dev): Bump @types/node from 26.5.0 to 26.6.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14288](https://github.com/paperless-ngx/paperless-ngx/pull/14288))
- Chore(deps-dev): Bump prettier from 3.9.6 to 3.9.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14289](https://github.com/paperless-ngx/paperless-ngx/pull/14289))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14285](https://github.com/paperless-ngx/paperless-ngx/pull/14285))
- Chore: replace bleach with turbohtml [@gaborbernat](https://github.com/gaborbernat) ([#14269](https://github.com/paperless-ngx/paperless-ngx/pull/14269))
- Chore(deps): Bump autobahn from 25.12.2 to 26.7.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14231](https://github.com/paperless-ngx/paperless-ngx/pull/14231))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14133](https://github.com/paperless-ngx/paperless-ngx/pull/14133))
</details>
### All App Changes
<details>
<summary>41 changes</summary>
- Feature: store barcode contents, list and search them [@jurassicparkicecream](https://github.com/jurassicparkicecream) ([#14276](https://github.com/paperless-ngx/paperless-ngx/pull/14276))
- Fix: Set the ProcessedMail owner based on the rule owner in all cases [@stumpylog](https://github.com/stumpylog) ([#14356](https://github.com/paperless-ngx/paperless-ngx/pull/14356))
- Chore(deps): Bump django-filter from 25.2 to 26.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14337](https://github.com/paperless-ngx/paperless-ngx/pull/14337))
- Chore(deps): Bump the utilities-patch group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14338](https://github.com/paperless-ngx/paperless-ngx/pull/14338))
- Chore(deps-dev): Bump types-channels from 4.3.0.20260408 to 4.3.0.20260518 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14335](https://github.com/paperless-ngx/paperless-ngx/pull/14335))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14325](https://github.com/paperless-ngx/paperless-ngx/pull/14325))
- Fix: Ensure log rotation settings are converted to integers [@stumpylog](https://github.com/stumpylog) ([#14343](https://github.com/paperless-ngx/paperless-ngx/pull/14343))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14329](https://github.com/paperless-ngx/paperless-ngx/pull/14329))
- Chore(deps-dev): Bump prettier from 3.9.8 to 3.9.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14331](https://github.com/paperless-ngx/paperless-ngx/pull/14331))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14330](https://github.com/paperless-ngx/paperless-ngx/pull/14330))
- Fix: ship pdf.js CMaps so CJK documents render in the viewer [@MrOggy85](https://github.com/MrOggy85) ([#14318](https://github.com/paperless-ngx/paperless-ngx/pull/14318))
- Chore(deps-dev): Bump zensical from 0.0.64 to 0.0.65 in the development group @[dependabot[bot]](https://github.com/apps/dependabot) ([#14326](https://github.com/paperless-ngx/paperless-ngx/pull/14326))
- Enhancement: more control over suggestion requests [@shamoon](https://github.com/shamoon) ([#14258](https://github.com/paperless-ngx/paperless-ngx/pull/14258))
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14314](https://github.com/paperless-ngx/paperless-ngx/pull/14314))
- Chore: anchor admin url pattern [@shamoon](https://github.com/shamoon) ([#14316](https://github.com/paperless-ngx/paperless-ngx/pull/14316))
- Chorehancement: set manifest CORS for credentials [@shamoon](https://github.com/shamoon) ([#14307](https://github.com/paperless-ngx/paperless-ngx/pull/14307))
- Chore(deps): Bump the utilities-minor group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14305](https://github.com/paperless-ngx/paperless-ngx/pull/14305))
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14297](https://github.com/paperless-ngx/paperless-ngx/pull/14297))
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14286](https://github.com/paperless-ngx/paperless-ngx/pull/14286))
- Chore(deps-dev): Bump eslint from 10.10.0 to 10.11.0 in /src-ui in the frontend-eslint-dependencies group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14287](https://github.com/paperless-ngx/paperless-ngx/pull/14287))
- Chore(deps-dev): Bump @types/node from 26.5.0 to 26.6.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14288](https://github.com/paperless-ngx/paperless-ngx/pull/14288))
- Chore(deps-dev): Bump prettier from 3.9.6 to 3.9.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14289](https://github.com/paperless-ngx/paperless-ngx/pull/14289))
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14285](https://github.com/paperless-ngx/paperless-ngx/pull/14285))
- Fix: use version page\_count for versioned document [@shamoon](https://github.com/shamoon) ([#14280](https://github.com/paperless-ngx/paperless-ngx/pull/14280))
- Chore: replace bleach with turbohtml [@gaborbernat](https://github.com/gaborbernat) ([#14269](https://github.com/paperless-ngx/paperless-ngx/pull/14269))
- Enhancement: include Django admin with 2FA [@shamoon](https://github.com/shamoon) ([#14270](https://github.com/paperless-ngx/paperless-ngx/pull/14270))
- Fix: allow pointer events for pdf links in pngx viewer [@shamoon](https://github.com/shamoon) ([#14264](https://github.com/paperless-ngx/paperless-ngx/pull/14264))
- Feature: propagate resolved secrets to interactive container shells [@stumpylog](https://github.com/stumpylog) ([#14254](https://github.com/paperless-ngx/paperless-ngx/pull/14254))
- Fix: During a move to the trash directory, only attempt to copy metadata [@stumpylog](https://github.com/stumpylog) ([#14250](https://github.com/paperless-ngx/paperless-ngx/pull/14250))
- Fix: convert file mtime to the configured time zone directly [@stumpylog](https://github.com/stumpylog) ([#14249](https://github.com/paperless-ngx/paperless-ngx/pull/14249))
- Fix: ensure documentDeleted subscription is discarded [@shamoon](https://github.com/shamoon) ([#14247](https://github.com/paperless-ngx/paperless-ngx/pull/14247))
- Chore: Fix bugs in the test suite [@stumpylog](https://github.com/stumpylog) ([#14244](https://github.com/paperless-ngx/paperless-ngx/pull/14244))
- Fix: ensure bulk operations are checked against version root [@shamoon](https://github.com/shamoon) ([#14246](https://github.com/paperless-ngx/paperless-ngx/pull/14246))
- Enhancement: support separate embedding API key [@furkanural](https://github.com/furkanural) ([#14067](https://github.com/paperless-ngx/paperless-ngx/pull/14067))
- Enhancement: support passthrough extra params for LLMs [@shamoon](https://github.com/shamoon) ([#14202](https://github.com/paperless-ngx/paperless-ngx/pull/14202))
- Chore(deps): Bump autobahn from 25.12.2 to 26.7.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#14231](https://github.com/paperless-ngx/paperless-ngx/pull/14231))
- Fix: indexing after document-added workflows signal [@shamoon](https://github.com/shamoon) ([#14242](https://github.com/paperless-ngx/paperless-ngx/pull/14242))
- Fix: Record full tag and custom field lists in bulk edit audit log [@stumpylog](https://github.com/stumpylog) ([#14236](https://github.com/paperless-ngx/paperless-ngx/pull/14236))
- Chore: update pikepdf for ocrmypdf requirement [@shamoon](https://github.com/shamoon) ([#14235](https://github.com/paperless-ngx/paperless-ngx/pull/14235))
- Fix: handle legacy bulk edit split page range with missing page\_count [@shamoon](https://github.com/shamoon) ([#14212](https://github.com/paperless-ngx/paperless-ngx/pull/14212))
- Fix: ignore invalid EXIF orientation when generating image archives [@zhzy0077](https://github.com/zhzy0077) ([#14203](https://github.com/paperless-ngx/paperless-ngx/pull/14203))
</details>
## paperless-ngx 3.2.1 ## paperless-ngx 3.2.1
### Bug Fixes ### Bug Fixes
+19 -10
View File
@@ -1315,17 +1315,29 @@ valid crontab(5) expression describing when to run.
#### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT} #### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT}
!!! warning : On smaller systems, or even in the case of Very Large Documents, the
consumer may explode, complaining about how it's "unable to extend
pixel cache". In such cases, try setting this to a reasonably low
value, like 32. The default is to use whatever is necessary to do
everything without writing to disk, and units are in megabytes.
Deprecated and has no effect, since PDF thumbnails no longer use For more information on how to use this value, you should search the
ImageMagick. It will be removed in a future release. web for "MAGICK_MEMORY_LIMIT".
Defaults to 0, which disables the limit.
#### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR} #### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR}
!!! warning : Similar to the memory limit, if you've got a small system and your
OS mounts /tmp as tmpfs, you should set this to a path that's on a
physical disk, like /home/your_user/tmp or something. ImageMagick
will use this as scratch space when crunching through very large
documents.
Deprecated and has no effect, since PDF thumbnails no longer use For more information on how to use this value, you should search the
ImageMagick. It will be removed in a future release. web for "MAGICK_TMPDIR".
Default is none, which disables the temporary directory.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS} #### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
@@ -1873,10 +1885,7 @@ the literal path for that program.
#### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY} #### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
!!! warning : Defaults to "convert".
Deprecated and has no effect, since Paperless-ngx no longer uses
ImageMagick. It will be removed in a future release.
#### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY} #### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
+3 -1
View File
@@ -76,7 +76,9 @@ 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. If you prefer the old **Duplicates** tab to review documents that share the same content, or filter the document
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`.
+4 -4
View File
@@ -12,10 +12,10 @@ physical documents into a searchable online archive so you can keep, well, _less
[Get started](setup.md){ .md-button .md-button--primary .index-callout } [Get started](setup.md){ .md-button .md-button--primary .index-callout }
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank } [Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
<div style="display: flex; justify-content: end; margin-top: -1.5rem;"> <div class="demo-sponsor">
<a href="https://m.do.co/c/8d70b916d462" target="_blank"> <span>Demo hosting by</span>
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px"> <a href="https://fyrastack.com" target="_blank" rel="noopener">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px"> <img src="assets/sponsors/fyra-stack.svg" alt="" class="no-lightbox" width="20" height="20"> Fyra Stack
</a> </a>
</div> </div>
+13 -2
View File
@@ -177,16 +177,17 @@ to a positive number to enable polling and disable native filesystem notificatio
- `pkg-config` for mysqlclient (python dependency) - `pkg-config` for mysqlclient (python dependency)
- `fonts-liberation` for generating thumbnails for plain text - `fonts-liberation` for generating thumbnails for plain text
files files
- `imagemagick` >= 6 for PDF conversion
- `gnupg` for decrypting GPG-encrypted email - `gnupg` for decrypting GPG-encrypted email
- `libpq-dev` for PostgreSQL - `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection - `libmagic-dev` for mime type detection
- `mariadb-client` for MariaDB compile time - `mariadb-client` for MariaDB compile time
- `poppler-utils` for thumbnail generation and barcode detection - `poppler-utils` for barcode detection
Use this list for your preferred package management: Use this list for your preferred package management:
``` ```
python3 python3-pip python3-dev fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
``` ```
These dependencies are required for OCRmyPDF, which is used for text These dependencies are required for OCRmyPDF, which is used for text
@@ -415,6 +416,16 @@ to a positive number to enable polling and disable native filesystem notificatio
You may need to change the path in the files. Example: You may need to change the path in the files. Example:
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO` `ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
12. Configure ImageMagick to allow processing of PDF documents and disable
formats that Paperless-ngx does not use. Most distributions disable PDF
processing by default, since PDF documents can contain malware. If you
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
steps such as thumbnail generation.
Configure the active ImageMagick policy file (commonly
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.** **Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
software has been patented until around 2017 and binary packages are not available for most distributions. software has been patented until around 2017 and binary packages are not available for most distributions.
+60 -1
View File
@@ -129,7 +129,7 @@ change in the `docker-compose.yml` file:
command: command:
- 'gotenberg' - 'gotenberg'
- '--chromium-disable-javascript=true' - '--chromium-disable-javascript=true'
- '--chromium-allow-list=file:///tmp/.*' - '--chromium-allow-list=^file:///tmp/.*'
- '--api-timeout=60s' - '--api-timeout=60s'
``` ```
@@ -272,6 +272,65 @@ This error can occur in installations which have upgraded from a version of Pape
$ python3 manage.py convert_mariadb_uuid $ python3 manage.py convert_mariadb_uuid
``` ```
## MariaDB/MySQL error "Illegal mix of collations"
Consumption or other operations fail with an error like:
```
(1267, "Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and
(utf8mb4_unicode_ci,IMPLICIT) for operation '='")
```
This happens when the tables in your database do not all use the same
collation. It is most often seen on databases that existed before a
MariaDB/MySQL upgrade: older tables keep their original collation, while
tables created afterwards use the new server default.
To work around it, set the collation your existing tables use (the one in the error
that is not `utf8mb4_unicode_ci`) with
[`PAPERLESS_DB_OPTIONS`](configuration.md#PAPERLESS_DB_OPTIONS):
```bash
PAPERLESS_DB_OPTIONS="collation=utf8mb4_general_ci"
```
To fix it permanently, back up your database, then convert the database and
each table to a single collation and remove the override:
```sql
ALTER DATABASE paperless CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE <table_name> CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
## PostgreSQL warns about a "collation version mismatch"
The PostgreSQL log shows a warning like:
```
WARNING: database "paperless" has a collation version mismatch
DETAIL: The database was created using collation version 2.36, but the operating system provides version 2.41.
HINT: Rebuild all objects in this database that use the default collation and run ALTER DATABASE paperless REFRESH COLLATION VERSION, or build PostgreSQL with the right library version.
```
This comes from PostgreSQL, not Paperless-ngx. The `glibc` version that PostgreSQL
runs against changed, and the existing database was created with an older one. With
Docker, `glibc` comes from the PostgreSQL image's Debian base, not the host, so this
commonly happens when a new image is pulled after its base Debian release changed.
If PostgreSQL is installed directly on a host, it uses the host's `glibc` instead.
The warning is not an error and Paperless-ngx keeps working, but the database's text
indexes may be built with outdated sorting rules. To resolve it, back up your
database, then connect to it (for example with `psql -U paperless -d paperless`
inside the database container) and run:
```sql
REINDEX DATABASE paperless;
ALTER DATABASE paperless REFRESH COLLATION VERSION;
```
To avoid this in the future, pin your PostgreSQL image to a specific Debian release,
for example `postgres:18-trixie`, rather than `postgres:18`.
## Platform-Specific Deployment Troubleshooting ## Platform-Specific Deployment Troubleshooting
A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting). A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting).
+7 -8
View File
@@ -299,19 +299,18 @@ 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 exactly match an existing document (same checksum), the new copy is still contents match an existing document (same original or archive checksum), the new copy is
consumed and a warning is logged. The task entry for the upload also flags that a still consumed and a warning is logged.
duplicate was detected and links to the existing document(s).
To review duplicates, open a document and switch to the **Duplicates** tab on the When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
document detail page. It lists other documents that share the same content, including any the other documents you can view that share the same content (including any in the trash).
that are in the trash (shown with a badge), and links to each so you can decide which to To find all documents with duplicates, choose **Duplicates** in the document list's text
keep. filter dropdown, or use `has_duplicates=true` in the REST API.
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. a "Document already exists" message linking to the existing document.
## Document Suggestions ## Document Suggestions
+3
View File
@@ -50,6 +50,8 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_OCR_ROTATE_PAGES=true #PAPERLESS_OCR_ROTATE_PAGES=true
#PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0 #PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0
#PAPERLESS_OCR_USER_ARGS={} #PAPERLESS_OCR_USER_ARGS={}
#PAPERLESS_CONVERT_MEMORY_LIMIT=0
#PAPERLESS_CONVERT_TMPDIR=/var/tmp/paperless
# Software tweaks # Software tweaks
@@ -88,4 +90,5 @@ PAPERLESS_SECRET_KEY=change-me
# Binaries # Binaries
#PAPERLESS_CONVERT_BINARY=/usr/bin/convert
#PAPERLESS_GS_BINARY=/usr/bin/gs #PAPERLESS_GS_BINARY=/usr/bin/gs
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.2.1" version = "3.3.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\
""" """
@@ -43,7 +43,7 @@ dependencies = [
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10", "drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock>=3.32,<4.1",
"flower>=2.0.1,<2.3", "flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
+1 -1
View File
@@ -2,5 +2,5 @@
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15 docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
docker run -d -p 6379:6379 redis:latest docker run -d -p 6379:6379 redis:latest
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*" docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="^file:///tmp/.*"
docker run -p 9998:9998 -d docker.io/apache/tika:latest docker run -p 9998:9998 -d docker.io/apache/tika:latest
+2
View File
@@ -15,6 +15,8 @@ module.exports = {
tsconfig: '<rootDir>/tsconfig.spec.json', tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$', stringifyContentPathRegex: '\\.(html|svg)$',
useESM: true, useESM: true,
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
isolatedModules: false,
}, },
], ],
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.2.1", "version": "3.3.1",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -484,6 +484,22 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toBeNull() expect(component.textFilter).toBeNull()
}) })
it('should ingest title filter rules alongside a fulltext query with date created', () => {
component.filterRules = [
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'foo',
},
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'created:[-1 week to now]',
},
]
expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
expect(component.textFilter).toEqual('foo')
expect(component.textFilterTarget).toEqual('title') // TEXT_FILTER_TARGET_TITLE
})
it('should ingest text filter rules for fulltext query that include date added', () => { it('should ingest text filter rules for fulltext query that include date added', () => {
expect(component.dateAddedRelativeDate).toBeNull() expect(component.dateAddedRelativeDate).toBeNull()
component.filterRules = [ component.filterRules = [
@@ -1845,7 +1861,7 @@ describe('FilterEditorComponent', () => {
]) ])
}) })
it('should carry over text filtering on date created with relative date', () => { it('should keep text filtering separate from date created relative date', () => {
component.textFilter = 'foo' component.textFilter = 'foo'
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
@@ -1855,14 +1871,18 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'foo',
},
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]', value: 'created:[-1 week to now]',
}, },
]) ])
}) })
it('should carry over text filtering once with created and added relative dates', () => { it('should keep text filtering separate from created and added relative dates', () => {
component.textFilter = 'foo' component.textFilter = 'foo'
const datesDropdown = fixture.debugElement.query( const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
@@ -1873,14 +1893,18 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'foo',
},
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now],added:[-1 month to now]', value: 'created:[-1 week to now],added:[-1 month to now]',
}, },
]) ])
}) })
it('should convert legacy title filters into full text query when adding a created relative date', () => { it('should keep legacy title filters as a title search when adding a created relative date', () => {
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_TITLE, rule_type: FILTER_TITLE,
@@ -1895,14 +1919,18 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'foo',
},
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]', value: 'created:[-1 week to now]',
}, },
]) ])
}) })
it('should convert simple title filters into full text query when adding a created relative date', () => { it('should keep simple title filters as a title search when adding a created relative date', () => {
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_SIMPLE_TITLE, rule_type: FILTER_SIMPLE_TITLE,
@@ -1917,9 +1945,13 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TITLE,
value: 'foo',
},
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]', value: 'created:[-1 week to now]',
}, },
]) ])
}) })
@@ -2005,7 +2037,7 @@ describe('FilterEditorComponent', () => {
]) ])
}) })
it('should carry over text filtering on date added with relative date', () => { it('should keep text filtering separate from date added relative date', () => {
component.textFilter = 'foo' component.textFilter = 'foo'
const datesDropdown = fixture.debugElement.query( const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
@@ -2015,9 +2047,13 @@ describe('FilterEditorComponent', () => {
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{
rule_type: FILTER_SIMPLE_TEXT,
value: 'foo',
},
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,added:[-1 week to now]', value: 'added:[-1 week to now]',
}, },
]) ])
}) })
@@ -1083,23 +1083,6 @@ export class FilterEditorComponent
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY (fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
) )
// if had a title / content search and added a relative date we need to carry it over...
if (
!existingRule &&
this._textFilter?.length > 0 &&
(this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT ||
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE)
) {
existingRule = filterRules.find(
(fr) =>
fr.rule_type == FILTER_TITLE_CONTENT ||
fr.rule_type == FILTER_SIMPLE_TEXT ||
fr.rule_type == FILTER_TITLE ||
fr.rule_type == FILTER_SIMPLE_TITLE
)
existingRule.rule_type = FILTER_FULLTEXT_QUERY
}
let queryArgs = existingRule?.value.split(',') ?? [] let queryArgs = existingRule?.value.split(',') ?? []
if (this.dateCreatedRelativeDate !== null) { if (this.dateCreatedRelativeDate !== null) {
const rd = RELATIVE_DATE_QUERYSTRINGS.find( const rd = RELATIVE_DATE_QUERYSTRINGS.find(
+1 -1
View File
@@ -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.2.1', version: '3.3.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/',
+1 -1
View File
@@ -8525,7 +8525,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">54</context>
</context-group> </context-group>
<target state="needs-translation">{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</target> <target state="translated">{VAR_PLURAL, plural, =1 {1 Vorschlag unten verfügbar} other {<x id="INTERPOLATION"/> Vorschläge unten verfügbar}}</target>
</trans-unit> </trans-unit>
<trans-unit id="4169875112244042673" datatype="html" approved="yes"> <trans-unit id="4169875112244042673" datatype="html" approved="yes">
<source>Suggestion options</source> <source>Suggestion options</source>
+4 -4
View File
@@ -8525,7 +8525,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">54</context>
</context-group> </context-group>
<target state="needs-translation">{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</target> <target state="translated">{VAR_PLURAL, plural, one {<x id="INTERPOLATION"/> suggerimenti disponibili sotto} =1 {1 suggerimento disponibile sotto} other {<x id="INTERPOLATION"/> suggerimenti disponibili sotto}}</target>
</trans-unit> </trans-unit>
<trans-unit id="4169875112244042673" datatype="html"> <trans-unit id="4169875112244042673" datatype="html">
<source>Suggestion options</source> <source>Suggestion options</source>
@@ -9219,7 +9219,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="needs-translation">Copy content</target> <target state="translated">Copia contenuto</target>
</trans-unit> </trans-unit>
<trans-unit id="2266163016683537825" datatype="html"> <trans-unit id="2266163016683537825" datatype="html">
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source> <source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source>
@@ -9479,7 +9479,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">360,361</context> <context context-type="linenumber">360,361</context>
</context-group> </context-group>
<target state="needs-translation">Barcodes</target> <target state="translated">Codici a barre</target>
</trans-unit> </trans-unit>
<trans-unit id="6992781481378431874" datatype="html" approved="yes"> <trans-unit id="6992781481378431874" datatype="html" approved="yes">
<source>Original document metadata</source> <source>Original document metadata</source>
@@ -13376,7 +13376,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">335</context> <context context-type="linenumber">335</context>
</context-group> </context-group>
<target state="needs-translation">Store Barcode Contents</target> <target state="translated">Contenuti Codice a Barre</target>
</trans-unit> </trans-unit>
<trans-unit id="7011909364081812031" datatype="html"> <trans-unit id="7011909364081812031" datatype="html">
<source>AI Enabled</source> <source>AI Enabled</source>
+46 -46
View File
@@ -17,7 +17,7 @@
<context context-type="linenumber">386,393</context> <context context-type="linenumber">386,393</context>
</context-group> </context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note> <note priority="1" from="description">Currently selected slide number read by screen reader</note>
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target> <target state="translated"> スライド <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> / <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.previous" datatype="html"> <trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source> <source>Previous</source>
@@ -479,7 +479,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">201</context> <context context-type="linenumber">201</context>
</context-group> </context-group>
<target state="needs-translation">Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.</target> <target state="translated">タグ、取引先、ドキュメントの種類、保存先パス、カスタムフィールドなどの属性をここで管理できます。これらはドキュメントの編集画面から作成することもできます。</target>
</trans-unit> </trans-unit>
<trans-unit id="7851939076947092983" datatype="html"> <trans-unit id="7851939076947092983" datatype="html">
<source>Manage e-mail accounts and rules for automatically importing documents.</source> <source>Manage e-mail accounts and rules for automatically importing documents.</source>
@@ -507,7 +507,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">225</context> <context context-type="linenumber">225</context>
</context-group> </context-group>
<target state="needs-translation">Tasks helps you track background work, what needs attention, and what recently completed.</target> <target state="translated">タスクでは、バックグラウンド処理の進行状況、対応が必要な項目、最近完了した処理を確認できます。</target>
</trans-unit> </trans-unit>
<trans-unit id="2755033621682206902" datatype="html"> <trans-unit id="2755033621682206902" datatype="html">
<source>Check out the settings for various tweaks to the web app.</source> <source>Check out the settings for various tweaks to the web app.</source>
@@ -571,7 +571,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">35</context> <context context-type="linenumber">35</context>
</context-group> </context-group>
<target state="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target> <target state="translated">この値は、Paperless の外で設定されている <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>を上書きします。</target>
</trans-unit> </trans-unit>
<trans-unit id="7221396516204435584" datatype="html"> <trans-unit id="7221396516204435584" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source> <source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
@@ -579,7 +579,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">37</context> <context context-type="linenumber">37</context>
</context-group> </context-group>
<target state="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> はPaperlessの外部で設定されています。ここに値を入力すると、その設定を上書きできます。</target>
</trans-unit> </trans-unit>
<trans-unit id="8318849619178340389" datatype="html"> <trans-unit id="8318849619178340389" datatype="html">
<source>Use the externally configured value</source> <source>Use the externally configured value</source>
@@ -587,7 +587,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">42</context>
</context-group> </context-group>
<target state="needs-translation">Use the externally configured value</target> <target state="translated">外部で設定された値を使用</target>
</trans-unit> </trans-unit>
<trans-unit id="6032629623003430385" datatype="html"> <trans-unit id="6032629623003430385" datatype="html">
<source>Reset to external</source> <source>Reset to external</source>
@@ -595,7 +595,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context> <context context-type="linenumber">43</context>
</context-group> </context-group>
<target state="needs-translation">Reset to external</target> <target state="translated">外部設定に戻す</target>
</trans-unit> </trans-unit>
<trans-unit id="7808756054397155068" datatype="html"> <trans-unit id="7808756054397155068" datatype="html">
<source>Reset</source> <source>Reset</source>
@@ -875,7 +875,7 @@
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context> <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">18,19</context> <context context-type="linenumber">18,19</context>
</context-group> </context-group>
<target state="needs-translation">lines</target> <target state="translated">行</target>
</trans-unit> </trans-unit>
<trans-unit id="8838884664569764142" datatype="html"> <trans-unit id="8838884664569764142" datatype="html">
<source>Auto refresh</source> <source>Auto refresh</source>
@@ -1003,7 +1003,7 @@
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context> <context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">63,64</context> <context context-type="linenumber">63,64</context>
</context-group> </context-group>
<target state="needs-translation">Jump to bottom</target> <target state="translated">一番下へ移動</target>
</trans-unit> </trans-unit>
<trans-unit id="1255048712725285892" datatype="html"> <trans-unit id="1255048712725285892" datatype="html">
<source>Options to customize appearance, notifications and more. Settings apply to the &lt;strong&gt;current user only&lt;/strong&gt;.</source> <source>Options to customize appearance, notifications and more. Settings apply to the &lt;strong&gt;current user only&lt;/strong&gt;.</source>
@@ -1135,7 +1135,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">115,116</context> <context context-type="linenumber">115,116</context>
</context-group> </context-group>
<target state="needs-translation">Sidebar items to show:</target> <target state="translated">サイドバーに表示するアイテム:</target>
</trans-unit> </trans-unit>
<trans-unit id="1356890996281769972" datatype="html"> <trans-unit id="1356890996281769972" datatype="html">
<source>Dark mode</source> <source>Dark mode</source>
@@ -1323,7 +1323,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">201</context> <context context-type="linenumber">201</context>
</context-group> </context-group>
<target state="needs-translation">Show document counts in sidebar saved views</target> <target state="translated">サイドバーの保存済みビューにドキュメント数を表示</target>
</trans-unit> </trans-unit>
<trans-unit id="8939587804990976924" datatype="html"> <trans-unit id="8939587804990976924" datatype="html">
<source>Items per page</source> <source>Items per page</source>
@@ -1403,7 +1403,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">259,260</context> <context context-type="linenumber">259,260</context>
</context-group> </context-group>
<target state="needs-translation">Suggestions default to</target> <target state="translated">提案機能のデフォルト設定</target>
</trans-unit> </trans-unit>
<trans-unit id="6251586038459969294" datatype="html"> <trans-unit id="6251586038459969294" datatype="html">
<source>Both</source> <source>Both</source>
@@ -1411,7 +1411,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context> <context context-type="linenumber">264</context>
</context-group> </context-group>
<target state="needs-translation">Both</target> <target state="translated">両方</target>
</trans-unit> </trans-unit>
<trans-unit id="8967224283122923634" datatype="html"> <trans-unit id="8967224283122923634" datatype="html">
<source>ML only</source> <source>ML only</source>
@@ -1427,7 +1427,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context> <context context-type="linenumber">268</context>
</context-group> </context-group>
<target state="needs-translation">AI only</target> <target state="translated">AIのみ</target>
</trans-unit> </trans-unit>
<trans-unit id="1043449647727456069" datatype="html"> <trans-unit id="1043449647727456069" datatype="html">
<source>Automatically request suggestions for inbox documents</source> <source>Automatically request suggestions for inbox documents</source>
@@ -1435,7 +1435,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context> <context context-type="linenumber">276</context>
</context-group> </context-group>
<target state="needs-translation">Automatically request suggestions for inbox documents</target> <target state="translated">受信トレイ内のドキュメントに対して自動的に提案をリクエストする</target>
</trans-unit> </trans-unit>
<trans-unit id="460700039378563125" datatype="html"> <trans-unit id="460700039378563125" datatype="html">
<source>If un-checked, suggestions must be requested via the Suggest button.</source> <source>If un-checked, suggestions must be requested via the Suggest button.</source>
@@ -1443,7 +1443,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context> <context context-type="linenumber">276</context>
</context-group> </context-group>
<target state="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target> <target state="translated">チェックを外した場合、提案を取得するには「提案」ボタンを押す必要があります。</target>
</trans-unit> </trans-unit>
<trans-unit id="8793267604636304297" datatype="html"> <trans-unit id="8793267604636304297" datatype="html">
<source>Show document thumbnail during loading</source> <source>Show document thumbnail during loading</source>
@@ -1467,7 +1467,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">300,301</context> <context context-type="linenumber">300,301</context>
</context-group> </context-group>
<target state="needs-translation">Uncheck fields to hide them on the document details page.</target> <target state="translated">ドキュメントの詳細ページで非表示にするフィールドは、チェックを外してください。</target>
</trans-unit> </trans-unit>
<trans-unit id="8508424367627989968" datatype="html"> <trans-unit id="8508424367627989968" datatype="html">
<source>Bulk editing</source> <source>Bulk editing</source>
@@ -1507,7 +1507,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1907</context> <context context-type="linenumber">1907</context>
</context-group> </context-group>
<target state="needs-translation">PDF Editor</target> <target state="translated">PDF エディター</target>
</trans-unit> </trans-unit>
<trans-unit id="1577733187050997705" datatype="html"> <trans-unit id="1577733187050997705" datatype="html">
<source>Default editing mode</source> <source>Default editing mode</source>
@@ -1515,7 +1515,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">317,318</context> <context context-type="linenumber">317,318</context>
</context-group> </context-group>
<target state="needs-translation">Default editing mode</target> <target state="translated">デフォルトの編集モード</target>
</trans-unit> </trans-unit>
<trans-unit id="7273640930165035289" datatype="html"> <trans-unit id="7273640930165035289" datatype="html">
<source>Create new document(s)</source> <source>Create new document(s)</source>
@@ -1527,7 +1527,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context> <context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">82,83</context> <context context-type="linenumber">82,83</context>
</context-group> </context-group>
<target state="needs-translation">Create new document(s)</target> <target state="translated">新しいドキュメントを作成</target>
</trans-unit> </trans-unit>
<trans-unit id="7860582931776068318" datatype="html"> <trans-unit id="7860582931776068318" datatype="html">
<source>Add document version</source> <source>Add document version</source>
@@ -1539,7 +1539,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context> <context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">87,88</context> <context context-type="linenumber">87,88</context>
</context-group> </context-group>
<target state="needs-translation">Add document version</target> <target state="translated">ドキュメントのバージョンを追加</target>
</trans-unit> </trans-unit>
<trans-unit id="8104421162933956065" datatype="html"> <trans-unit id="8104421162933956065" datatype="html">
<source>Notes</source> <source>Notes</source>
@@ -1639,7 +1639,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">347</context> <context context-type="linenumber">347</context>
</context-group> </context-group>
<target state="needs-translation"> Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents. </target> <target state="translated">これらの設定は、Web UIから作成されたオブジェクト(タグ、メールルールなど)に対して、このユーザーアカウントに適用されます。ドキュメントには適用されません。</target>
</trans-unit> </trans-unit>
<trans-unit id="4292903881380648974" datatype="html"> <trans-unit id="4292903881380648974" datatype="html">
<source>Default Owner</source> <source>Default Owner</source>
@@ -2203,7 +2203,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">331</context> <context context-type="linenumber">331</context>
</context-group> </context-group>
<target state="needs-translation">Tasks</target> <target state="translated">タスク</target>
</trans-unit> </trans-unit>
<trans-unit id="8492095365580052820" datatype="html"> <trans-unit id="8492095365580052820" datatype="html">
<source>Tasks shows detailed information about document consumption and system tasks.</source> <source>Tasks shows detailed information about document consumption and system tasks.</source>
@@ -2211,7 +2211,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">4,5</context> <context context-type="linenumber">4,5</context>
</context-group> </context-group>
<target state="needs-translation">Tasks shows detailed information about document consumption and system tasks.</target> <target state="translated">タスクでは、ドキュメントの取り込み処理やシステムタスクに関する詳細情報を確認できます。</target>
</trans-unit> </trans-unit>
<trans-unit id="103921551219467537" datatype="html"> <trans-unit id="103921551219467537" datatype="html">
<source>Clear selection</source> <source>Clear selection</source>
@@ -2235,7 +2235,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">16</context> <context context-type="linenumber">16</context>
</context-group> </context-group>
<target state="needs-translation">Dismiss all</target> <target state="translated">すべて閉じる</target>
</trans-unit> </trans-unit>
<trans-unit id="1616102757855967475" datatype="html"> <trans-unit id="1616102757855967475" datatype="html">
<source>All</source> <source>All</source>
@@ -2295,7 +2295,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">219</context> <context context-type="linenumber">219</context>
</context-group> </context-group>
<target state="needs-translation">All types</target> <target state="translated">すべての種類</target>
</trans-unit> </trans-unit>
<trans-unit id="131016739441837046" datatype="html"> <trans-unit id="131016739441837046" datatype="html">
<source>All sources</source> <source>All sources</source>
@@ -2307,7 +2307,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">231</context> <context context-type="linenumber">231</context>
</context-group> </context-group>
<target state="needs-translation">All sources</target> <target state="translated">すべてのソース</target>
</trans-unit> </trans-unit>
<trans-unit id="3643866539136239218" datatype="html"> <trans-unit id="3643866539136239218" datatype="html">
<source>Clear search</source> <source>Clear search</source>
@@ -2327,7 +2327,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">92,93</context> <context context-type="linenumber">92,93</context>
</context-group> </context-group>
<target state="needs-translation">Search tasks</target> <target state="translated">タスクを検索</target>
</trans-unit> </trans-unit>
<trans-unit id="6849725902312323996" datatype="html"> <trans-unit id="6849725902312323996" datatype="html">
<source>Reset filters</source> <source>Reset filters</source>
@@ -2351,7 +2351,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">122</context> <context context-type="linenumber">122</context>
</context-group> </context-group>
<target state="needs-translation">{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</target> <target state="translated">{VAR_PLURAL, plural, =1 {1件のタスク} other {<x id="INTERPOLATION"/>件のタスク}}</target>
</trans-unit> </trans-unit>
<trans-unit id="8953033926734869941" datatype="html"> <trans-unit id="8953033926734869941" datatype="html">
<source>Name</source> <source>Name</source>
@@ -2651,7 +2651,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">207</context> <context context-type="linenumber">207</context>
</context-group> </context-group>
<target state="needs-translation">View task details</target> <target state="translated">タスクの詳細を表示</target>
</trans-unit> </trans-unit>
<trans-unit id="1536087519743707362" datatype="html"> <trans-unit id="1536087519743707362" datatype="html">
<source>Dismiss</source> <source>Dismiss</source>
@@ -2691,7 +2691,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">232,233</context> <context context-type="linenumber">232,233</context>
</context-group> </context-group>
<target state="needs-translation">Result message</target> <target state="translated">結果メッセージ</target>
</trans-unit> </trans-unit>
<trans-unit id="6621329748219109148" datatype="html"> <trans-unit id="6621329748219109148" datatype="html">
<source>Duplicate</source> <source>Duplicate</source>
@@ -2699,7 +2699,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">239,240</context> <context context-type="linenumber">239,240</context>
</context-group> </context-group>
<target state="needs-translation">Duplicate</target> <target state="translated">重複</target>
</trans-unit> </trans-unit>
<trans-unit id="7593555694782789615" datatype="html"> <trans-unit id="7593555694782789615" datatype="html">
<source>Open</source> <source>Open</source>
@@ -2739,7 +2739,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">257,258</context> <context context-type="linenumber">257,258</context>
</context-group> </context-group>
<target state="needs-translation">Input data</target> <target state="translated">入力データ</target>
</trans-unit> </trans-unit>
<trans-unit id="1585185618099050920" datatype="html"> <trans-unit id="1585185618099050920" datatype="html">
<source>Result data</source> <source>Result data</source>
@@ -2747,7 +2747,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">263,264</context> <context context-type="linenumber">263,264</context>
</context-group> </context-group>
<target state="needs-translation">Result data</target> <target state="translated">結果データ</target>
</trans-unit> </trans-unit>
<trans-unit id="7976920528153858271" datatype="html"> <trans-unit id="7976920528153858271" datatype="html">
<source>No tasks match the current filters.</source> <source>No tasks match the current filters.</source>
@@ -2755,7 +2755,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">285,287</context> <context context-type="linenumber">285,287</context>
</context-group> </context-group>
<target state="needs-translation">No tasks match the current filters.</target> <target state="translated">現在のフィルター条件に一致するタスクはありません。</target>
</trans-unit> </trans-unit>
<trans-unit id="2525230676386818985" datatype="html"> <trans-unit id="2525230676386818985" datatype="html">
<source>Result</source> <source>Result</source>
@@ -2771,7 +2771,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">55</context> <context context-type="linenumber">55</context>
</context-group> </context-group>
<target state="needs-translation">Needs attention</target> <target state="translated">注意が必要</target>
</trans-unit> </trans-unit>
<trans-unit id="3307214558906712688" datatype="html"> <trans-unit id="3307214558906712688" datatype="html">
<source>In progress</source> <source>In progress</source>
@@ -2779,7 +2779,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">56</context> <context context-type="linenumber">56</context>
</context-group> </context-group>
<target state="needs-translation">In progress</target> <target state="translated">進行中</target>
</trans-unit> </trans-unit>
<trans-unit id="1371178633976505412" datatype="html"> <trans-unit id="1371178633976505412" datatype="html">
<source>Recently completed</source> <source>Recently completed</source>
@@ -2787,7 +2787,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">57</context> <context context-type="linenumber">57</context>
</context-group> </context-group>
<target state="needs-translation">Recently completed</target> <target state="translated">最近完了したタスク</target>
</trans-unit> </trans-unit>
<trans-unit id="2936452645673957909" datatype="html"> <trans-unit id="2936452645673957909" datatype="html">
<source>Consume File</source> <source>Consume File</source>
@@ -2795,7 +2795,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">66</context> <context context-type="linenumber">66</context>
</context-group> </context-group>
<target state="needs-translation">Consume File</target> <target state="translated">ファイルの取り込み</target>
</trans-unit> </trans-unit>
<trans-unit id="5622386857946132209" datatype="html"> <trans-unit id="5622386857946132209" datatype="html">
<source>Train Classifier</source> <source>Train Classifier</source>
@@ -2811,7 +2811,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">74</context> <context context-type="linenumber">74</context>
</context-group> </context-group>
<target state="needs-translation">Sanity Check</target> <target state="translated">整合性チェック</target>
</trans-unit> </trans-unit>
<trans-unit id="7502272564743467653" datatype="html"> <trans-unit id="7502272564743467653" datatype="html">
<source>Mail Fetch</source> <source>Mail Fetch</source>
@@ -2839,7 +2839,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">80</context> <context context-type="linenumber">80</context>
</context-group> </context-group>
<target state="needs-translation">Empty Trash</target> <target state="translated">ゴミ箱を空にする</target>
</trans-unit> </trans-unit>
<trans-unit id="4173754487295857311" datatype="html"> <trans-unit id="4173754487295857311" datatype="html">
<source>Check Workflows</source> <source>Check Workflows</source>
@@ -2847,7 +2847,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">84</context> <context context-type="linenumber">84</context>
</context-group> </context-group>
<target state="needs-translation">Check Workflows</target> <target state="translated">ワークフローの確認</target>
</trans-unit> </trans-unit>
<trans-unit id="2065831805515896240" datatype="html"> <trans-unit id="2065831805515896240" datatype="html">
<source>Bulk Update</source> <source>Bulk Update</source>
@@ -2855,7 +2855,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<target state="needs-translation">Bulk Update</target> <target state="translated">一括更新</target>
</trans-unit> </trans-unit>
<trans-unit id="5062348912423871240" datatype="html"> <trans-unit id="5062348912423871240" datatype="html">
<source>Reprocess Document</source> <source>Reprocess Document</source>
@@ -2863,7 +2863,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">92</context> <context context-type="linenumber">92</context>
</context-group> </context-group>
<target state="needs-translation">Reprocess Document</target> <target state="translated">ドキュメントの再処理</target>
</trans-unit> </trans-unit>
<trans-unit id="6903696804811148799" datatype="html"> <trans-unit id="6903696804811148799" datatype="html">
<source>Build Share Link</source> <source>Build Share Link</source>
@@ -2871,7 +2871,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">96</context> <context context-type="linenumber">96</context>
</context-group> </context-group>
<target state="needs-translation">Build Share Link</target> <target state="translated">共有リンクの生成</target>
</trans-unit> </trans-unit>
<trans-unit id="6581973658756561124" datatype="html"> <trans-unit id="6581973658756561124" datatype="html">
<source>Bulk Delete</source> <source>Bulk Delete</source>
@@ -2879,7 +2879,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">100</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
<target state="needs-translation">Bulk Delete</target> <target state="translated">一括削除</target>
</trans-unit> </trans-unit>
<trans-unit id="5672013191898828327" datatype="html"> <trans-unit id="5672013191898828327" datatype="html">
<source>Apply AI Suggestions</source> <source>Apply AI Suggestions</source>
+18 -18
View File
@@ -17,7 +17,7 @@
<context context-type="linenumber">386,393</context> <context context-type="linenumber">386,393</context>
</context-group> </context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note> <note priority="1" from="description">Currently selected slide number read by screen reader</note>
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target> <target state="translated"> Slajd <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> z <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.previous" datatype="html"> <trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source> <source>Previous</source>
@@ -507,7 +507,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">225</context> <context context-type="linenumber">225</context>
</context-group> </context-group>
<target state="needs-translation">Tasks helps you track background work, what needs attention, and what recently completed.</target> <target state="translated">Zadania pomagają śledzić pracę w tle, to, co wymaga uwagi i co zostało ostatnio ukończone.</target>
</trans-unit> </trans-unit>
<trans-unit id="2755033621682206902" datatype="html"> <trans-unit id="2755033621682206902" datatype="html">
<source>Check out the settings for various tweaks to the web app.</source> <source>Check out the settings for various tweaks to the web app.</source>
@@ -571,7 +571,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">35</context> <context context-type="linenumber">35</context>
</context-group> </context-group>
<target state="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target> <target state="translated">Ta wartość zastępuje <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, która jest ustawiona poza Paperless.</target>
</trans-unit> </trans-unit>
<trans-unit id="7221396516204435584" datatype="html"> <trans-unit id="7221396516204435584" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source> <source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
@@ -579,7 +579,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">37</context> <context context-type="linenumber">37</context>
</context-group> </context-group>
<target state="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> jest ustawiony poza Paperless. Wpisz tutaj wartość, aby go zastąpić.</target>
</trans-unit> </trans-unit>
<trans-unit id="8318849619178340389" datatype="html"> <trans-unit id="8318849619178340389" datatype="html">
<source>Use the externally configured value</source> <source>Use the externally configured value</source>
@@ -587,7 +587,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">42</context>
</context-group> </context-group>
<target state="needs-translation">Use the externally configured value</target> <target state="translated">Użyj wartości skonfigurowanej zewnętrznie</target>
</trans-unit> </trans-unit>
<trans-unit id="6032629623003430385" datatype="html"> <trans-unit id="6032629623003430385" datatype="html">
<source>Reset to external</source> <source>Reset to external</source>
@@ -595,7 +595,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context> <context context-type="linenumber">43</context>
</context-group> </context-group>
<target state="needs-translation">Reset to external</target> <target state="translated">Zresetuj na zewnętrzny</target>
</trans-unit> </trans-unit>
<trans-unit id="7808756054397155068" datatype="html"> <trans-unit id="7808756054397155068" datatype="html">
<source>Reset</source> <source>Reset</source>
@@ -1411,7 +1411,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context> <context context-type="linenumber">264</context>
</context-group> </context-group>
<target state="needs-translation">Both</target> <target state="translated">Obydwa</target>
</trans-unit> </trans-unit>
<trans-unit id="8967224283122923634" datatype="html"> <trans-unit id="8967224283122923634" datatype="html">
<source>ML only</source> <source>ML only</source>
@@ -1419,7 +1419,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">266</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
<target state="needs-translation">ML only</target> <target state="translated">Tylko ML</target>
</trans-unit> </trans-unit>
<trans-unit id="6418969597665857901" datatype="html"> <trans-unit id="6418969597665857901" datatype="html">
<source>AI only</source> <source>AI only</source>
@@ -1427,7 +1427,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context> <context context-type="linenumber">268</context>
</context-group> </context-group>
<target state="needs-translation">AI only</target> <target state="translated">Tylko SI</target>
</trans-unit> </trans-unit>
<trans-unit id="1043449647727456069" datatype="html"> <trans-unit id="1043449647727456069" datatype="html">
<source>Automatically request suggestions for inbox documents</source> <source>Automatically request suggestions for inbox documents</source>
@@ -2211,7 +2211,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">4,5</context> <context context-type="linenumber">4,5</context>
</context-group> </context-group>
<target state="needs-translation">Tasks shows detailed information about document consumption and system tasks.</target> <target state="translated">Zadania zawierają szczegóły dotyczące przetwarzania dokumentów i zadań systemowych.</target>
</trans-unit> </trans-unit>
<trans-unit id="103921551219467537" datatype="html"> <trans-unit id="103921551219467537" datatype="html">
<source>Clear selection</source> <source>Clear selection</source>
@@ -2327,7 +2327,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">92,93</context> <context context-type="linenumber">92,93</context>
</context-group> </context-group>
<target state="needs-translation">Search tasks</target> <target state="translated">Szukaj w zadaniach</target>
</trans-unit> </trans-unit>
<trans-unit id="6849725902312323996" datatype="html" approved="yes"> <trans-unit id="6849725902312323996" datatype="html" approved="yes">
<source>Reset filters</source> <source>Reset filters</source>
@@ -2651,7 +2651,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">207</context> <context context-type="linenumber">207</context>
</context-group> </context-group>
<target state="needs-translation">View task details</target> <target state="translated">Wyświetl szczegóły zadania</target>
</trans-unit> </trans-unit>
<trans-unit id="1536087519743707362" datatype="html"> <trans-unit id="1536087519743707362" datatype="html">
<source>Dismiss</source> <source>Dismiss</source>
@@ -4555,7 +4555,7 @@
<context context-type="sourcefile">src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html</context>
<context context-type="linenumber">9</context> <context context-type="linenumber">9</context>
</context-group> </context-group>
<target state="needs-translation">Rotate counterclockwise</target> <target state="translated">Obróć w lewo</target>
</trans-unit> </trans-unit>
<trans-unit id="631338357809243389" datatype="html"> <trans-unit id="631338357809243389" datatype="html">
<source>Rotate clockwise</source> <source>Rotate clockwise</source>
@@ -4563,7 +4563,7 @@
<context context-type="sourcefile">src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component.html</context>
<context context-type="linenumber">19</context> <context context-type="linenumber">19</context>
</context-group> </context-group>
<target state="needs-translation">Rotate clockwise</target> <target state="translated">Obróć w prawo</target>
</trans-unit> </trans-unit>
<trans-unit id="8157388568390631653" datatype="html"> <trans-unit id="8157388568390631653" datatype="html">
<source>Note that only PDFs will be rotated.</source> <source>Note that only PDFs will be rotated.</source>
@@ -7641,7 +7641,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context> <context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">17</context> <context context-type="linenumber">17</context>
</context-group> </context-group>
<target state="translated">Obróć wybrane strony przeciwnie do ruchu wskazówek zegara</target> <target state="translated">Obróć zaznaczone strony w lewo</target>
</trans-unit> </trans-unit>
<trans-unit id="4787219034890830544" datatype="html"> <trans-unit id="4787219034890830544" datatype="html">
<source>Rotate selected pages clockwise</source> <source>Rotate selected pages clockwise</source>
@@ -7649,7 +7649,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context> <context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</context>
</context-group> </context-group>
<target state="translated">Obróć wybrane strony zgodnie z ruchem wskazówek zegara</target> <target state="translated">Obróć zaznaczone strony w prawo</target>
</trans-unit> </trans-unit>
<trans-unit id="3441043765105475130" datatype="html"> <trans-unit id="3441043765105475130" datatype="html">
<source>Delete selected pages</source> <source>Delete selected pages</source>
@@ -7665,7 +7665,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context> <context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">33</context> <context context-type="linenumber">33</context>
</context-group> </context-group>
<target state="translated">Obróć stronę przeciwnie do ruchu wskazówek zegara</target> <target state="translated">Obróć stronę w lewo</target>
</trans-unit> </trans-unit>
<trans-unit id="3450236521040548507" datatype="html"> <trans-unit id="3450236521040548507" datatype="html">
<source>Rotate page clockwise</source> <source>Rotate page clockwise</source>
@@ -7673,7 +7673,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context> <context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">36</context> <context context-type="linenumber">36</context>
</context-group> </context-group>
<target state="translated">Obróć stronę zgodnie z ruchem wskazówek zegara</target> <target state="translated">Obróć stronę w prawo</target>
</trans-unit> </trans-unit>
<trans-unit id="7647925464077975347" datatype="html"> <trans-unit id="7647925464077975347" datatype="html">
<source>Delete page</source> <source>Delete page</source>
+14 -14
View File
@@ -17,7 +17,7 @@
<context context-type="linenumber">386,393</context> <context context-type="linenumber">386,393</context>
</context-group> </context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note> <note priority="1" from="description">Currently selected slide number read by screen reader</note>
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target> <target state="translated"> Слайд <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> из <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.previous" datatype="html"> <trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source> <source>Previous</source>
@@ -1403,7 +1403,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">259,260</context> <context context-type="linenumber">259,260</context>
</context-group> </context-group>
<target state="needs-translation">Suggestions default to</target> <target state="translated">Предложения по умолчанию для</target>
</trans-unit> </trans-unit>
<trans-unit id="6251586038459969294" datatype="html"> <trans-unit id="6251586038459969294" datatype="html">
<source>Both</source> <source>Both</source>
@@ -1411,7 +1411,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context> <context context-type="linenumber">264</context>
</context-group> </context-group>
<target state="needs-translation">Both</target> <target state="translated">Оба</target>
</trans-unit> </trans-unit>
<trans-unit id="8967224283122923634" datatype="html"> <trans-unit id="8967224283122923634" datatype="html">
<source>ML only</source> <source>ML only</source>
@@ -1419,7 +1419,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">266</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
<target state="needs-translation">ML only</target> <target state="translated">Только ML</target>
</trans-unit> </trans-unit>
<trans-unit id="6418969597665857901" datatype="html"> <trans-unit id="6418969597665857901" datatype="html">
<source>AI only</source> <source>AI only</source>
@@ -1427,7 +1427,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context> <context context-type="linenumber">268</context>
</context-group> </context-group>
<target state="needs-translation">AI only</target> <target state="translated">Только ИИ</target>
</trans-unit> </trans-unit>
<trans-unit id="1043449647727456069" datatype="html"> <trans-unit id="1043449647727456069" datatype="html">
<source>Automatically request suggestions for inbox documents</source> <source>Automatically request suggestions for inbox documents</source>
@@ -8525,7 +8525,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">54</context>
</context-group> </context-group>
<target state="needs-translation">{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</target> <target state="translated">{VAR_PLURAL, plural, =1 {1 совет} other {<x id="INTERPOLATION"/> советов}}</target>
</trans-unit> </trans-unit>
<trans-unit id="4169875112244042673" datatype="html"> <trans-unit id="4169875112244042673" datatype="html">
<source>Suggestion options</source> <source>Suggestion options</source>
@@ -8533,7 +8533,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">65,66</context> <context context-type="linenumber">65,66</context>
</context-group> </context-group>
<target state="needs-translation">Suggestion options</target> <target state="translated">Настройки советов</target>
</trans-unit> </trans-unit>
<trans-unit id="3145824396228856507" datatype="html"> <trans-unit id="3145824396228856507" datatype="html">
<source>Not using default</source> <source>Not using default</source>
@@ -8541,7 +8541,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">69,70</context> <context context-type="linenumber">69,70</context>
</context-group> </context-group>
<target state="needs-translation">Not using default</target> <target state="translated">Не использует по умолчанию</target>
</trans-unit> </trans-unit>
<trans-unit id="8102623853570619759" datatype="html"> <trans-unit id="8102623853570619759" datatype="html">
<source>Suggest using:</source> <source>Suggest using:</source>
@@ -8549,7 +8549,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">74,75</context> <context context-type="linenumber">74,75</context>
</context-group> </context-group>
<target state="needs-translation">Suggest using:</target> <target state="translated">Предложить используя:</target>
</trans-unit> </trans-unit>
<trans-unit id="5799391967036896978" datatype="html"> <trans-unit id="5799391967036896978" datatype="html">
<source>ML</source> <source>ML</source>
@@ -8557,7 +8557,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">77</context> <context context-type="linenumber">77</context>
</context-group> </context-group>
<target state="needs-translation">ML</target> <target state="translated">ML</target>
</trans-unit> </trans-unit>
<trans-unit id="3484984238852181071" datatype="html"> <trans-unit id="3484984238852181071" datatype="html">
<source>AI</source> <source>AI</source>
@@ -8565,7 +8565,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">81</context> <context context-type="linenumber">81</context>
</context-group> </context-group>
<target state="needs-translation">AI</target> <target state="translated">ИИ</target>
</trans-unit> </trans-unit>
<trans-unit id="9180110319941008393" datatype="html"> <trans-unit id="9180110319941008393" datatype="html">
<source>Environment</source> <source>Environment</source>
@@ -9219,7 +9219,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="needs-translation">Copy content</target> <target state="translated">Скопировать содержимое</target>
</trans-unit> </trans-unit>
<trans-unit id="2266163016683537825" datatype="html"> <trans-unit id="2266163016683537825" datatype="html">
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source> <source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source>
@@ -9479,7 +9479,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">360,361</context> <context context-type="linenumber">360,361</context>
</context-group> </context-group>
<target state="needs-translation">Barcodes</target> <target state="translated">Штрихкоды</target>
</trans-unit> </trans-unit>
<trans-unit id="6992781481378431874" datatype="html" approved="yes"> <trans-unit id="6992781481378431874" datatype="html" approved="yes">
<source>Original document metadata</source> <source>Original document metadata</source>
@@ -13375,7 +13375,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">335</context> <context context-type="linenumber">335</context>
</context-group> </context-group>
<target state="needs-translation">Store Barcode Contents</target> <target state="translated">Сохранить содержимое штрихкодов</target>
</trans-unit> </trans-unit>
<trans-unit id="7011909364081812031" datatype="html"> <trans-unit id="7011909364081812031" datatype="html">
<source>AI Enabled</source> <source>AI Enabled</source>
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -17,7 +17,7 @@
<context context-type="linenumber">386,393</context> <context context-type="linenumber">386,393</context>
</context-group> </context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note> <note priority="1" from="description">Currently selected slide number read by screen reader</note>
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target> <target state="translated"> Shlid <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> i <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/></target>
</trans-unit> </trans-unit>
<trans-unit id="ngb.carousel.previous" datatype="html"> <trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source> <source>Previous</source>
@@ -571,7 +571,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">35</context> <context context-type="linenumber">35</context>
</context-group> </context-group>
<target state="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target> <target state="translated">Ky vlerë mbizotëron <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, i cili është vendosur jashtë Paperless.</target>
</trans-unit> </trans-unit>
<trans-unit id="7221396516204435584" datatype="html"> <trans-unit id="7221396516204435584" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source> <source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
@@ -579,7 +579,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">37</context> <context context-type="linenumber">37</context>
</context-group> </context-group>
<target state="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> është vendosur jashtë Paperless. Shkruani një vlerë këtu për ta mbingarkuar atë.</target>
</trans-unit> </trans-unit>
<trans-unit id="8318849619178340389" datatype="html"> <trans-unit id="8318849619178340389" datatype="html">
<source>Use the externally configured value</source> <source>Use the externally configured value</source>
@@ -587,7 +587,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">42</context>
</context-group> </context-group>
<target state="needs-translation">Use the externally configured value</target> <target state="translated">Përdorni vlerën e konfiguruar nga jashtë</target>
</trans-unit> </trans-unit>
<trans-unit id="6032629623003430385" datatype="html"> <trans-unit id="6032629623003430385" datatype="html">
<source>Reset to external</source> <source>Reset to external</source>
@@ -595,7 +595,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context> <context context-type="linenumber">43</context>
</context-group> </context-group>
<target state="needs-translation">Reset to external</target> <target state="translated">Rinisni në të jashtëm</target>
</trans-unit> </trans-unit>
<trans-unit id="7808756054397155068" datatype="html"> <trans-unit id="7808756054397155068" datatype="html">
<source>Reset</source> <source>Reset</source>
@@ -1135,7 +1135,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">115,116</context> <context context-type="linenumber">115,116</context>
</context-group> </context-group>
<target state="needs-translation">Sidebar items to show:</target> <target state="translated">Elementët e shiritit anësor për t'u shfaqur:</target>
</trans-unit> </trans-unit>
<trans-unit id="1356890996281769972" datatype="html"> <trans-unit id="1356890996281769972" datatype="html">
<source>Dark mode</source> <source>Dark mode</source>
@@ -1403,7 +1403,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">259,260</context> <context context-type="linenumber">259,260</context>
</context-group> </context-group>
<target state="needs-translation">Suggestions default to</target> <target state="translated">Sugjerimet parazgjedhore janë</target>
</trans-unit> </trans-unit>
<trans-unit id="6251586038459969294" datatype="html"> <trans-unit id="6251586038459969294" datatype="html">
<source>Both</source> <source>Both</source>
@@ -1411,7 +1411,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context> <context context-type="linenumber">264</context>
</context-group> </context-group>
<target state="needs-translation">Both</target> <target state="translated">Të dyja</target>
</trans-unit> </trans-unit>
<trans-unit id="8967224283122923634" datatype="html"> <trans-unit id="8967224283122923634" datatype="html">
<source>ML only</source> <source>ML only</source>
@@ -1419,7 +1419,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">266</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
<target state="needs-translation">ML only</target> <target state="translated">Vetëm ML</target>
</trans-unit> </trans-unit>
<trans-unit id="6418969597665857901" datatype="html"> <trans-unit id="6418969597665857901" datatype="html">
<source>AI only</source> <source>AI only</source>
@@ -1427,7 +1427,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context> <context context-type="linenumber">268</context>
</context-group> </context-group>
<target state="needs-translation">AI only</target> <target state="translated">Vetëm AI</target>
</trans-unit> </trans-unit>
<trans-unit id="1043449647727456069" datatype="html"> <trans-unit id="1043449647727456069" datatype="html">
<source>Automatically request suggestions for inbox documents</source> <source>Automatically request suggestions for inbox documents</source>
@@ -1435,7 +1435,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context> <context context-type="linenumber">276</context>
</context-group> </context-group>
<target state="needs-translation">Automatically request suggestions for inbox documents</target> <target state="translated">Kërkoni automatikisht sugjerime për dokumentet në kutinë e postës elektronike</target>
</trans-unit> </trans-unit>
<trans-unit id="460700039378563125" datatype="html"> <trans-unit id="460700039378563125" datatype="html">
<source>If un-checked, suggestions must be requested via the Suggest button.</source> <source>If un-checked, suggestions must be requested via the Suggest button.</source>
@@ -1443,7 +1443,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context> <context context-type="linenumber">276</context>
</context-group> </context-group>
<target state="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target> <target state="translated">Nëse nuk është shënuar, sugjerimet duhet të kërkohen përmes butonit Sugjero.</target>
</trans-unit> </trans-unit>
<trans-unit id="8793267604636304297" datatype="html"> <trans-unit id="8793267604636304297" datatype="html">
<source>Show document thumbnail during loading</source> <source>Show document thumbnail during loading</source>
@@ -2319,7 +2319,7 @@
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</context>
</context-group> </context-group>
<target state="needs-translation">Clear search</target> <target state="translated">Pastro kërkimin</target>
</trans-unit> </trans-unit>
<trans-unit id="3570013039254960445" datatype="html"> <trans-unit id="3570013039254960445" datatype="html">
<source>Search tasks</source> <source>Search tasks</source>
@@ -2651,7 +2651,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">207</context> <context context-type="linenumber">207</context>
</context-group> </context-group>
<target state="needs-translation">View task details</target> <target state="translated">Shiko detajet e detyrës</target>
</trans-unit> </trans-unit>
<trans-unit id="1536087519743707362" datatype="html"> <trans-unit id="1536087519743707362" datatype="html">
<source>Dismiss</source> <source>Dismiss</source>
@@ -2887,7 +2887,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">104</context>
</context-group> </context-group>
<target state="needs-translation">Apply AI Suggestions</target> <target state="translated">Apliko sugjerimet e AI</target>
</trans-unit> </trans-unit>
<trans-unit id="9172233176401579786" datatype="html"> <trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source> <source>Scheduled</source>
@@ -3783,7 +3783,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">42</context>
</context-group> </context-group>
<target state="needs-translation">User menu</target> <target state="translated">Menuja e përdoruesit</target>
</trans-unit> </trans-unit>
<trans-unit id="2448391510242468907" datatype="html"> <trans-unit id="2448391510242468907" datatype="html">
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source> <source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
@@ -3939,7 +3939,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context> <context context-type="linenumber">2,3</context>
</context-group> </context-group>
<target state="needs-translation">Share links</target> <target state="translated">Ndani lidhjet</target>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
@@ -4015,7 +4015,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">403</context> <context context-type="linenumber">403</context>
</context-group> </context-group>
<target state="needs-translation">Configure update checking</target> <target state="translated">Konfiguro kontrollin e përditësimeve</target>
</trans-unit> </trans-unit>
<trans-unit id="1542489069631984294" datatype="html"> <trans-unit id="1542489069631984294" datatype="html">
<source>Sidebar views updated</source> <source>Sidebar views updated</source>
@@ -4259,7 +4259,7 @@
<context context-type="sourcefile">src/app/data/saved-view-icons.ts</context> <context context-type="sourcefile">src/app/data/saved-view-icons.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</context>
</context-group> </context-group>
<target state="needs-translation">Chat</target> <target state="translated">Bisedë</target>
</trans-unit> </trans-unit>
<trans-unit id="8066608938393600549" datatype="html"> <trans-unit id="8066608938393600549" datatype="html">
<source>Message</source> <source>Message</source>
@@ -4379,7 +4379,7 @@
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component.html</context>
<context context-type="linenumber">8,9</context> <context context-type="linenumber">8,9</context>
</context-group> </context-group>
<target state="needs-translation">Root document:</target> <target state="translated">Dokumenti rrënjësor:</target>
</trans-unit> </trans-unit>
<trans-unit id="2121026240713807342" datatype="html"> <trans-unit id="2121026240713807342" datatype="html">
<source>Versions (oldest first):</source> <source>Versions (oldest first):</source>
+58 -58
View File
@@ -595,7 +595,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context> <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context> <context context-type="linenumber">43</context>
</context-group> </context-group>
<target state="needs-translation">Reset to external</target> <target state="translated">重置为外部</target>
</trans-unit> </trans-unit>
<trans-unit id="7808756054397155068" datatype="html"> <trans-unit id="7808756054397155068" datatype="html">
<source>Reset</source> <source>Reset</source>
@@ -1135,7 +1135,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">115,116</context> <context context-type="linenumber">115,116</context>
</context-group> </context-group>
<target state="needs-translation">Sidebar items to show:</target> <target state="translated">侧边栏显示项:</target>
</trans-unit> </trans-unit>
<trans-unit id="1356890996281769972" datatype="html"> <trans-unit id="1356890996281769972" datatype="html">
<source>Dark mode</source> <source>Dark mode</source>
@@ -1419,7 +1419,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">266</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
<target state="needs-translation">ML only</target> <target state="translated">仅限 ML</target>
</trans-unit> </trans-unit>
<trans-unit id="6418969597665857901" datatype="html"> <trans-unit id="6418969597665857901" datatype="html">
<source>AI only</source> <source>AI only</source>
@@ -1427,7 +1427,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context> <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context> <context context-type="linenumber">268</context>
</context-group> </context-group>
<target state="needs-translation">AI only</target> <target state="translated">仅限 AI</target>
</trans-unit> </trans-unit>
<trans-unit id="1043449647727456069" datatype="html"> <trans-unit id="1043449647727456069" datatype="html">
<source>Automatically request suggestions for inbox documents</source> <source>Automatically request suggestions for inbox documents</source>
@@ -2887,7 +2887,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">104</context>
</context-group> </context-group>
<target state="needs-translation">Apply AI Suggestions</target> <target state="translated">应用 AI 建议</target>
</trans-unit> </trans-unit>
<trans-unit id="9172233176401579786" datatype="html"> <trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source> <source>Scheduled</source>
@@ -3939,7 +3939,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context> <context context-type="linenumber">2,3</context>
</context-group> </context-group>
<target state="needs-translation">Share links</target> <target state="translated">分享链接</target>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
@@ -4259,7 +4259,7 @@
<context context-type="sourcefile">src/app/data/saved-view-icons.ts</context> <context context-type="sourcefile">src/app/data/saved-view-icons.ts</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</context>
</context-group> </context-group>
<target state="needs-translation">Chat</target> <target state="translated">聊天</target>
</trans-unit> </trans-unit>
<trans-unit id="8066608938393600549" datatype="html"> <trans-unit id="8066608938393600549" datatype="html">
<source>Message</source> <source>Message</source>
@@ -4539,7 +4539,7 @@
<context context-type="sourcefile">src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html</context>
<context context-type="linenumber">16,17</context> <context context-type="linenumber">16,17</context>
</context-group> </context-group>
<target state="needs-translation">Use remote OCR</target> <target state="translated">使用远程 OCR</target>
</trans-unit> </trans-unit>
<trans-unit id="6733578401091585663" datatype="html"> <trans-unit id="6733578401091585663" datatype="html">
<source>Sends the document to the configured remote OCR service, which may incur costs.</source> <source>Sends the document to the configured remote OCR service, which may incur costs.</source>
@@ -4547,7 +4547,7 @@
<context context-type="sourcefile">src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component.html</context>
<context context-type="linenumber">17,18</context> <context context-type="linenumber">17,18</context>
</context-group> </context-group>
<target state="needs-translation">Sends the document to the configured remote OCR service, which may incur costs.</target> <target state="translated">将文档发送到已配置的远程 OCR 服务,可能会产生费用。</target>
</trans-unit> </trans-unit>
<trans-unit id="3883144805282469432" datatype="html"> <trans-unit id="3883144805282469432" datatype="html">
<source>Rotate counterclockwise</source> <source>Rotate counterclockwise</source>
@@ -4655,7 +4655,7 @@
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context> <context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
<context context-type="linenumber">16</context> <context context-type="linenumber">16</context>
</context-group> </context-group>
<target state="needs-translation">Open date picker</target> <target state="translated">打开日期选择器</target>
</trans-unit> </trans-unit>
<trans-unit id="6048892649018070225" datatype="html"> <trans-unit id="6048892649018070225" datatype="html">
<source>Today</source> <source>Today</source>
@@ -4779,7 +4779,7 @@
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">154</context>
</context-group> </context-group>
<target state="needs-translation">Remove query</target> <target state="translated">移除查询</target>
</trans-unit> </trans-unit>
<trans-unit id="3184700926171002527" datatype="html"> <trans-unit id="3184700926171002527" datatype="html">
<source>Any</source> <source>Any</source>
@@ -4823,7 +4823,7 @@
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
<context context-type="linenumber">196</context> <context context-type="linenumber">196</context>
</context-group> </context-group>
<target state="needs-translation">Remove expression</target> <target state="translated">移除表达式</target>
</trans-unit> </trans-unit>
<trans-unit id="6630533861307510614" datatype="html"> <trans-unit id="6630533861307510614" datatype="html">
<source>Clear created relative date</source> <source>Clear created relative date</source>
@@ -6931,7 +6931,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">58</context> <context context-type="linenumber">58</context>
</context-group> </context-group>
<target state="needs-translation">Remote OCR</target> <target state="translated">远程 OCR</target>
</trans-unit> </trans-unit>
<trans-unit id="4101630920511881483" datatype="html"> <trans-unit id="4101630920511881483" datatype="html">
<source>Apply AI suggestions</source> <source>Apply AI suggestions</source>
@@ -6939,7 +6939,7 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">159</context> <context context-type="linenumber">159</context>
</context-group> </context-group>
<target state="needs-translation">Apply AI suggestions</target> <target state="translated">应用 AI 建议</target>
</trans-unit> </trans-unit>
<trans-unit id="5701618810648052610" datatype="html"> <trans-unit id="5701618810648052610" datatype="html">
<source>Title</source> <source>Title</source>
@@ -6975,7 +6975,7 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">186</context> <context context-type="linenumber">186</context>
</context-group> </context-group>
<target state="needs-translation">Created date</target> <target state="translated">创建日期</target>
</trans-unit> </trans-unit>
<trans-unit id="4522609911791833187" datatype="html"> <trans-unit id="4522609911791833187" datatype="html">
<source>Has any of these tags</source> <source>Has any of these tags</source>
@@ -7396,7 +7396,7 @@
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context> <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">52,53</context> <context context-type="linenumber">52,53</context>
</context-group> </context-group>
<target state="needs-translation">Unavailable</target> <target state="translated">不可用</target>
</trans-unit> </trans-unit>
<trans-unit id="5676637575587497817" datatype="html"> <trans-unit id="5676637575587497817" datatype="html">
<source>Search for documents</source> <source>Search for documents</source>
@@ -7440,7 +7440,7 @@
<context context-type="sourcefile">src/app/components/common/input/entries/entries.component.html</context> <context context-type="sourcefile">src/app/components/common/input/entries/entries.component.html</context>
<context context-type="linenumber">16</context> <context context-type="linenumber">16</context>
</context-group> </context-group>
<target state="needs-translation">Remove entry</target> <target state="translated">移除条目</target>
</trans-unit> </trans-unit>
<trans-unit id="6932865105766151309" datatype="html"> <trans-unit id="6932865105766151309" datatype="html">
<source>Upload</source> <source>Upload</source>
@@ -7493,7 +7493,7 @@
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context> <context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
<context context-type="linenumber">41</context> <context context-type="linenumber">41</context>
</context-group> </context-group>
<target state="needs-translation">Remove item</target> <target state="translated">移除项目</target>
</trans-unit> </trans-unit>
<trans-unit id="3686284950598311784" datatype="html"> <trans-unit id="3686284950598311784" datatype="html">
<source>Private</source> <source>Private</source>
@@ -7769,7 +7769,7 @@
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">61</context> <context context-type="linenumber">61</context>
</context-group> </context-group>
<target state="needs-translation">Clear selected users</target> <target state="translated">清除选中的用户</target>
</trans-unit> </trans-unit>
<trans-unit id="8999708063434507268" datatype="html"> <trans-unit id="8999708063434507268" datatype="html">
<source>Hide unowned</source> <source>Hide unowned</source>
@@ -8481,7 +8481,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">12,13</context> <context context-type="linenumber">12,13</context>
</context-group> </context-group>
<target state="needs-translation">No suggestions</target> <target state="translated">无建议</target>
</trans-unit> </trans-unit>
<trans-unit id="5320136382998259826" datatype="html"> <trans-unit id="5320136382998259826" datatype="html">
<source>Suggest</source> <source>Suggest</source>
@@ -8533,7 +8533,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">65,66</context> <context context-type="linenumber">65,66</context>
</context-group> </context-group>
<target state="needs-translation">Suggestion options</target> <target state="translated">建议选项</target>
</trans-unit> </trans-unit>
<trans-unit id="3145824396228856507" datatype="html"> <trans-unit id="3145824396228856507" datatype="html">
<source>Not using default</source> <source>Not using default</source>
@@ -8541,7 +8541,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">69,70</context> <context context-type="linenumber">69,70</context>
</context-group> </context-group>
<target state="needs-translation">Not using default</target> <target state="translated">不使用默认值</target>
</trans-unit> </trans-unit>
<trans-unit id="8102623853570619759" datatype="html"> <trans-unit id="8102623853570619759" datatype="html">
<source>Suggest using:</source> <source>Suggest using:</source>
@@ -8549,7 +8549,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">74,75</context> <context context-type="linenumber">74,75</context>
</context-group> </context-group>
<target state="needs-translation">Suggest using:</target> <target state="translated">建议使用:</target>
</trans-unit> </trans-unit>
<trans-unit id="5799391967036896978" datatype="html"> <trans-unit id="5799391967036896978" datatype="html">
<source>ML</source> <source>ML</source>
@@ -8557,7 +8557,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">77</context> <context context-type="linenumber">77</context>
</context-group> </context-group>
<target state="needs-translation">ML</target> <target state="translated">ML</target>
</trans-unit> </trans-unit>
<trans-unit id="3484984238852181071" datatype="html"> <trans-unit id="3484984238852181071" datatype="html">
<source>AI</source> <source>AI</source>
@@ -8565,7 +8565,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">81</context> <context context-type="linenumber">81</context>
</context-group> </context-group>
<target state="needs-translation">AI</target> <target state="translated">AI</target>
</trans-unit> </trans-unit>
<trans-unit id="9180110319941008393" datatype="html"> <trans-unit id="9180110319941008393" datatype="html">
<source>Environment</source> <source>Environment</source>
@@ -9219,7 +9219,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="needs-translation">Copy content</target> <target state="translated">复制内容</target>
</trans-unit> </trans-unit>
<trans-unit id="2266163016683537825" datatype="html"> <trans-unit id="2266163016683537825" datatype="html">
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source> <source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source>
@@ -9255,7 +9255,7 @@
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">154</context>
</context-group> </context-group>
<target state="needs-translation">Download options</target> <target state="translated">下载选项</target>
</trans-unit> </trans-unit>
<trans-unit id="8659635229098859487" datatype="html"> <trans-unit id="8659635229098859487" datatype="html">
<source>Download original</source> <source>Download original</source>
@@ -9479,7 +9479,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">360,361</context> <context context-type="linenumber">360,361</context>
</context-group> </context-group>
<target state="needs-translation">Barcodes</target> <target state="translated">条形码</target>
</trans-unit> </trans-unit>
<trans-unit id="6992781481378431874" datatype="html"> <trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source> <source>Original document metadata</source>
@@ -9727,7 +9727,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1074</context> <context context-type="linenumber">1074</context>
</context-group> </context-group>
<target state="needs-translation">You have unsaved changes to the content of this version.</target> <target state="translated">此版本的内容有未保存的更改。</target>
</trans-unit> </trans-unit>
<trans-unit id="85184271222513014" datatype="html"> <trans-unit id="85184271222513014" datatype="html">
<source>Switching versions will discard them.</source> <source>Switching versions will discard them.</source>
@@ -9735,7 +9735,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1075</context> <context context-type="linenumber">1075</context>
</context-group> </context-group>
<target state="needs-translation">Switching versions will discard them.</target> <target state="translated">切换版本将丢弃这些更改。</target>
</trans-unit> </trans-unit>
<trans-unit id="2565707334844767610" datatype="html"> <trans-unit id="2565707334844767610" datatype="html">
<source>Discard and switch</source> <source>Discard and switch</source>
@@ -9743,7 +9743,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1077</context> <context context-type="linenumber">1077</context>
</context-group> </context-group>
<target state="needs-translation">Discard and switch</target> <target state="translated">丢弃并切换</target>
</trans-unit> </trans-unit>
<trans-unit id="2109314380040637387" datatype="html"> <trans-unit id="2109314380040637387" datatype="html">
<source>Save and switch</source> <source>Save and switch</source>
@@ -9751,7 +9751,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1079</context> <context context-type="linenumber">1079</context>
</context-group> </context-group>
<target state="needs-translation">Save and switch</target> <target state="translated">保存并切换</target>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
@@ -9967,7 +9967,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/add-existing-document-version-dialog/add-existing-document-version-dialog.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/add-existing-document-version-dialog/add-existing-document-version-dialog.component.html</context>
<context context-type="linenumber">13,14</context> <context context-type="linenumber">13,14</context>
</context-group> </context-group>
<target state="needs-translation">Select one document to add as a version.</target> <target state="translated">请选择一份文档,将其添加为新版本。</target>
</trans-unit> </trans-unit>
<trans-unit id="337558081609002420" datatype="html"> <trans-unit id="337558081609002420" datatype="html">
<source>Add version</source> <source>Add version</source>
@@ -10011,7 +10011,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">35,36</context> <context context-type="linenumber">35,36</context>
</context-group> </context-group>
<target state="needs-translation">Upload a new version</target> <target state="translated">上传新版本</target>
</trans-unit> </trans-unit>
<trans-unit id="278763911843514709" datatype="html"> <trans-unit id="278763911843514709" datatype="html">
<source>Use an existing document</source> <source>Use an existing document</source>
@@ -10019,7 +10019,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">44,45</context> <context context-type="linenumber">44,45</context>
</context-group> </context-group>
<target state="needs-translation">Use an existing document</target> <target state="translated">使用现有文档</target>
</trans-unit> </trans-unit>
<trans-unit id="1906507263389090131" datatype="html"> <trans-unit id="1906507263389090131" datatype="html">
<source>Existing</source> <source>Existing</source>
@@ -10027,7 +10027,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">47,48</context> <context context-type="linenumber">47,48</context>
</context-group> </context-group>
<target state="needs-translation">Existing</target> <target state="translated">已有的</target>
</trans-unit> </trans-unit>
<trans-unit id="3119565892291077820" datatype="html"> <trans-unit id="3119565892291077820" datatype="html">
<source>Uploading version...</source> <source>Uploading version...</source>
@@ -11234,7 +11234,7 @@
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">208</context> <context context-type="linenumber">208</context>
</context-group> </context-group>
<target state="needs-translation">Duplicates</target> <target state="translated">创建副本</target>
</trans-unit> </trans-unit>
<trans-unit id="8492379284173601938" datatype="html"> <trans-unit id="8492379284173601938" datatype="html">
<source>Custom fields (Deprecated)</source> <source>Custom fields (Deprecated)</source>
@@ -11298,7 +11298,7 @@
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">252</context> <context context-type="linenumber">252</context>
</context-group> </context-group>
<target state="needs-translation">exist</target> <target state="translated">存在</target>
</trans-unit> </trans-unit>
<trans-unit id="4528435301189613620" datatype="html"> <trans-unit id="4528435301189613620" datatype="html">
<source>do not exist</source> <source>do not exist</source>
@@ -11306,7 +11306,7 @@
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">256</context> <context context-type="linenumber">256</context>
</context-group> </context-group>
<target state="needs-translation">do not exist</target> <target state="translated">不存在</target>
</trans-unit> </trans-unit>
<trans-unit id="5195932016807797291" datatype="html"> <trans-unit id="5195932016807797291" datatype="html">
<source>Correspondent: <x id="PH" equiv-text="this.correspondentSelectionModel.items.find( (c) =&gt; c.id == +rule.value )?.name"/></source> <source>Correspondent: <x id="PH" equiv-text="this.correspondentSelectionModel.items.find( (c) =&gt; c.id == +rule.value )?.name"/></source>
@@ -11378,7 +11378,7 @@
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">343</context> <context context-type="linenumber">343</context>
</context-group> </context-group>
<target state="needs-translation">Without duplicates</target> <target state="translated">无重复项</target>
</trans-unit> </trans-unit>
<trans-unit id="8448347828886552444" datatype="html"> <trans-unit id="8448347828886552444" datatype="html">
<source>With duplicates</source> <source>With duplicates</source>
@@ -11386,7 +11386,7 @@
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">344</context> <context context-type="linenumber">344</context>
</context-group> </context-group>
<target state="needs-translation">With duplicates</target> <target state="translated">带重复项</target>
</trans-unit> </trans-unit>
<trans-unit id="8644099678903817943" datatype="html"> <trans-unit id="8644099678903817943" datatype="html">
<source>Custom fields query</source> <source>Custom fields query</source>
@@ -11462,7 +11462,7 @@
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context> <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</context>
</context-group> </context-group>
<target state="needs-translation">Icon</target> <target state="translated">图标</target>
</trans-unit> </trans-unit>
<trans-unit id="8689274715612276035" datatype="html"> <trans-unit id="8689274715612276035" datatype="html">
<source>Show in sidebar</source> <source>Show in sidebar</source>
@@ -12515,7 +12515,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="needs-translation">Expired</target> <target state="translated">已过期</target>
</trans-unit> </trans-unit>
<trans-unit id="2057574872309338088" datatype="html"> <trans-unit id="2057574872309338088" datatype="html">
<source>Copy share link</source> <source>Copy share link</source>
@@ -12559,7 +12559,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.html</context>
<context context-type="linenumber">164,165</context> <context context-type="linenumber">164,165</context>
</context-group> </context-group>
<target state="needs-translation">Share link bundles pagination</target> <target state="translated">分享链接合集分页</target>
</trans-unit> </trans-unit>
<trans-unit id="1541995860059283227" datatype="html"> <trans-unit id="1541995860059283227" datatype="html">
<source>Failed to load share link bundles.</source> <source>Failed to load share link bundles.</source>
@@ -12623,7 +12623,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">15,16</context> <context context-type="linenumber">15,16</context>
</context-group> </context-group>
<target state="needs-translation">Document</target> <target state="translated">文档</target>
</trans-unit> </trans-unit>
<trans-unit id="7420713064779772535" datatype="html"> <trans-unit id="7420713064779772535" datatype="html">
<source>Delete share link</source> <source>Delete share link</source>
@@ -12631,7 +12631,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.html</context>
<context context-type="linenumber">75,76</context> <context context-type="linenumber">75,76</context>
</context-group> </context-group>
<target state="needs-translation">Delete share link</target> <target state="translated">删除分享链接</target>
</trans-unit> </trans-unit>
<trans-unit id="5198271726831144068" datatype="html"> <trans-unit id="5198271726831144068" datatype="html">
<source>Share links pagination</source> <source>Share links pagination</source>
@@ -12647,7 +12647,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">105</context>
</context-group> </context-group>
<target state="needs-translation">Failed to load share links.</target> <target state="translated">加载分享链接失败。</target>
</trans-unit> </trans-unit>
<trans-unit id="4100863040928608880" datatype="html"> <trans-unit id="4100863040928608880" datatype="html">
<source>Error retrieving share links.</source> <source>Error retrieving share links.</source>
@@ -12655,7 +12655,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">107</context> <context context-type="linenumber">107</context>
</context-group> </context-group>
<target state="needs-translation">Error retrieving share links.</target> <target state="translated">获取分享链接失败。</target>
</trans-unit> </trans-unit>
<trans-unit id="326120283142363299" datatype="html"> <trans-unit id="326120283142363299" datatype="html">
<source>Share link deleted.</source> <source>Share link deleted.</source>
@@ -12663,7 +12663,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">154</context>
</context-group> </context-group>
<target state="needs-translation">Share link deleted.</target> <target state="translated">分享链接已删除。</target>
</trans-unit> </trans-unit>
<trans-unit id="4964931417181298145" datatype="html"> <trans-unit id="4964931417181298145" datatype="html">
<source>Error deleting share link.</source> <source>Error deleting share link.</source>
@@ -12671,7 +12671,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-link-list/share-link-list.component.ts</context>
<context context-type="linenumber">159</context> <context context-type="linenumber">159</context>
</context-group> </context-group>
<target state="needs-translation">Error deleting share link.</target> <target state="translated">删除分享链接失败。</target>
</trans-unit> </trans-unit>
<trans-unit id="3439104787814858626" datatype="html"> <trans-unit id="3439104787814858626" datatype="html">
<source>Manage public links to individual documents and document bundles.</source> <source>Manage public links to individual documents and document bundles.</source>
@@ -12679,7 +12679,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">4,5</context> <context context-type="linenumber">4,5</context>
</context-group> </context-group>
<target state="needs-translation">Manage public links to individual documents and document bundles.</target> <target state="translated">管理单个文档和文档合集的公开分享链接。</target>
</trans-unit> </trans-unit>
<trans-unit id="3740311348735113099" datatype="html"> <trans-unit id="3740311348735113099" datatype="html">
<source>Document links</source> <source>Document links</source>
@@ -12687,7 +12687,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">18,19</context> <context context-type="linenumber">18,19</context>
</context-group> </context-group>
<target state="needs-translation">Document links</target> <target state="translated">文档链接</target>
</trans-unit> </trans-unit>
<trans-unit id="8216780576869093031" datatype="html"> <trans-unit id="8216780576869093031" datatype="html">
<source>Bundles</source> <source>Bundles</source>
@@ -12695,7 +12695,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context> <context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">26,27</context> <context context-type="linenumber">26,27</context>
</context-group> </context-group>
<target state="needs-translation">Bundles</target> <target state="translated">捆绑包</target>
</trans-unit> </trans-unit>
<trans-unit id="1229748338333965418" datatype="html"> <trans-unit id="1229748338333965418" datatype="html">
<source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source> <source>Use workflows to customize the behavior of Paperless-ngx when events &apos;trigger&apos; a workflow.</source>
@@ -13208,7 +13208,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">200</context> <context context-type="linenumber">200</context>
</context-group> </context-group>
<target state="needs-translation">Remote OCR Engine</target> <target state="translated">远程 OCR 引擎</target>
</trans-unit> </trans-unit>
<trans-unit id="2892372808177591734" datatype="html"> <trans-unit id="2892372808177591734" datatype="html">
<source>Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.</source> <source>Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.</source>
@@ -13224,7 +13224,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">210</context> <context context-type="linenumber">210</context>
</context-group> </context-group>
<target state="needs-translation">Remote OCR API Key</target> <target state="translated">远程 OCR API 密钥</target>
</trans-unit> </trans-unit>
<trans-unit id="8592973963881683840" datatype="html"> <trans-unit id="8592973963881683840" datatype="html">
<source>Remote OCR Endpoint</source> <source>Remote OCR Endpoint</source>
@@ -13232,7 +13232,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">218</context> <context context-type="linenumber">218</context>
</context-group> </context-group>
<target state="needs-translation">Remote OCR Endpoint</target> <target state="translated">远程 OCR 端点</target>
</trans-unit> </trans-unit>
<trans-unit id="2477780225973884032" datatype="html"> <trans-unit id="2477780225973884032" datatype="html">
<source>Required when using the Azure AI engine.</source> <source>Required when using the Azure AI engine.</source>
@@ -13240,7 +13240,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">223</context> <context context-type="linenumber">223</context>
</context-group> </context-group>
<target state="needs-translation">Required when using the Azure AI engine.</target> <target state="translated">使用 Azure AI 引擎时必填。</target>
</trans-unit> </trans-unit>
<trans-unit id="2908398258708722139" datatype="html"> <trans-unit id="2908398258708722139" datatype="html">
<source>Remote OCR Mode</source> <source>Remote OCR Mode</source>
@@ -13248,7 +13248,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">227</context> <context context-type="linenumber">227</context>
</context-group> </context-group>
<target state="needs-translation">Remote OCR Mode</target> <target state="translated">远程 OCR 模式</target>
</trans-unit> </trans-unit>
<trans-unit id="4022045371405916558" datatype="html"> <trans-unit id="4022045371405916558" datatype="html">
<source>Which documents are sent to the remote engine. Use &apos;workflow_only&apos; to keep remote OCR off unless a workflow enables it for a document.</source> <source>Which documents are sent to the remote engine. Use &apos;workflow_only&apos; to keep remote OCR off unless a workflow enables it for a document.</source>
+6 -6
View File
@@ -479,7 +479,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">201</context> <context context-type="linenumber">201</context>
</context-group> </context-group>
<target state="translated">標籤、關聯方、文件類型、儲存路徑與自訂欄位等屬性皆可在此處管理,亦可在「文件編輯」檢視中建立。</target> <target state="translated">標籤、關聯方、文件類型、儲存路徑與自訂欄位等文件屬性皆可在此處管理,亦可在「文件編輯」檢視中建立。</target>
</trans-unit> </trans-unit>
<trans-unit id="7851939076947092983" datatype="html"> <trans-unit id="7851939076947092983" datatype="html">
<source>Manage e-mail accounts and rules for automatically importing documents.</source> <source>Manage e-mail accounts and rules for automatically importing documents.</source>
@@ -3859,7 +3859,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">195</context> <context context-type="linenumber">195</context>
</context-group> </context-group>
<target state="translated">屬性</target> <target state="translated">文件屬性</target>
</trans-unit> </trans-unit>
<trans-unit id="7437910965833684826" datatype="html"> <trans-unit id="7437910965833684826" datatype="html">
<source>Correspondents</source> <source>Correspondents</source>
@@ -8525,7 +8525,7 @@
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">54</context>
</context-group> </context-group>
<target state="needs-translation">{VAR_PLURAL, plural, =1 {1 suggestion available below} other {<x id="INTERPOLATION"/> suggestions available below}}</target> <target state="translated">{VAR_PLURAL, plural, =1 {以下有 1 個可用建議} other {以下有 <x id="INTERPOLATION"/> 個可用建議}}</target>
</trans-unit> </trans-unit>
<trans-unit id="4169875112244042673" datatype="html"> <trans-unit id="4169875112244042673" datatype="html">
<source>Suggestion options</source> <source>Suggestion options</source>
@@ -9219,7 +9219,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-barcodes/document-barcodes.component.html</context>
<context context-type="linenumber">40,41</context> <context context-type="linenumber">40,41</context>
</context-group> </context-group>
<target state="needs-translation">Copy content</target> <target state="translated">拷貝內容</target>
</trans-unit> </trans-unit>
<trans-unit id="2266163016683537825" datatype="html"> <trans-unit id="2266163016683537825" datatype="html">
<source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source> <source>of <x id="INTERPOLATION" equiv-text="{{previewNumPages()}}"/></source>
@@ -9479,7 +9479,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">360,361</context> <context context-type="linenumber">360,361</context>
</context-group> </context-group>
<target state="needs-translation">Barcodes</target> <target state="translated">條碼</target>
</trans-unit> </trans-unit>
<trans-unit id="6992781481378431874" datatype="html"> <trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source> <source>Original document metadata</source>
@@ -13376,7 +13376,7 @@
<context context-type="sourcefile">src/app/data/paperless-config.ts</context> <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
<context context-type="linenumber">335</context> <context context-type="linenumber">335</context>
</context-group> </context-group>
<target state="needs-translation">Store Barcode Contents</target> <target state="translated">儲存條碼內容</target>
</trans-unit> </trans-unit>
<trans-unit id="7011909364081812031" datatype="html"> <trans-unit id="7011909364081812031" datatype="html">
<source>AI Enabled</source> <source>AI Enabled</source>
+1 -1
View File
@@ -15,7 +15,7 @@
"target": "ES2022", "target": "ES2022",
"module": "es2020", "module": "es2020",
"lib": [ "lib": [
"es2020", "es2022",
"dom" "dom"
], ],
"useDefineForClassFields": false "useDefineForClassFields": false
+4
View File
@@ -54,6 +54,10 @@ def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
cutoff so that uncertain predictions are discarded rather than assigned. cutoff so that uncertain predictions are discarded rather than assigned.
""" """
probas = classifier.predict_proba(X)[0] probas = classifier.predict_proba(X)[0]
if len(classifier.classes_) == 1:
# scikit-learn treats a single-class fit as binary, so predict_proba
# returns [1 - p, p] for one class. Column 0 is the real class.
probas = probas[:1]
best_idx = int(probas.argmax()) best_idx = int(probas.argmax())
best_class = int(classifier.classes_[best_idx]) best_class = int(classifier.classes_[best_idx])
+11 -2
View File
@@ -1,11 +1,12 @@
from pathlib import Path from pathlib import Path
import img2pdf import img2pdf
from django.conf import settings
from PIL import Image from PIL import Image
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import maybe_override_pixel_limit from documents.utils import maybe_override_pixel_limit
from documents.utils import remove_alpha_channel from documents.utils import run_subprocess
def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path: def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
@@ -26,7 +27,15 @@ def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
# Note the save into the temp folder, so as not to trigger a new # Note the save into the temp folder, so as not to trigger a new
# consume # consume
scratch_image = target_directory / tiff_path.name scratch_image = target_directory / tiff_path.name
remove_alpha_channel(tiff_path, scratch_image) run_subprocess(
[
settings.CONVERT_BINARY,
"-alpha",
"off",
tiff_path,
scratch_image,
],
)
else: else:
# Not modifying the original, safe to use in place # Not modifying the original, safe to use in place
scratch_image = tiff_path scratch_image = tiff_path
+2
View File
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
get_users_with_perms( get_users_with_perms(
doc, doc,
only_with_perms_in=["view_document"], only_with_perms_in=["view_document"],
with_group_users=False,
).values_list("id", flat=True), ).values_list("id", flat=True),
) )
overrides.change_users = list( overrides.change_users = list(
get_users_with_perms( get_users_with_perms(
doc, doc,
only_with_perms_in=["change_document"], only_with_perms_in=["change_document"],
with_group_users=False,
).values_list("id", flat=True), ).values_list("id", flat=True),
) )
overrides.custom_fields = { overrides.custom_fields = {
+18 -2
View File
@@ -50,6 +50,7 @@ from documents.models import ShareLink
from documents.models import ShareLinkBundle from documents.models import ShareLinkBundle
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import annotate_authorizing_fields
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.versioning import annotate_effective_content from documents.versioning import annotate_effective_content
@@ -1075,6 +1076,9 @@ class PermittedObjectsFilter(BaseFilterBackend):
include_granted: bool = True include_granted: bool = True
perm_codename: str | None = None perm_codename: str | None = None
# A self-referencing foreign key whose target authorizes a row, so a row is
# judged by its parent's owner and grants (``Document.root_document``).
parent_field: str | None = None
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
# Before the superuser and owner-only paths, neither of which consults # Before the superuser and owner-only paths, neither of which consults
@@ -1086,11 +1090,23 @@ class PermittedObjectsFilter(BaseFilterBackend):
if request.user.is_superuser: if request.user.is_superuser:
return queryset return queryset
if not self.include_granted: if not self.include_granted:
return queryset.filter(Q(owner=request.user) | Q(owner__isnull=True)) owner_field = "owner"
if self.parent_field is not None:
queryset = annotate_authorizing_fields(queryset, self.parent_field)
owner_field = "authorizing_owner"
return queryset.filter(
Q(**{owner_field: request.user.pk})
| Q(**{f"{owner_field}__isnull": True}),
)
model = queryset.model model = queryset.model
perm = self.perm_codename or f"view_{model._meta.model_name}" perm = self.perm_codename or f"view_{model._meta.model_name}"
return queryset.filter( return queryset.filter(
id__in=permitted_object_ids(request.user, model, perm), id__in=permitted_object_ids(
request.user,
model,
perm,
parent_field=self.parent_field,
),
) )
@@ -3,6 +3,7 @@ from itertools import islice
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Final
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.models import SocialAccount
@@ -70,6 +71,15 @@ from paperless.models import ApplicationConfiguration
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
# Derived names replace the original's extension with these suffixes
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
# Leave room for the longest derived suffix, which replaces the original's extension
MAX_BASE_NAME_BYTES: Final[int] = Document.MAX_PUBLIC_FILENAME_BYTES - max(
len(THUMBNAIL_SUFFIX),
len(ARCHIVE_SUFFIX),
)
def serialize_queryset_batched( def serialize_queryset_batched(
queryset: "QuerySet[Any]", queryset: "QuerySet[Any]",
@@ -512,10 +522,16 @@ class Command(CryptMixin, PaperlessCommand):
counter=filename_counter, counter=filename_counter,
) )
else: else:
base_name = document.get_public_filename(counter=filename_counter) base_name = document.get_public_filename(
counter=filename_counter,
max_bytes=MAX_BASE_NAME_BYTES,
)
if base_name not in self.exported_files: # The thumbnail and archive names are derived from the stem, so
self.exported_files.add(base_name) # uniqueness must hold without the original's extension
unique_key = str(Path(base_name).with_suffix(""))
if unique_key not in self.exported_files:
self.exported_files.add(unique_key)
break break
else: else:
filename_counter += 1 filename_counter += 1
@@ -538,7 +554,7 @@ class Command(CryptMixin, PaperlessCommand):
document_dict[EXPORTER_FILE_NAME] = original_arc document_dict[EXPORTER_FILE_NAME] = original_arc
if not self.no_thumbnail: if not self.no_thumbnail:
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp") thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
if self.use_folder_prefix: if self.use_folder_prefix:
thumbnail_name = Path("thumbnails") / thumbnail_name thumbnail_name = Path("thumbnails") / thumbnail_name
thumbnail_arc = thumbnail_name.as_posix() thumbnail_arc = thumbnail_name.as_posix()
@@ -547,7 +563,7 @@ class Command(CryptMixin, PaperlessCommand):
thumbnail_arc = None thumbnail_arc = None
if not self.no_archive and document.has_archive_version: if not self.no_archive and document.has_archive_version:
archive_name = base_name.parent / (base_name.stem + "-archive.pdf") archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
if self.use_folder_prefix: if self.use_folder_prefix:
archive_name = Path("archive") / archive_name archive_name = Path("archive") / archive_name
archive_arc = archive_name.as_posix() archive_arc = archive_name.as_posix()
@@ -67,18 +67,22 @@ class Command(PaperlessCommand):
if options.get("recreate"): if options.get("recreate"):
wipe_index(settings.INDEX_DIR) wipe_index(settings.INDEX_DIR)
documents = Document.objects.select_related( documents = (
"correspondent", Document.objects.filter(root_document__isnull=True)
"document_type", .select_related(
"storage_path", "correspondent",
"owner", "document_type",
).prefetch_related( "storage_path",
"tags", "owner",
"notes__user", )
"custom_fields__field", .prefetch_related(
"versions", "tags",
"barcodes", "notes__user",
"versions__barcodes", "custom_fields__field",
"versions",
"barcodes",
"versions__barcodes",
)
) )
total = documents.count() total = documents.count()
rebuild_kwargs = {} rebuild_kwargs = {}
+23 -12
View File
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing] class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024 MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
correspondent = models.ForeignKey( correspondent = models.ForeignKey(
Correspondent, Correspondent,
@@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def archive_file(self): def archive_file(self):
return Path(self.archive_path).open("rb") return Path(self.archive_path).open("rb")
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str: def get_public_filename(
self,
*,
archive: bool = False,
counter: int = 0,
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
) -> str:
""" """
Returns a sanitized filename for the document, not including any paths. Returns a sanitized filename for the document, not including any paths.
The name fits in max_bytes (UTF-8). A long title is shortened before
the counter and extension are appended, because sanitize_filename
truncates from the end. The tail is cut only when max_bytes is
smaller than the tail itself.
""" """
# Root owns metadata for all versions # Root owns metadata for all versions
context_document = ( context_document = (
self.root_document if self.root_document_id is not None else self self.root_document if self.root_document_id is not None else self
) )
result = str(context_document)
if counter: extension = ".pdf" if archive else self.file_type
result += f"_{counter:02}" tail = f"_{counter:02}{extension}" if counter else extension
if suffix: head_bytes = max(0, max_bytes - len(tail.encode()))
result += suffix head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
if archive: return pathvalidate.sanitize_filename(
result += ".pdf" head + tail,
else: replacement_text="-",
result += self.file_type max_len=max_bytes,
)
return pathvalidate.sanitize_filename(result, replacement_text="-")
@property @property
def file_type(self): def file_type(self):
+96 -159
View File
@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import mimetypes import mimetypes
import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -68,6 +68,58 @@ def get_supported_file_extensions() -> set[str]:
return extensions return extensions
def run_convert(
input_file,
output_file,
*,
density=None,
scale=None,
alpha=None,
strip=False,
trim=False,
type=None,
depth=None,
auto_orient=False,
use_cropbox=False,
extra=None,
logging_group=None,
) -> None:
environment = os.environ.copy()
if settings.CONVERT_MEMORY_LIMIT:
# MAGICK_MEMORY_LIMIT sets the maximum amount of RAM the pixel cache can use.
# MAGICK_MAP_LIMIT sets the maximum amount of memory-mapped I/O allowed.
#
# For large-format documents ImageMagick will hit the RAM limit and
# immediately try to "map" the remaining data. If MAGICK_MAP_LIMIT isn't
# also set, the process may trigger an OOM kill because the default
# system/policy map limit is often too restrictive for these massive bitmaps.
environment["MAGICK_MEMORY_LIMIT"] = settings.CONVERT_MEMORY_LIMIT
environment["MAGICK_MAP_LIMIT"] = settings.CONVERT_MEMORY_LIMIT
if settings.CONVERT_TMPDIR:
environment["MAGICK_TMPDIR"] = settings.CONVERT_TMPDIR
args = [settings.CONVERT_BINARY]
args += ["-density", str(density)] if density else []
args += ["-scale", str(scale)] if scale else []
args += ["-alpha", str(alpha)] if alpha else []
args += ["-strip"] if strip else []
args += ["-trim"] if trim else []
args += ["-type", str(type)] if type else []
args += ["-depth", str(depth)] if depth else []
args += ["-auto-orient"] if auto_orient else []
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
args += [str(input_file), str(output_file)]
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
try:
run_subprocess(args, environment, logger)
except subprocess.CalledProcessError as e:
raise ParseError(f"Convert failed at {args}") from e
except Exception as e: # pragma: no cover
raise ParseError("Unknown error running convert") from e
def get_default_thumbnail() -> Path: def get_default_thumbnail() -> Path:
""" """
Returns the path to a generic thumbnail Returns the path to a generic thumbnail
@@ -75,168 +127,46 @@ def get_default_thumbnail() -> Path:
return (Path(__file__).parent / "resources" / "document.webp").resolve() return (Path(__file__).parent / "resources" / "document.webp").resolve()
_THUMBNAIL_MAX_WIDTH = 500 def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None) -> Path:
_THUMBNAIL_MAX_HEIGHT = 5000 out_path: Path = Path(temp_dir) / "convert_gs.webp"
# Used only when the page geometry cannot be read
_THUMBNAIL_FALLBACK_DPI = 150
# Applied before supersampling, so tiny pages are not enlarged
_THUMBNAIL_MAX_DPI = 300
# Rendering at a multiple and downsampling keeps text crisper
_THUMBNAIL_SUPERSAMPLE = 2
def rasterize_pdf_page_to_png(
in_path: Path,
out_path: Path,
*,
dpi: int,
logging_group=None,
) -> None:
"""
Rasterizes the first page of a PDF to a PNG with pdftoppm.
"""
# -singlefile drops the page number and -png appends ".png", so pass the
# path without its suffix
args = [
"pdftoppm",
"-f",
"1",
"-l",
"1",
"-r",
str(dpi),
"-png",
"-singlefile",
"-cropbox",
str(in_path),
str(out_path.with_suffix("")),
]
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
try:
run_subprocess(args, logger=logger)
except subprocess.CalledProcessError as e:
raise ParseError(f"pdftoppm failed at {args}") from e
except Exception as e: # pragma: no cover
raise ParseError("Unknown error running pdftoppm") from e
def encode_thumbnail_webp(
png_path: Path,
out_path: Path,
*,
supersample: int = 1,
) -> None:
"""
Flattens alpha onto white, undoes supersampling, shrinks to fit and saves as WebP.
"""
from PIL import Image
try:
with Image.open(png_path) as im:
if im.mode in ("RGBA", "LA"):
flattened = Image.new("RGB", im.size, (255, 255, 255))
flattened.paste(im, mask=im.split()[-1])
else:
flattened = im.convert("RGB")
if supersample > 1:
flattened = flattened.resize(
(
max(1, round(flattened.width / supersample)),
max(1, round(flattened.height / supersample)),
),
Image.Resampling.LANCZOS,
)
flattened.thumbnail((_THUMBNAIL_MAX_WIDTH, _THUMBNAIL_MAX_HEIGHT))
flattened.save(out_path, format="WEBP")
except (OSError, Image.DecompressionBombError) as e:
raise ParseError(f"Unable to encode thumbnail from {png_path}") from e
def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> tuple[int, int]:
"""
Returns (dpi, supersample). Unknown geometry is not supersampled, since
the render size cannot be bounded.
"""
from paperless.parsers.utils import get_pdf_first_page_size_points
size = get_pdf_first_page_size_points(in_path)
if size is None:
logger.debug(
"Could not read PDF page size, using fallback DPI",
extra={"group": logging_group},
)
return _THUMBNAIL_FALLBACK_DPI, 1
width_pts, height_pts = size
dpi_for_width = _THUMBNAIL_MAX_WIDTH * 72 / width_pts
dpi_for_height = _THUMBNAIL_MAX_HEIGHT * 72 / height_pts
# Round up so the downsampled render is never a few pixels short of the
# target; the shrink-only clamp in encode_thumbnail_webp trims the excess.
dpi = max(
1,
math.ceil(min(_THUMBNAIL_MAX_DPI, dpi_for_width, dpi_for_height)),
)
# At the 1 DPI floor the page is already oversized, so do not supersample
return dpi, 1 if dpi == 1 else _THUMBNAIL_SUPERSAMPLE
def _render_pdf_thumbnail(
in_path: Path,
png_path: Path,
out_path: Path,
logging_group=None,
) -> None:
dpi, supersample = _compute_thumbnail_dpi(in_path, logging_group=logging_group)
rasterize_pdf_page_to_png(
in_path,
png_path,
dpi=dpi * supersample,
logging_group=logging_group,
)
encode_thumbnail_webp(png_path, out_path, supersample=supersample)
def _repair_pdf_with_qpdf(in_path: Path, out_path: Path) -> None:
# qpdf exits 3 after a repair; --warning-exit-0 keeps that from failing
try:
shutil.copy(in_path, out_path)
run_subprocess(
["qpdf", "--warning-exit-0", "--replace-input", str(out_path)],
logger=logger,
)
except (subprocess.CalledProcessError, OSError) as e:
raise ParseError(f"qpdf repair failed for {in_path}") from e
def make_thumbnail_from_pdf_qpdf_fallback(
in_path: Path,
temp_dir: Path,
logging_group=None,
) -> Path:
png_path = temp_dir / "page1_repaired.png"
out_path = temp_dir / "convert_qpdf.webp"
repaired_path = temp_dir / "repaired.pdf"
# if convert fails, fall back to extracting
# the first PDF page as a PNG using Ghostscript
logger.warning( logger.warning(
"Thumbnail generation with pdftoppm failed, attempting qpdf repair and retry.", "Thumbnail generation with ImageMagick failed, falling back "
"to ghostscript. Check your /etc/ImageMagick-x/policy.xml!",
extra={"group": logging_group}, extra={"group": logging_group},
) )
# Ghostscript doesn't handle WebP outputs
gs_out_path: Path = Path(temp_dir) / "gs_out.png"
cmd = [settings.GS_BINARY, "-q", "-sDEVICE=pngalpha", "-o", gs_out_path, in_path]
try: try:
_repair_pdf_with_qpdf(in_path, repaired_path) try:
_render_pdf_thumbnail(repaired_path, png_path, out_path, logging_group) run_subprocess(cmd, logger=logger)
except subprocess.CalledProcessError as e:
raise ParseError(f"Thumbnail (gs) failed at {cmd}") from e
# then run convert on the output from gs to make WebP
run_convert(
density=300,
scale="500x5000>",
alpha="remove",
strip=True,
trim=False,
auto_orient=True,
input_file=gs_out_path,
output_file=out_path,
logging_group=logging_group,
)
return out_path return out_path
except ParseError as e: except ParseError as e:
logger.error(f"Unable to make thumbnail after qpdf repair: {e}") logger.error(f"Unable to make thumbnail with Ghostscript: {e}")
# The caller might expect a generated thumbnail that can be moved, # The caller might expect a generated thumbnail that can be moved,
# so we need to copy it before it gets moved. # so we need to copy it before it gets moved.
# https://github.com/paperless-ngx/paperless-ngx/issues/3631 # https://github.com/paperless-ngx/paperless-ngx/issues/3631
default_thumbnail_path = temp_dir / "document.webp" default_thumbnail_path: Path = Path(temp_dir) / "document.webp"
copy_file_with_basic_stats(get_default_thumbnail(), default_thumbnail_path) copy_file_with_basic_stats(get_default_thumbnail(), default_thumbnail_path)
return default_thumbnail_path return default_thumbnail_path
@@ -245,18 +175,25 @@ def make_thumbnail_from_pdf(in_path: Path, temp_dir: Path, logging_group=None) -
""" """
The thumbnail of a PDF is just a 500px wide image of the first page. The thumbnail of a PDF is just a 500px wide image of the first page.
""" """
png_path: Path = temp_dir / "page1.png"
out_path: Path = temp_dir / "convert.webp" out_path: Path = temp_dir / "convert.webp"
# Run convert to get a decent thumbnail
try: try:
_render_pdf_thumbnail(in_path, png_path, out_path, logging_group) run_convert(
except ParseError as e: density=300,
logger.error(f"Unable to make thumbnail with pdftoppm: {e}") scale="500x5000>",
out_path = make_thumbnail_from_pdf_qpdf_fallback( alpha="remove",
in_path, strip=True,
temp_dir, trim=False,
logging_group, auto_orient=True,
use_cropbox=True,
input_file=f"{in_path}[0]",
output_file=str(out_path),
logging_group=logging_group,
) )
except ParseError as e:
logger.error(f"Unable to make thumbnail with convert: {e}")
out_path = make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group)
return out_path return out_path
+105 -37
View File
@@ -6,7 +6,9 @@ from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Case from django.db.models import Case
from django.db.models import CharField
from django.db.models import Count from django.db.models import Count
from django.db.models import F
from django.db.models import IntegerField from django.db.models import IntegerField
from django.db.models import Model from django.db.models import Model
from django.db.models import Q from django.db.models import Q
@@ -26,6 +28,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoObjectPermissions from rest_framework.permissions import DjangoObjectPermissions
from documents.models import Document from documents.models import Document
from documents.versioning import get_root_document
class PaperlessObjectPermissions(DjangoObjectPermissions): class PaperlessObjectPermissions(DjangoObjectPermissions):
@@ -349,6 +352,7 @@ def permitted_object_ids(
perm: str, perm: str,
*, *,
include_deleted: bool = False, include_deleted: bool = False,
parent_field: str | None = None,
) -> QuerySet[int]: ) -> QuerySet[int]:
""" """
Generic version of ``permitted_document_ids`` for any model with an Generic version of ``permitted_document_ids`` for any model with an
@@ -357,6 +361,24 @@ def permitted_object_ids(
soft-delete pattern (currently only ``Document``); for every other model soft-delete pattern (currently only ``Document``); for every other model
it is accepted but has no effect, since those models have no soft-delete it is accepted but has no effect, since those models have no soft-delete
concept. concept.
``parent_field`` names a self-referencing foreign key whose target
authorizes the row (``Document.root_document``). A row with a parent is
visible exactly when its parent is, judged by the parent's owner and
grants, so the row's own owner and grants are ignored.
Guardian stores ``object_pk`` as a string, so the row key is cast to a
string and tested against the user's and groups' grants with a single
uncorrelated ``IN``. Postgres and SQLite build that set once. MariaDB
evaluates it as an index probe per row, which is cheap because the
lookups use guardian's unique indexes. Casting every ``object_pk`` to an
integer instead cannot use an index, and MariaDB cannot materialize it
inside the owner ``OR``, so it re-scans the user's grants for every row.
A correlated ``EXISTS`` per grant fixes MariaDB too, but Postgres and
SQLite re-run it for every row and end up slower than the original. The
user's groups are matched with an ``IN`` subquery rather than a join
through the membership table, which SQLite plans badly once the grant
tables grow.
""" """
has_soft_delete = hasattr(model, "global_objects") has_soft_delete = hasattr(model, "global_objects")
manager = ( manager = (
@@ -364,8 +386,14 @@ def permitted_object_ids(
) )
base_qs = manager.all().only("id", "owner") base_qs = manager.all().only("id", "owner")
owner_field, key_field = "owner", "pk"
if parent_field is not None:
owner_field, key_field = "authorizing_owner", "authorizing_id"
base_qs = annotate_authorizing_fields(base_qs, parent_field)
unowned = Q(**{f"{owner_field}__isnull": True})
if user is None or not getattr(user, "is_authenticated", False): if user is None or not getattr(user, "is_authenticated", False):
return base_qs.filter(owner__isnull=True).values_list("id", flat=True) return base_qs.filter(unowned).values_list("id", flat=True)
# Deactivated users get nothing, deactivated superusers included, so this # Deactivated users get nothing, deactivated superusers included, so this
# has to come before the superuser shortcut. guardian's # has to come before the superuser shortcut. guardian's
@@ -389,26 +417,51 @@ def permitted_object_ids(
"permission__content_type": content_type, "permission__content_type": content_type,
} }
user_perm_ids = ( # Both grant sets are compared to the row key as strings, exactly as
UserObjectPermission.objects.filter(user=user, **perm_filter) # guardian stores them, and are uncorrelated, so each engine can build the
.annotate(object_pk_int=Cast("object_pk", IntegerField())) # set once instead of probing per row.
.values_list("object_pk_int", flat=True) user_keys = UserObjectPermission.objects.filter(
) user=user,
group_perm_ids = ( **perm_filter,
GroupObjectPermission.objects.filter(group__user=user, **perm_filter) ).values_list("object_pk", flat=True)
.annotate(object_pk_int=Cast("object_pk", IntegerField())) group_keys = GroupObjectPermission.objects.filter(
.values_list("object_pk_int", flat=True) group_id__in=user.groups.values("id"),
) **perm_filter,
permitted_ids = user_perm_ids.union(group_perm_ids) ).values_list("object_pk", flat=True)
permitted_keys = user_keys.union(group_keys, all=True)
return base_qs.filter( return (
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids), base_qs.annotate(permitted_key=Cast(key_field, CharField(max_length=64)))
).values_list("id", flat=True) .filter(
Q(**{owner_field: user.pk}) | unowned | Q(permitted_key__in=permitted_keys),
)
.values_list("id", flat=True)
)
ModelT = TypeVar("ModelT", bound=Model) ModelT = TypeVar("ModelT", bound=Model)
def annotate_authorizing_fields(
queryset: QuerySet[ModelT],
parent_field: str,
) -> QuerySet[ModelT]:
"""
Annotate each row with ``authorizing_id`` and ``authorizing_owner``: the id
and owner of the row that authorizes it. A row with a parent (the
self-referencing foreign key ``parent_field``) is authorized by its parent,
any other row by itself.
"""
return queryset.annotate(
authorizing_id=Coalesce(f"{parent_field}_id", "id"),
authorizing_owner=Case(
When(**{f"{parent_field}_id__isnull": True}, then=F("owner_id")),
default=F(f"{parent_field}__owner_id"),
output_field=IntegerField(),
),
)
def user_is_unrestricted(user: User | None) -> bool: def user_is_unrestricted(user: User | None) -> bool:
""" """
True when ``user`` means "no restriction at all" (an absent user, or an True when ``user`` means "no restriction at all" (an absent user, or an
@@ -458,6 +511,28 @@ def restrict_queryset_to_visible(
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm)) return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
def user_is_active_superuser(user: User | None) -> bool:
"""
True only for an active, authenticated superuser.
"""
return user is not None and user_is_unrestricted(user)
def restrict_queryset_to_owner_visible(
queryset: QuerySet[ModelT],
user: User | None,
perm: str,
) -> QuerySet[ModelT]:
"""
Like ``restrict_queryset_to_visible``, but ``user=None`` sees only unowned
rows (``permitted_object_ids(None, ...)`` semantics) instead of every row.
See ``user_is_active_superuser``.
"""
if user_is_active_superuser(user):
return queryset
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
def permitted_document_ids( def permitted_document_ids(
user: User | None, user: User | None,
*, *,
@@ -471,30 +546,16 @@ def permitted_document_ids(
``include_deleted=True`` for callers that need to check permission on ``include_deleted=True`` for callers that need to check permission on
soft-deleted documents (e.g. trash restore). This intentionally avoids soft-deleted documents (e.g. trash restore). This intentionally avoids
``get_objects_for_user`` to keep the subquery small and index-friendly. ``get_objects_for_user`` to keep the subquery small and index-friendly.
"""
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
A version is authorized by its root document, so a version's own owner and
def documents_without_permitted_root( grants never matter.
documents: QuerySet[Document],
user: User | None,
*,
perm: str = "view_document",
include_deleted: bool = False,
) -> QuerySet[Document]:
""" """
The documents the user lacks ``perm`` on. Versions are authorized by their return permitted_object_ids(
root document, so a version's own owner is ignored. A single query, without user,
loading the documents or joining the root. Document,
""" perm,
return documents.annotate( include_deleted=include_deleted,
root_id=Coalesce("root_document_id", "id"), parent_field="root_document",
).exclude(
root_id__in=permitted_document_ids(
user,
perm=perm,
include_deleted=include_deleted,
),
) )
@@ -654,7 +715,14 @@ def has_perms_owner_aware(user, perms, obj):
single-object check still has many production callers. Several callers single-object check still has many production callers. Several callers
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
-- grep for this function name before removing it. -- grep for this function name before removing it.
A document version is authorized by its root document, like in
``permitted_document_ids``, so a version's own owner and grants never
matter. Fetch the root with ``select_related("root_document__owner")`` to
avoid extra queries.
""" """
if isinstance(obj, Document):
obj = get_root_document(obj)
checker = ObjectPermissionChecker(user) checker = ObjectPermissionChecker(user)
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj) return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
+24 -3
View File
@@ -284,9 +284,14 @@ class WriteBatch:
and adding the new version. This ensures stale document data (e.g., after and adding the new version. This ensures stale document data (e.g., after
permission changes) doesn't persist in the index. permission changes) doesn't persist in the index.
Only root documents are indexed, with their effective content, so a
version is indexed as its root document.
Args: Args:
document: Django Document instance to index document: Django Document instance to index
""" """
if document.root_document_id is not None:
document = document.root_document
self.remove(document.pk) self.remove(document.pk)
doc = self._backend._build_tantivy_doc(document) doc = self._backend._build_tantivy_doc(document)
self._writer.add_document(doc) self._writer.add_document(doc)
@@ -311,20 +316,22 @@ class WriteBatch:
An id with no matching document (e.g. deleted between the caller An id with no matching document (e.g. deleted between the caller
collecting ids and the batch running) is silently skipped, matching collecting ids and the batch running) is silently skipped, matching
``add_or_update()``'s existing single-document deferred-task behavior ``add_or_update()``'s existing single-document deferred-task behavior
rather than erroring or leaving a stale index entry. rather than erroring or leaving a stale index entry. The id of a
version stands for its root document.
Args: Args:
ids: Primary keys of Document instances to index ids: Primary keys of Document instances to index
""" """
from documents.models import Document from documents.models import Document
from documents.versioning import annotate_effective_content from documents.versioning import annotate_effective_content
from documents.versioning import root_document_ids
ids = list(ids) ids = list(ids)
if not ids: if not ids:
return return
queryset = annotate_effective_content( queryset = annotate_effective_content(
Document.objects.filter(pk__in=ids) Document.objects.filter(pk__in=root_document_ids(ids))
.select_related("correspondent", "document_type", "storage_path", "owner") .select_related("correspondent", "document_type", "storage_path", "owner")
.prefetch_related( .prefetch_related(
"tags", "tags",
@@ -919,6 +926,7 @@ class TantivyBackend:
sort_field: str | None = None, sort_field: str | None = None,
sort_reverse: bool = False, sort_reverse: bool = False,
search_mode: SearchMode = SearchMode.QUERY, search_mode: SearchMode = SearchMode.QUERY,
filter_query: str | None = None,
limit: int | None = None, limit: int | None = None,
) -> list[int]: ) -> list[int]:
""" """
@@ -934,6 +942,7 @@ class TantivyBackend:
sort_field: Field to sort by (None for relevance ranking) sort_field: Field to sort by (None for relevance ranking)
sort_reverse: Whether to reverse the sort order sort_reverse: Whether to reverse the sort order
search_mode: Query parsing mode (QUERY, TEXT, or TITLE) search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
filter_query: Optional advanced query results must also match
limit: Maximum number of IDs to return (None = all matching docs) limit: Maximum number of IDs to return (None = all matching docs)
Returns: Returns:
@@ -941,6 +950,13 @@ class TantivyBackend:
""" """
self._ensure_open() self._ensure_open()
user_query = self._parse_query(query, search_mode) user_query = self._parse_query(query, search_mode)
if filter_query is not None:
user_query = user_query.and_must_match(
tantivy.Query.const_score_query(
self._parse_query(filter_query, SearchMode.QUERY),
0.0, # zero so doesn't affect relevance
),
)
final_query = self._apply_permission_filter(user_query, user) final_query = self._apply_permission_filter(user_query, user)
searcher = self._index.searcher() searcher = self._index.searcher()
@@ -1036,16 +1052,21 @@ class TantivyBackend:
excluded from results. excluded from results.
Args: Args:
doc_id: Primary key of the reference document doc_id: Primary key of the reference document, or of one of its
versions
user: User for permission filtering (None for no filtering) user: User for permission filtering (None for no filtering)
limit: Maximum number of IDs to return (None = all matching docs) limit: Maximum number of IDs to return (None = all matching docs)
Returns: Returns:
List of similar document IDs (excluding the original) List of similar document IDs (excluding the original)
""" """
from documents.versioning import root_document_ids
self._ensure_open() self._ensure_open()
searcher = self._index.searcher() searcher = self._index.searcher()
# Only root documents are indexed, so a version stands for its root
doc_id = next(iter(root_document_ids([doc_id])), doc_id)
id_query = tantivy.Query.term_query(self._schema, "id", doc_id) id_query = tantivy.Query.term_query(self._schema, "id", doc_id)
results = searcher.search(id_query, limit=1) results = searcher.search(id_query, limit=1)
+3 -1
View File
@@ -28,7 +28,9 @@ logger = logging.getLogger("paperless.search")
# columns dropped. tantivy compares schemas by ordered field list, so an # columns dropped. tantivy compares schemas by ordered field list, so an
# index built by v1 rejects every write against the v2 schema. # index built by v1 rejects every write against the v2 schema.
# v3 - barcodes JSON field for stored barcode contents # v3 - barcodes JSON field for stored barcode contents
SCHEMA_VERSION: Final[int] = 3 # v4 - root documents only. Earlier indexes may hold document versions under
# their own id and metadata, so they are rebuilt without them.
SCHEMA_VERSION: Final[int] = 4
# Present in the index directory from the moment a full rebuild starts until it # 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 # finishes. If a rebuild is interrupted it is left behind, so the half-built
+1 -2
View File
@@ -90,7 +90,6 @@ from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template from documents.templating.workflows import validate_workflow_template
from documents.validators import uri_validator from documents.validators import uri_validator
from documents.validators import url_validator from documents.validators import url_validator
from documents.versioning import get_root_document
from documents.versioning import has_prefetched_effective_content from documents.versioning import has_prefetched_effective_content
from documents.versioning import sort_versions_newest_first from documents.versioning import sort_versions_newest_first
@@ -2895,7 +2894,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
and has_perms_owner_aware( and has_perms_owner_aware(
self.user, self.user,
"view_document", "view_document",
get_root_document(document), document,
) )
): ):
return document return document
+5 -8
View File
@@ -490,23 +490,20 @@ def update_document_content_maybe_archive_file(
shutil.move(thumbnail, document.thumbnail_path) shutil.move(thumbnail, document.thumbnail_path)
document.refresh_from_db() document.refresh_from_db()
root_document = (
document.root_document if document.root_document_id else document
)
logger.info( logger.info(
f"Updating index for document {root_document.pk} ({document.archive_checksum})", f"Updating index for document {document_id} ({document.archive_checksum})",
) )
from documents.search import get_backend from documents.search import get_backend
get_backend().add_or_update(root_document) get_backend().add_or_update(document)
ai_config = AIConfig() ai_config = AIConfig()
if ai_config.llm_index_enabled: if ai_config.llm_index_enabled:
llm_index_add_or_update_document(root_document) llm_index_add_or_update_document(document)
clear_document_caches(document.pk) clear_document_caches(document.pk)
if root_document.pk != document.pk: if document.root_document_id is not None:
clear_document_caches(root_document.pk) clear_document_caches(document.root_document_id)
except Exception: except Exception:
logger.exception( logger.exception(
+102
View File
@@ -293,6 +293,80 @@ class TestAddOrUpdateIds:
assert backend.search_ids("updated", user=None) == [doc.pk] assert backend.search_ids("updated", user=None) == [doc.pk]
class TestVersionsAreIndexedAsTheirRoot:
"""Only root documents are indexed, with their effective content, so
every write path that is handed a version indexes its root instead."""
@staticmethod
def _root_with_version() -> tuple[Document, Document]:
root = DocumentFactory(title="Statement", content="stale text")
version = DocumentFactory(
title="Statement",
content="latest text",
root_document=root,
version_index=1,
)
return root, version
def test_add_or_update_indexes_the_root_of_a_version(
self,
backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A root document with a version
WHEN:
- The version is passed to add_or_update
THEN:
- The root is indexed with the version's text, and the version is not
"""
root, version = self._root_with_version()
backend.add_or_update(version)
assert backend.search_ids("latest", user=None) == [root.pk]
assert backend.search_ids("stale", user=None) == []
def test_add_or_update_ids_indexes_each_root_once(
self,
backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A root document with a version
WHEN:
- Both ids are passed to add_or_update_ids
THEN:
- The root is indexed once and the version is not indexed
"""
root, version = self._root_with_version()
with backend.batch_update() as batch:
batch.add_or_update_ids([version.pk, root.pk])
assert backend.search_ids("Statement", user=None) == [root.pk]
assert backend.search_ids("latest", user=None) == [root.pk]
def test_add_or_update_ids_resolves_a_lone_version(
self,
backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A root document with a version
WHEN:
- Only the version's id is passed to add_or_update_ids
THEN:
- The root is indexed
"""
root, version = self._root_with_version()
with backend.batch_update() as batch:
batch.add_or_update_ids([version.pk])
assert backend.search_ids("latest", user=None) == [root.pk]
class TestSearch: class TestSearch:
"""Test search query parsing and matching via search_ids.""" """Test search query parsing and matching via search_ids."""
@@ -1062,6 +1136,34 @@ class TestMoreLikeThis:
assert 150 not in ids assert 150 not in ids
assert 151 in ids assert 151 in ids
def test_more_like_this_ids_seeded_by_version_uses_root(
self,
backend: TantivyBackend,
) -> None:
"""A version is not indexed, so it must be looked up as its root."""
root = DocumentFactory.create(
title="Important document",
content="financial information report",
)
version = DocumentFactory.create(
title="Important document",
content="financial information report",
root_document=root,
version_index=1,
)
other = DocumentFactory.create(
title="Another document",
content="financial information report",
)
backend.add_or_update(root)
backend.add_or_update(other)
ids = backend.more_like_this_ids(doc_id=version.pk, user=None)
assert other.pk in ids
assert root.pk not in ids
assert version.pk not in ids
class TestSingleton: class TestSingleton:
"""Test get_backend() and reset_backend() singleton lifecycle.""" """Test get_backend() and reset_backend() singleton lifecycle."""
+2 -1
View File
@@ -835,6 +835,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
for filters in ( for filters in (
{"text": "new doc 2017-03-16"}, {"text": "new doc 2017-03-16"},
{"title_search": "apple"}, {"title_search": "apple"},
{"title_search": "apple", "query": "created:[-1 year to now]"},
): ):
with self.subTest(filters=filters): with self.subTest(filters=filters):
get_backend.return_value.search_ids.return_value = [self.doc2.id] get_backend.return_value.search_ids.return_value = [self.doc2.id]
@@ -886,7 +887,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
"all": True, "all": True,
"filters": { "filters": {
"text": "B", "text": "B",
"query": "c1", "title_search": "c1",
}, },
"method": "set_storage_path", "method": "set_storage_path",
"parameters": {"storage_path": self.sp1.id}, "parameters": {"storage_path": self.sp1.id},
+147 -1
View File
@@ -245,8 +245,68 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(response.data["count"], 1) self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["id"], title_match.id) self.assertEqual(response.data["results"][0]["id"], title_match.id)
def test_simple_search_combined_with_query_filter(self) -> None:
"""
GIVEN:
- A document with the term in its title, created this year
- A document with the term only in its content, created this year
- A document with the term in its title, created years ago
WHEN:
- A title or text search is combined with a relative created
date query, as the filter editor sends it
THEN:
- The simple search keeps its own field scope and the query only
narrows the results (issue #14436)
"""
this_year = timezone.localdate().replace(month=1, day=1)
title_match = Document.objects.create(
title="foo report",
content="nothing relevant",
checksum="F1",
created=this_year,
pk=21,
)
content_match = Document.objects.create(
title="something else",
content="foo appears here",
checksum="F2",
created=this_year,
pk=22,
)
old_title_match = Document.objects.create(
title="foo archive",
content="nothing relevant",
checksum="F3",
created=datetime.date(2001, 1, 1),
pk=23,
)
backend = get_backend()
for doc in (title_match, content_match, old_title_match):
backend.add_or_update(doc)
query = "created:[-1 year to now]"
response = self.client.get(
"/api/documents/",
{"title_search": "foo", "query": query},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[r["id"] for r in response.data["results"]],
[title_match.id],
)
response = self.client.get(
"/api/documents/",
{"text": "foo", "query": query},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertCountEqual(
[r["id"] for r in response.data["results"]],
[title_match.id, content_match.id],
)
def test_search_rejects_multiple_search_modes(self) -> None: def test_search_rejects_multiple_search_modes(self) -> None:
response = self.client.get("/api/documents/?text=bank&query=bank") response = self.client.get("/api/documents/?text=bank&title_search=bank")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual( self.assertEqual(
response.data["detail"], response.data["detail"],
@@ -1196,6 +1256,59 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertIn(d3.id, result_ids) self.assertIn(d3.id, result_ids)
self.assertNotIn(d4.id, result_ids) self.assertNotIn(d4.id, result_ids)
def test_search_more_like_version_uses_its_root(self) -> None:
"""
GIVEN:
- A document similar in content to a root document, and one that is not
- A version of the root document, which is never indexed
WHEN:
- API request for more like the version
THEN:
- The documents similar to the root are returned, not the version
"""
indexed = {}
for name, title, content, day in (
("root", "bank statement 1", "things i paid for in august", (2019, 3, 4)),
(
"similar",
"bank statement 3",
"things i paid for in september",
(2020, 7, 9),
),
(
"other",
"Quarterly Report",
"quarterly revenue profit margin",
(2021, 11, 30),
),
):
with time_machine.travel(
timezone.make_aware(datetime.datetime(*day)),
tick=False,
):
indexed[name] = DocumentFactory(
title=title,
content=content,
created=datetime.date(*day),
added=timezone.make_aware(datetime.datetime(*day)),
)
version = DocumentFactory(
root_document=indexed["root"],
version_index=1,
content="things i paid for in august",
)
backend = get_backend()
for document in indexed.values():
backend.add_or_update(document)
response = self.client.get(f"/api/documents/?more_like_id={version.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
result_ids = [r["id"] for r in response.data["results"]]
self.assertIn(indexed["similar"].id, result_ids)
self.assertNotIn(indexed["other"].id, result_ids)
self.assertNotIn(version.id, result_ids)
def test_more_like_requires_id_of_existing_document(self) -> None: def test_more_like_requires_id_of_existing_document(self) -> None:
""" """
GIVEN: GIVEN:
@@ -1261,6 +1374,39 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions.") self.assertEqual(response.content, b"Insufficient permissions.")
def test_search_more_like_version_requires_view_permission_on_its_root(
self,
) -> None:
"""
GIVEN:
- Another user's private root document with an unowned version
WHEN:
- A user without access requests more-like-this for the version
THEN:
- The request is rejected, the version's missing owner grants nothing
"""
owner = UserFactory(username="owner")
attacker = UserFactory(username="attacker")
grant_global(attacker, "view_document")
private_root = DocumentFactory(
title="private bank statement",
content="quarterly treasury bank statement wire transfer",
owner=owner,
)
version = DocumentFactory(
root_document=private_root,
version_index=1,
owner=None,
)
get_backend().add_or_update(private_root)
self.client.force_authenticate(user=attacker)
response = self.client.get(f"/api/documents/?more_like_id={version.id}")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions.")
def test_search_filtering(self) -> None: def test_search_filtering(self) -> None:
t = Tag.objects.create(name="tag") t = Tag.objects.create(name="tag")
t2 = Tag.objects.create(name="tag2") t2 = Tag.objects.create(name="tag2")
+44
View File
@@ -14,6 +14,7 @@ from django.test import override_settings
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from pytest_django.fixtures import Settings from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from sklearn.neural_network import MLPClassifier
from documents.classifier import ClassifierModelCorruptError from documents.classifier import ClassifierModelCorruptError
from documents.classifier import DocumentClassifier from documents.classifier import DocumentClassifier
@@ -1036,6 +1037,49 @@ def test_classifier_match_threshold_default() -> None:
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3 assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
class TestPredictWithThresholdSingleClass:
@pytest.fixture
def single_class_model(self) -> tuple[MLPClassifier, np.ndarray]:
X = np.random.default_rng(0).random((2, 5))
return MLPClassifier(tol=0.01, random_state=0).fit(X, [3, 3]), X[:1]
def test_returns_only_class(
self,
single_class_model: tuple[MLPClassifier, np.ndarray],
) -> None:
"""
GIVEN:
- A classifier fitted on a single class (every document shares one label)
WHEN:
- A prediction is requested with the threshold disabled
THEN:
- No error is raised and the only class is returned
"""
classifier, X = single_class_model
assert _predict_with_threshold(classifier, X, threshold=0.0) == 3
def test_applies_threshold(
self,
single_class_model: tuple[MLPClassifier, np.ndarray],
) -> None:
"""
GIVEN:
- A classifier fitted on a single class
WHEN:
- A prediction is requested
THEN:
- The class's confidence is still compared against the threshold
"""
classifier, X = single_class_model
confidence = classifier.predict_proba(X)[0][0]
assert _predict_with_threshold(classifier, X, threshold=confidence - 0.01) == 3
assert (
_predict_with_threshold(classifier, X, threshold=confidence + 0.01) is None
)
class TestPreprocessContent: class TestPreprocessContent:
@pytest.fixture @pytest.fixture
def samples(self) -> Path: def samples(self) -> Path:
+28
View File
@@ -34,8 +34,10 @@ from documents.tests.utils import GetConsumerMixin
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory from paperless_testing.factories import UserFactory
from paperless_testing.fakes.progress import FakeProgressManager from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.permissions import grant_object
class _BaseNewStyleParser: class _BaseNewStyleParser:
@@ -1683,6 +1685,32 @@ class TestMetadataOverrides(TestCase):
self.assertEqual(base.actor_id, 2) self.assertEqual(base.actor_id, 2)
self.assertEqual(base.version_label, "v2") self.assertEqual(base.version_label, "v2")
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
"""
GIVEN:
- A document with view and change granted directly to one user and
to a group with another user as a member
WHEN:
- Overrides are built from the document
THEN:
- The user lists hold only the directly granted user
- The group lists hold the group
"""
direct_user = UserFactory()
group_member = UserFactory()
group = Group.objects.create(name="group")
group_member.groups.add(group)
document = DocumentFactory()
grant_object(direct_user, document, "view_document", "change_document")
grant_object(group, document, "view_document", "change_document")
overrides = DocumentMetadataOverrides.from_document(document)
self.assertEqual(overrides.view_users, [direct_user.pk])
self.assertEqual(overrides.change_users, [direct_user.pk])
self.assertEqual(overrides.view_groups, [group.pk])
self.assertEqual(overrides.change_groups, [group.pk])
class TestBarcodeApplyDetectedASN(TestCase): class TestBarcodeApplyDetectedASN(TestCase):
""" """
@@ -4,13 +4,16 @@ from datetime import date
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import pytest
from django.test import TestCase from django.test import TestCase
from django.test import override_settings from django.test import override_settings
from faker import Faker from faker import Faker
from pytest_mock import MockerFixture
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
from documents.tasks import empty_trash from documents.tasks import empty_trash
from paperless_testing.factories import DocumentFactory
class TestDocument(TestCase): class TestDocument(TestCase):
@@ -257,3 +260,72 @@ def test_suggestion_content() -> None:
) )
doc.content = content_over_limit doc.content = content_over_limit
assert doc.suggestion_content == expected_cropped_content assert doc.suggestion_content == expected_cropped_content
class TestPublicFilenameLength:
@pytest.fixture(autouse=True)
def _no_parser_registry(self, mocker: MockerFixture) -> None:
# The parser registry reads ApplicationConfiguration from the database
extensions = {"application/pdf": ".pdf", "image/jpeg": ".jpg"}
mocker.patch(
"documents.models.get_default_file_extension",
side_effect=extensions.__getitem__,
)
LIMIT = Document.MAX_PUBLIC_FILENAME_BYTES
@pytest.mark.parametrize(
("title", "archive", "counter", "max_bytes", "tail"),
[
pytest.param("a" * 300, False, 0, LIMIT, ".jpg", id="ascii"),
pytest.param("a" * 300, True, 0, LIMIT, ".pdf", id="archive"),
pytest.param("ä" * 200, False, 0, LIMIT, ".jpg", id="umlaut"),
pytest.param("ä" * 200, False, 1, LIMIT, "_01.jpg", id="count"),
pytest.param("a" * 300, False, 1, 100, "_01.jpg", id="max"),
],
)
def test_long_title_keeps_tail_within_limit(
self,
title: str,
*,
archive: bool,
counter: int,
max_bytes: int,
tail: str,
) -> None:
"""
GIVEN:
- A document whose title alone is longer than the byte limit
WHEN:
- The public file name is generated
THEN:
- The name ends with the counter and extension
- The name fits in the limit
"""
name = DocumentFactory.build(
title=title,
mime_type="image/jpeg",
).get_public_filename(
archive=archive,
counter=counter,
max_bytes=max_bytes,
)
assert name.endswith(tail)
assert len(name.encode()) <= max_bytes
def test_long_title_counters_give_distinct_names(self) -> None:
"""
GIVEN:
- A document whose title alone is longer than the byte limit
WHEN:
- Public file names are generated with increasing counters
THEN:
- Each counter produces a distinct name
"""
doc = DocumentFactory.build(title="ä" * 200, mime_type="application/pdf")
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
assert len(set(names)) == 3
assert all(len(n.encode()) <= self.LIMIT for n in names)
+22
View File
@@ -22,6 +22,7 @@ from documents.models import Document
from documents.tasks import update_document_content_maybe_archive_file from documents.tasks import update_document_content_maybe_archive_file
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf" sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
@@ -116,6 +117,27 @@ class TestMakeIndex:
call_command("document_index", "reindex", skip_checks=True) call_command("document_index", "reindex", skip_checks=True)
mock_get_backend.return_value.rebuild.assert_called_once() mock_get_backend.return_value.rebuild.assert_called_once()
def test_reindex_skips_versions(self, mocker: MockerFixture) -> None:
"""
GIVEN:
- A root document with a version
WHEN:
- The reindex command runs
THEN:
- Only the root document is handed to the rebuild, since a version
is indexed as its root
"""
root = DocumentFactory()
DocumentFactory(root_document=root, version_index=1)
mock_get_backend = mocker.patch(
"documents.management.commands.document_index.get_backend",
)
call_command("document_index", "reindex", skip_checks=True)
documents = mock_get_backend.return_value.rebuild.call_args.args[0]
assert list(documents.values_list("pk", flat=True)) == [root.pk]
def test_optimize(self) -> None: def test_optimize(self) -> None:
"""Optimize command must execute without error (Tantivy handles optimization automatically).""" """Optimize command must execute without error (Tantivy handles optimization automatically)."""
call_command("document_index", "optimize", skip_checks=True) call_command("document_index", "optimize", skip_checks=True)
@@ -2,6 +2,7 @@ import hashlib
import json import json
import shutil import shutil
import tempfile import tempfile
from datetime import date
from datetime import timedelta from datetime import timedelta
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
@@ -44,14 +45,18 @@ from documents.models import Workflow
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.sanity_checker import check_sanity from documents.sanity_checker import check_sanity
from documents.settings import EXPORTER_ARCHIVE_NAME
from documents.settings import EXPORTER_FILE_NAME from documents.settings import EXPORTER_FILE_NAME
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
from documents.settings import EXPORTER_THUMBNAIL_NAME
from documents.tests.utils import SampleDirMixin from documents.tests.utils import SampleDirMixin
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.dirs import PaperlessDirs
from paperless_testing.dirs import paperless_environment from paperless_testing.dirs import paperless_environment
from paperless_testing.factories import DocumentBarcodeFactory from paperless_testing.factories import DocumentBarcodeFactory
from paperless_testing.factories import DocumentFactory
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
@@ -1462,3 +1467,140 @@ class TestCryptExportImport(
("No passphrase was given, sensitive fields will be in plaintext"), ("No passphrase was given, sensitive fields will be in plaintext"),
stdout.read(), stdout.read(),
) )
@pytest.mark.management
@pytest.mark.django_db
class TestExportDerivedFileNameCollisions:
def test_same_stem_different_extension_get_distinct_derived_files(
self,
paperless_dirs: PaperlessDirs,
tmp_path: Path,
) -> None:
"""
GIVEN:
- Two documents with the same title and created date
- One original is a PDF, the other a PNG, both with archive and thumbnail
WHEN:
- The exporter runs
THEN:
- Each document is exported under a distinct original, thumbnail and archive name
- Each exported file holds its own document's content
"""
for pk, (filename, mime_type) in enumerate(
[("collide.pdf", "application/pdf"), ("collide.png", "image/png")],
start=1,
):
(paperless_dirs.originals_dir / filename).write_bytes(
f"original {pk}".encode(),
)
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
f"archive {pk}".encode(),
)
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
f"thumbnail {pk}".encode(),
)
DocumentFactory(
pk=pk,
title="collide",
created=date(2026, 10, 7),
filename=filename,
archive_filename=f"{pk:07}.pdf",
mime_type=mime_type,
)
target = tmp_path / "export"
target.mkdir()
call_command(
"document_exporter",
str(target),
"--no-progress-bar",
skip_checks=True,
)
manifest = json.loads((target / "manifest.json").read_text())
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
for key in (
EXPORTER_FILE_NAME,
EXPORTER_THUMBNAIL_NAME,
EXPORTER_ARCHIVE_NAME,
):
assert records[1][key] != records[2][key]
for pk in (1, 2):
record = records[pk]
assert (target / record[EXPORTER_FILE_NAME]).read_text() == f"original {pk}"
assert (
target / record[EXPORTER_THUMBNAIL_NAME]
).read_text() == f"thumbnail {pk}"
assert (
target / record[EXPORTER_ARCHIVE_NAME]
).read_text() == f"archive {pk}"
def test_long_titles_export_within_file_name_limit(
self,
paperless_dirs: PaperlessDirs,
tmp_path: Path,
) -> None:
"""
GIVEN:
- Two documents with the same created date and the same title
- The title alone is longer than 255 bytes and contains umlauts
WHEN:
- The exporter runs
THEN:
- The export finishes with distinct original, thumbnail and archive names
- Every exported file name fits in the byte limit
- Each exported file holds its own document's content
"""
for pk in (1, 2):
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
f"original {pk}".encode(),
)
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
f"archive {pk}".encode(),
)
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
f"thumbnail {pk}".encode(),
)
DocumentFactory(
pk=pk,
title="Bestellbestätigung " * 15,
created=date(2026, 10, 15),
filename=f"{pk:07}.pdf",
archive_filename=f"{pk:07}.pdf",
mime_type="application/pdf",
)
target = tmp_path / "export"
target.mkdir()
call_command(
"document_exporter",
str(target),
"--no-progress-bar",
skip_checks=True,
)
manifest = json.loads((target / "manifest.json").read_text())
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
for key in (
EXPORTER_FILE_NAME,
EXPORTER_THUMBNAIL_NAME,
EXPORTER_ARCHIVE_NAME,
):
assert records[1][key] != records[2][key]
for pk in (1, 2):
record = records[pk]
for key, content in (
(EXPORTER_FILE_NAME, f"original {pk}"),
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
):
assert (
len(Path(record[key]).name.encode())
<= Document.MAX_PUBLIC_FILENAME_BYTES
)
assert (target / record[key]).read_text() == content
-382
View File
@@ -1,22 +1,11 @@
import subprocess
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path
import pikepdf
import pytest import pytest
from PIL import Image
from pytest_django.fixtures import Settings from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture
from documents.parsers import ParseError
from documents.parsers import _compute_thumbnail_dpi
from documents.parsers import encode_thumbnail_webp
from documents.parsers import get_default_file_extension from documents.parsers import get_default_file_extension
from documents.parsers import get_default_thumbnail
from documents.parsers import get_supported_file_extensions from documents.parsers import get_supported_file_extensions
from documents.parsers import is_file_ext_supported from documents.parsers import is_file_ext_supported
from documents.parsers import make_thumbnail_from_pdf
from documents.parsers import rasterize_pdf_page_to_png
from paperless.parsers.registry import get_parser_registry from paperless.parsers.registry import get_parser_registry
from paperless.parsers.registry import reset_parser_registry from paperless.parsers.registry import reset_parser_registry
from paperless.parsers.tesseract import RasterisedDocumentParser from paperless.parsers.tesseract import RasterisedDocumentParser
@@ -136,374 +125,3 @@ class TestParserAvailability:
assert is_file_ext_supported(".pdf") assert is_file_ext_supported(".pdf")
assert not is_file_ext_supported(".hsdfh") assert not is_file_ext_supported(".hsdfh")
assert not is_file_ext_supported("") assert not is_file_ext_supported("")
class TestComputeThumbnailDpi:
@pytest.mark.parametrize(
("size", "expected"),
[
pytest.param((612.0, 792.0), (59, 2), id="letter-width-bound"),
pytest.param((792.0, 612.0), (46, 2), id="landscape-rounded-up"),
pytest.param((612.0, 100000.0), (4, 2), id="tall-strip-height-bound"),
pytest.param((200.0, 300.0), (180, 2), id="small-page-width-bound"),
pytest.param((72.0, 72.0), (300, 2), id="tiny-page-capped-at-300"),
pytest.param(
(1000000.0, 1000000.0),
(1, 1),
id="huge-page-minimum-one-unsupersampled",
),
pytest.param(None, (150, 1), id="unreadable-geometry-fallback"),
],
)
def test_dpi_from_page_size(
self,
mocker: MockerFixture,
tmp_path: Path,
size: tuple[float, float] | None,
expected: tuple[int, int],
) -> None:
"""
GIVEN:
- A first page of the given size, or unreadable geometry
WHEN:
- The thumbnail DPI is computed
THEN:
- The expected DPI and supersample factor are returned
"""
mocker.patch(
"paperless.parsers.utils.get_pdf_first_page_size_points",
return_value=size,
)
assert _compute_thumbnail_dpi(tmp_path / "doc.pdf") == expected
class TestMakeThumbnailFromPdf:
@pytest.fixture
def work_dir(self, tmp_path: Path) -> Path:
path = tmp_path / "work"
path.mkdir()
return path
@staticmethod
def _write_blank_pdf(path: Path, page_size: tuple[int, int] = (612, 792)) -> Path:
pdf = pikepdf.new()
pdf.add_blank_page(page_size=page_size)
pdf.save(path, object_stream_mode=pikepdf.ObjectStreamMode.disable)
return path
@pytest.mark.parametrize(
("size", "expected_dpi", "expected_supersample"),
[
pytest.param((612.0, 792.0), 118, 2, id="known-geometry-2x"),
pytest.param(None, 150, 1, id="unreadable-geometry-plain-fallback"),
],
)
def test_render_dpi_requested(
self,
mocker: MockerFixture,
tmp_path: Path,
work_dir: Path,
size: tuple[float, float] | None,
expected_dpi: int,
expected_supersample: int,
) -> None:
"""
GIVEN:
- Readable or unreadable page geometry
WHEN:
- A thumbnail is made
THEN:
- Rasterize and encode get the matching DPI and supersample factor
"""
mocker.patch(
"paperless.parsers.utils.get_pdf_first_page_size_points",
return_value=size,
)
rasterize = mocker.patch("documents.parsers.rasterize_pdf_page_to_png")
encode = mocker.patch("documents.parsers.encode_thumbnail_webp")
make_thumbnail_from_pdf(tmp_path / "in.pdf", work_dir)
assert rasterize.call_args.kwargs["dpi"] == expected_dpi
assert encode.call_args.kwargs["supersample"] == expected_supersample
@pytest.mark.parametrize(
("page_size", "expected_width"),
[
pytest.param((612, 792), 500, id="letter"),
pytest.param((792, 612), 500, id="landscape-letter"),
pytest.param((595, 842), 500, id="a4"),
pytest.param((200, 300), 500, id="small-page"),
pytest.param((72, 72), 300, id="tiny-page-capped"),
],
)
def test_thumbnail_width(
self,
tmp_path: Path,
work_dir: Path,
page_size: tuple[int, int],
expected_width: int,
) -> None:
"""
GIVEN:
- A PDF whose first page has the given size in points
WHEN:
- A thumbnail is made from it
THEN:
- The thumbnail has the expected width
"""
pdf_path = self._write_blank_pdf(tmp_path / "in.pdf", page_size)
thumb = make_thumbnail_from_pdf(pdf_path, work_dir)
assert thumb == work_dir / "convert.webp"
with Image.open(thumb) as im:
assert im.format == "WEBP"
assert im.width == expected_width
@classmethod
def _write_pdf_without_xref(cls, path: Path) -> Path:
"""
Cuts off the xref and trailer, which pdftoppm cannot recover from but qpdf can.
"""
cls._write_blank_pdf(path)
data = path.read_bytes()
path.write_bytes(data[: data.rindex(b"\nxref")])
return path
def test_qpdf_repair_produces_real_thumbnail(
self,
tmp_path: Path,
work_dir: Path,
) -> None:
"""
GIVEN:
- A PDF with its xref table and trailer cut off
WHEN:
- A thumbnail is made from it
THEN:
- The thumbnail is rendered from a qpdf repaired copy
- The original file is unchanged
"""
pdf_path = self._write_pdf_without_xref(tmp_path / "broken.pdf")
original_bytes = pdf_path.read_bytes()
with pytest.raises(ParseError):
rasterize_pdf_page_to_png(pdf_path, work_dir / "probe.png", dpi=50)
thumb = make_thumbnail_from_pdf(pdf_path, work_dir)
assert thumb == work_dir / "convert_qpdf.webp"
with Image.open(thumb) as im:
assert im.format == "WEBP"
assert im.width == 500
assert pdf_path.read_bytes() == original_bytes
@pytest.mark.parametrize(
"qpdf_error",
[
pytest.param(subprocess.CalledProcessError(2, "qpdf"), id="qpdf-fails"),
pytest.param(None, id="repaired-still-unrenderable"),
],
)
def test_double_failure_uses_default_thumbnail(
self,
mocker: MockerFixture,
tmp_path: Path,
work_dir: Path,
qpdf_error: subprocess.CalledProcessError | None,
) -> None:
"""
GIVEN:
- A PDF that cannot be rendered, even after qpdf repair
WHEN:
- A thumbnail is made from it
THEN:
- A copy of the default thumbnail is returned
"""
mocker.patch(
"documents.parsers.rasterize_pdf_page_to_png",
side_effect=ParseError("Does not compute."),
)
if qpdf_error is not None:
mocker.patch("documents.parsers.run_subprocess", side_effect=qpdf_error)
pdf_path = self._write_blank_pdf(tmp_path / "in.pdf")
thumb = make_thumbnail_from_pdf(pdf_path, work_dir)
assert thumb == work_dir / "document.webp"
assert thumb.read_bytes() == get_default_thumbnail().read_bytes()
class TestRasterizePdfPageToPng:
@staticmethod
def _write_pdf(
path: Path,
*,
crop_box: tuple[float, float, float, float] | None = None,
rotate: int | None = None,
) -> Path:
pdf = pikepdf.new()
pdf.add_blank_page(page_size=(144, 72))
pdf.add_blank_page(page_size=(300, 300))
page = pdf.pages[0]
if crop_box is not None:
page.obj.CropBox = pikepdf.Array(crop_box)
if rotate is not None:
page.obj.Rotate = rotate
pdf.save(path)
return path
@pytest.mark.parametrize(
("crop_box", "rotate", "expected_size"),
[
pytest.param(None, None, (144, 72), id="plain"),
pytest.param(None, 90, (72, 144), id="rotated-90"),
pytest.param((0, 0, 72, 36), None, (72, 36), id="crop-box"),
],
)
def test_renders_first_page_to_exact_path(
self,
tmp_path: Path,
crop_box: tuple[float, float, float, float] | None,
rotate: int | None,
expected_size: tuple[int, int],
) -> None:
"""
GIVEN:
- A two page PDF, first page optionally cropped or rotated
WHEN:
- The first page is rasterized at 72 DPI
THEN:
- Only out_path is written, sized to the first page's crop and rotation
"""
pdf_path = self._write_pdf(
tmp_path / "in.pdf",
crop_box=crop_box,
rotate=rotate,
)
out_dir = tmp_path / "out"
out_dir.mkdir()
out_path = out_dir / "page1.png"
rasterize_pdf_page_to_png(pdf_path, out_path, dpi=72)
assert list(out_dir.iterdir()) == [out_path]
with Image.open(out_path) as im:
assert im.format == "PNG"
assert im.size == expected_size
def test_failure_raises_parse_error(self, tmp_path: Path) -> None:
"""
GIVEN:
- A file that is not a PDF
WHEN:
- Rasterization is attempted
THEN:
- A ParseError is raised
"""
bad = tmp_path / "bad.pdf"
bad.write_bytes(b"not a pdf")
with pytest.raises(ParseError):
rasterize_pdf_page_to_png(bad, tmp_path / "page1.png", dpi=72)
class TestEncodeThumbnailWebp:
@pytest.mark.parametrize(
("mode", "color"),
[
pytest.param("RGBA", (0, 0, 0, 0), id="rgba"),
pytest.param("LA", (0, 0), id="la"),
],
)
def test_alpha_flattened_onto_white(
self,
tmp_path: Path,
mode: str,
color: tuple[int, ...],
) -> None:
"""
GIVEN:
- A fully transparent PNG with an alpha channel
WHEN:
- It is encoded as a thumbnail
THEN:
- The WebP output is RGB with the transparency flattened to white
"""
png_path = tmp_path / "in.png"
Image.new(mode, (20, 10), color).save(png_path)
out_path = tmp_path / "out.webp"
encode_thumbnail_webp(png_path, out_path)
with Image.open(out_path) as im:
assert im.format == "WEBP"
assert im.mode == "RGB"
assert im.size == (20, 10)
red, green, blue = im.getpixel((10, 5))
assert min(red, green, blue) >= 250
@pytest.mark.parametrize(
("in_size", "supersample", "expected_size"),
[
pytest.param((1000, 2000), 1, (500, 1000), id="too-wide-shrunk"),
pytest.param((100, 10000), 1, (50, 5000), id="too-tall-shrunk"),
pytest.param((100, 200), 1, (100, 200), id="small-not-enlarged"),
pytest.param((1000, 1400), 2, (500, 700), id="2x-halved"),
pytest.param((1001, 1401), 2, (500, 700), id="2x-odd-rounded"),
pytest.param((600, 800), 2, (300, 400), id="2x-small-not-enlarged"),
pytest.param((900, 1200), 2, (450, 600), id="2x-below-clamp"),
],
)
def test_size_clamped_and_downsampled(
self,
tmp_path: Path,
in_size: tuple[int, int],
supersample: int,
expected_size: tuple[int, int],
) -> None:
"""
GIVEN:
- A rendered image and its supersample factor
WHEN:
- It is encoded as a thumbnail with that factor
THEN:
- It is downsampled, fit within 500x5000 and never enlarged
"""
png_path = tmp_path / "in.png"
Image.new("RGB", in_size, (255, 255, 255)).save(png_path)
out_path = tmp_path / "out.webp"
encode_thumbnail_webp(png_path, out_path, supersample=supersample)
with Image.open(out_path) as im:
assert im.size == expected_size
@pytest.mark.parametrize(
"error",
[
pytest.param(OSError("broken image"), id="os-error"),
pytest.param(Image.DecompressionBombError("too large"), id="bomb"),
],
)
def test_decode_failure_raises_parse_error(
self,
tmp_path: Path,
mocker: MockerFixture,
error: Exception,
) -> None:
"""
GIVEN:
- Opening the rendered image fails
WHEN:
- It is encoded as a thumbnail
THEN:
- A ParseError is raised
"""
png_path = tmp_path / "in.png"
Image.new("RGB", (10, 10)).save(png_path)
mocker.patch("PIL.Image.open", side_effect=error)
with pytest.raises(ParseError):
encode_thumbnail_webp(png_path, tmp_path / "out.webp")
@@ -18,6 +18,7 @@ from documents.models import Correspondent
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible from documents.permissions import restrict_queryset_to_visible
@@ -32,6 +33,8 @@ from paperless_testing.permissions import grant_global
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
if TYPE_CHECKING: if TYPE_CHECKING:
from django.contrib.auth.models import User
from paperless_testing.dirs import PaperlessDirs from paperless_testing.dirs import PaperlessDirs
@@ -178,6 +181,382 @@ class TestPermittedDocumentIdsIncludeDeleted:
) )
@pytest.mark.django_db
class TestPermittedDocumentIdsVersions:
"""
A version is authorized by its root document: the version's own owner and
grants never matter.
"""
@pytest.mark.parametrize(
("root_owner", "version_owner", "expected_visible"),
[
pytest.param(
"other",
"nobody",
False,
id="unowned-version-of-private-root",
),
pytest.param("other", "user", False, id="own-version-of-private-root"),
pytest.param("user", "other", True, id="foreign-version-of-own-root"),
pytest.param("user", "nobody", True, id="unowned-version-of-own-root"),
pytest.param("nobody", "other", True, id="private-version-of-unowned-root"),
],
)
def test_version_follows_root_owner(
self,
root_owner: str,
version_owner: str,
*,
expected_visible: bool,
) -> None:
"""
GIVEN:
- A root document and a version with differing owners
WHEN:
- The permitted document ids are resolved for the user
THEN:
- The version is visible exactly when its root is
"""
user = UserFactory()
owners = {"user": user, "other": UserFactory(), "nobody": None}
root = DocumentFactory(owner=owners[root_owner])
version = DocumentFactory(root_document=root, owner=owners[version_owner])
visible = set(permitted_document_ids(user))
assert (version.pk in visible) is expected_visible
assert (root.pk in visible) is expected_visible
@staticmethod
def grantee(user: User, kind: str) -> User | Group:
"""The user itself, or a new group the user belongs to."""
if kind == "user":
return user
group = Group.objects.create(name="shared")
user.groups.add(group)
return group
@pytest.mark.parametrize(
"grantee_kind",
[pytest.param("user", id="user"), pytest.param("group", id="group")],
)
def test_grant_on_root_applies_to_version(self, grantee_kind: str) -> None:
"""
GIVEN:
- A private root document shared with a user or one of their groups
- A version of it owned by someone else
WHEN:
- The permitted document ids are resolved for the user
THEN:
- Both the root and the version are visible
- A user without the grant sees neither
"""
user = UserFactory()
stranger = UserFactory()
root = DocumentFactory(owner=UserFactory())
version = DocumentFactory(root_document=root, owner=UserFactory())
grant_object(self.grantee(user, grantee_kind), root, "view_document")
assert_visible_document_ids(
permitted_document_ids(user),
expected_visible=[root.pk, version.pk],
expected_hidden=[],
)
assert_visible_document_ids(
permitted_document_ids(stranger),
expected_visible=[],
expected_hidden=[root.pk, version.pk],
)
@pytest.mark.parametrize(
"grantee_kind",
[pytest.param("user", id="user"), pytest.param("group", id="group")],
)
def test_grant_on_version_is_ignored(self, grantee_kind: str) -> None:
"""
GIVEN:
- A private root document
- A version with an explicit grant for the user or one of their groups
WHEN:
- The permitted document ids are resolved for the user
THEN:
- Neither the root nor the version is visible
"""
user = UserFactory()
root = DocumentFactory(owner=UserFactory())
version = DocumentFactory(root_document=root, owner=UserFactory())
grant_object(self.grantee(user, grantee_kind), version, "view_document")
assert_visible_document_ids(
permitted_document_ids(user),
expected_visible=[],
expected_hidden=[root.pk, version.pk],
)
def test_grant_on_one_root_does_not_reach_another_roots_version(self) -> None:
"""
GIVEN:
- Two private roots, each with a version
- The user may view only the first root
WHEN:
- The permitted document ids are resolved for the user
THEN:
- Only the first root and its version are visible
"""
user = UserFactory()
first = DocumentFactory(owner=UserFactory())
first_version = DocumentFactory(root_document=first, owner=UserFactory())
second = DocumentFactory(owner=UserFactory())
second_version = DocumentFactory(root_document=second, owner=user)
grant_object(user, first, "view_document")
assert_visible_document_ids(
permitted_document_ids(user),
expected_visible=[first.pk, first_version.pk],
expected_hidden=[second.pk, second_version.pk],
)
def test_user_in_several_groups(self) -> None:
"""
GIVEN:
- A user in two groups
- Two private roots shared with one group each, and a third shared with nobody
- A version of each root
WHEN:
- The permitted document ids are resolved for the user
THEN:
- The two shared roots and their versions are visible
- The third root and its version are not
"""
user = UserFactory()
groups = [Group.objects.create(name=f"group{i}") for i in range(2)]
user.groups.add(*groups)
shared = [DocumentFactory(owner=UserFactory()) for _ in groups]
for root, group in zip(shared, groups, strict=True):
grant_object(group, root, "view_document")
unshared = DocumentFactory(owner=UserFactory())
shared_versions = [
DocumentFactory(root_document=root, owner=UserFactory()) for root in shared
]
unshared_version = DocumentFactory(root_document=unshared, owner=None)
assert_visible_document_ids(
permitted_document_ids(user),
expected_visible=[
*(root.pk for root in shared),
*(version.pk for version in shared_versions),
],
expected_hidden=[unshared.pk, unshared_version.pk],
)
def test_permission_is_resolved_through_the_root(self) -> None:
"""
GIVEN:
- A private root document where the user may view and change
WHEN:
- The permitted ids are resolved for view, change and delete
THEN:
- The version is visible for view and change only
"""
user = UserFactory()
root = DocumentFactory(owner=UserFactory())
version = DocumentFactory(root_document=root, owner=UserFactory())
grant_object(user, root, "view_document", "change_document")
assert version.pk in set(permitted_document_ids(user))
assert version.pk in set(permitted_document_ids(user, perm="change_document"))
assert version.pk in set(
permitted_document_ids(user, perm="documents.change_document"),
)
assert version.pk not in set(
permitted_document_ids(user, perm="delete_document"),
)
def test_anonymous_sees_versions_of_unowned_roots_only(self) -> None:
"""
GIVEN:
- A version owned by nobody under a private root
- A version owned by someone under an unowned root
WHEN:
- The permitted document ids are resolved for an anonymous user
THEN:
- Only the version of the unowned root is visible
"""
private_root = DocumentFactory(owner=UserFactory())
private_version = DocumentFactory(root_document=private_root, owner=None)
open_root = DocumentFactory(owner=None)
open_version = DocumentFactory(root_document=open_root, owner=UserFactory())
assert_visible_document_ids(
permitted_document_ids(AnonymousUser()),
expected_visible=[open_root.pk, open_version.pk],
expected_hidden=[private_root.pk, private_version.pk],
)
def test_deleted_versions_follow_their_deleted_root(self) -> None:
"""
GIVEN:
- A soft-deleted root document and its version, which deleting the
root soft-deletes too; the version is owned by someone else
WHEN:
- The permitted document ids are resolved with and without deleted
documents
THEN:
- Nothing is visible by default
- With deleted documents included, the version is visible to the
root's owner and not to the version's own owner
"""
owner = UserFactory()
version_owner = UserFactory()
root = DocumentFactory(owner=owner)
version = DocumentFactory(root_document=root, owner=version_owner)
root.delete()
assert not {root.pk, version.pk} & set(permitted_document_ids(owner))
assert_visible_document_ids(
permitted_document_ids(owner, include_deleted=True),
expected_visible=[root.pk, version.pk],
expected_hidden=[],
)
assert_visible_document_ids(
permitted_document_ids(version_owner, include_deleted=True),
expected_visible=[],
expected_hidden=[root.pk, version.pk],
)
@pytest.mark.parametrize(
"is_superuser",
[
pytest.param(False, id="regular-user"),
pytest.param(True, id="superuser"),
],
)
def test_inactive_user_sees_no_versions(self, *, is_superuser: bool) -> None:
"""
GIVEN:
- An inactive user, possibly a superuser, who owns a root and its version
WHEN:
- The permitted document ids are resolved for them
THEN:
- Nothing is visible
"""
user = UserFactory(is_active=False, is_superuser=is_superuser)
root = DocumentFactory(owner=user)
version = DocumentFactory(root_document=root, owner=user)
assert_visible_document_ids(
permitted_document_ids(user),
expected_visible=[],
expected_hidden=[root.pk, version.pk],
)
def test_superuser_sees_all_versions(self) -> None:
"""
GIVEN:
- A private root owned by someone else, with a version
WHEN:
- The permitted document ids are resolved for a superuser
THEN:
- Both the root and the version are visible
"""
superuser = UserFactory(superuser=True)
root = DocumentFactory(owner=UserFactory())
version = DocumentFactory(root_document=root, owner=UserFactory())
assert_visible_document_ids(
permitted_document_ids(superuser),
expected_visible=[root.pk, version.pk],
expected_hidden=[],
)
@pytest.mark.django_db
class TestHasPermsOwnerAwareVersions:
"""
The single-object check agrees with permitted_document_ids: a version is
authorized by its root document.
"""
@pytest.mark.parametrize(
("root_owner", "version_owner", "expected"),
[
pytest.param(
"other",
"nobody",
False,
id="unowned-version-of-private-root",
),
pytest.param("other", "user", False, id="own-version-of-private-root"),
pytest.param("user", "other", True, id="foreign-version-of-own-root"),
pytest.param("nobody", "other", True, id="private-version-of-unowned-root"),
],
)
def test_version_follows_root_owner(
self,
root_owner: str,
version_owner: str,
*,
expected: bool,
) -> None:
"""
GIVEN:
- A root document and a version with differing owners
WHEN:
- The single-object check runs for the version
THEN:
- The version is allowed exactly when its root is
"""
user = UserFactory()
owners = {"user": user, "other": UserFactory(), "nobody": None}
root = DocumentFactory(owner=owners[root_owner])
version = DocumentFactory(root_document=root, owner=owners[version_owner])
assert has_perms_owner_aware(user, "view_document", version) is expected
assert has_perms_owner_aware(user, "view_document", root) is expected
def test_grant_on_root_applies_and_grant_on_version_does_not(self) -> None:
"""
GIVEN:
- A private root with a version, and a second private root with a version
- The user may change only the first root, and was granted the second
root's version directly
WHEN:
- The single-object check runs for each version
THEN:
- Only the first root's version is allowed
"""
user = UserFactory()
shared_root = DocumentFactory(owner=UserFactory())
shared_version = DocumentFactory(root_document=shared_root, owner=UserFactory())
private_root = DocumentFactory(owner=UserFactory())
private_version = DocumentFactory(
root_document=private_root,
owner=UserFactory(),
)
grant_object(user, shared_root, "change_document")
grant_object(user, private_version, "change_document")
assert has_perms_owner_aware(user, "change_document", shared_version)
assert not has_perms_owner_aware(user, "change_document", private_version)
def test_other_models_use_their_own_owner(self) -> None:
"""
GIVEN:
- A tag owned by someone else, and one owned by the user
WHEN:
- The single-object check runs for each
THEN:
- Only the user's own tag is allowed without a grant
"""
user = UserFactory()
mine = TagFactory(owner=user)
theirs = TagFactory(owner=UserFactory())
assert has_perms_owner_aware(user, "view_tag", mine)
assert not has_perms_owner_aware(user, "view_tag", theirs)
@pytest.mark.django_db @pytest.mark.django_db
class TestAiChatAllDocumentsPermissionBoundary: class TestAiChatAllDocumentsPermissionBoundary:
""" """
+7 -9
View File
@@ -310,7 +310,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
@mock.patch("documents.tasks.clear_document_caches") @mock.patch("documents.tasks.clear_document_caches")
@mock.patch("documents.search.get_backend") @mock.patch("documents.search.get_backend")
def test_update_content_version_indexes_root( def test_update_content_version_clears_caches_for_root(
self, self,
mock_get_backend: mock.Mock, mock_get_backend: mock.Mock,
mock_clear_caches: mock.Mock, mock_clear_caches: mock.Mock,
@@ -321,8 +321,8 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
WHEN: WHEN:
- Update content task is called for the version - Update content task is called for the version
THEN: THEN:
- The version's content is updated - The version's content is updated, not the root's
- The root document is indexed rather than the version - The document is indexed
- Caches are cleared for both - Caches are cleared for both
""" """
root, version = self._create_root_with_version() root, version = self._create_root_with_version()
@@ -334,8 +334,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
"my document", "my document",
) )
self.assertEqual(Document.objects.get(pk=root.pk).content, "root content") self.assertEqual(Document.objects.get(pk=root.pk).content, "root content")
indexed = mock_get_backend.return_value.add_or_update.call_args.args[0] mock_get_backend.return_value.add_or_update.assert_called_once()
self.assertEqual(indexed.pk, root.pk)
mock_clear_caches.assert_has_calls( mock_clear_caches.assert_has_calls(
[mock.call(version.pk), mock.call(root.pk)], [mock.call(version.pk), mock.call(root.pk)],
) )
@@ -343,7 +342,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface") @override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
@mock.patch("documents.tasks.llm_index_add_or_update_document") @mock.patch("documents.tasks.llm_index_add_or_update_document")
@mock.patch("documents.search.get_backend") @mock.patch("documents.search.get_backend")
def test_update_content_version_updates_llm_index_for_root( def test_update_content_version_updates_llm_index(
self, self,
mock_get_backend: mock.Mock, mock_get_backend: mock.Mock,
mock_llm_index: mock.Mock, mock_llm_index: mock.Mock,
@@ -355,14 +354,13 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
WHEN: WHEN:
- Update content task is called for the version - Update content task is called for the version
THEN: THEN:
- The LLM index is updated for the root document, not the version - The LLM index is updated
""" """
root, version = self._create_root_with_version() _, version = self._create_root_with_version()
tasks.update_document_content_maybe_archive_file(version.pk) tasks.update_document_content_maybe_archive_file(version.pk)
mock_llm_index.assert_called_once() mock_llm_index.assert_called_once()
self.assertEqual(mock_llm_index.call_args.args[0].pk, root.pk)
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase): class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
-101
View File
@@ -1,11 +1,6 @@
from pathlib import Path
import pytest
import pytest_mock import pytest_mock
from PIL import Image
from documents.utils import QuerySetStream from documents.utils import QuerySetStream
from documents.utils import remove_alpha_channel
class TestQuerySetStream: class TestQuerySetStream:
@@ -38,99 +33,3 @@ class TestQuerySetStream:
# the thing being verified here. # the thing being verified here.
mock_queryset.count.assert_called_with() mock_queryset.count.assert_called_with()
mock_queryset.iterator.assert_called_once_with(chunk_size=1000) mock_queryset.iterator.assert_called_once_with(chunk_size=1000)
class TestRemoveAlphaChannel:
@pytest.mark.parametrize(
("image_format", "mode", "expected_mode", "color", "expected_pixel"),
[
pytest.param(
"PNG",
"RGBA",
"RGB",
(10, 20, 30, 0),
(10, 20, 30),
id="png-rgba",
),
pytest.param(
"TIFF",
"RGBA",
"RGB",
(10, 20, 30, 128),
(10, 20, 30),
id="tiff-rgba",
),
pytest.param("PNG", "LA", "L", (40, 0), 40, id="png-la"),
],
)
def test_drops_alpha_and_keeps_format(
self,
tmp_path: Path,
image_format: str,
mode: str,
expected_mode: str,
color: tuple[int, ...],
expected_pixel: int | tuple[int, ...],
) -> None:
"""
GIVEN:
- An image with an alpha channel
WHEN:
- The alpha channel is removed to a destination without an extension
THEN:
- The destination has the matching opaque mode, the same size and
format, and the color values are kept rather than composited
"""
source = tmp_path / "source"
Image.new(mode, (8, 6), color).save(source, format=image_format)
dest = tmp_path / "no-alpha"
result = remove_alpha_channel(source, dest)
assert result == dest
with Image.open(dest) as out:
assert out.mode == expected_mode
assert out.size == (8, 6)
assert out.format == image_format
assert out.getpixel((0, 0)) == expected_pixel
def test_keeps_every_frame_of_a_multi_page_tiff(self, tmp_path: Path) -> None:
"""
GIVEN:
- A multi-page TIFF where every page has an alpha channel
WHEN:
- The alpha channel is removed
THEN:
- All pages are kept and none has an alpha channel
"""
source = tmp_path / "multi.tiff"
first = Image.new("RGBA", (8, 6), (255, 0, 0, 50))
rest = [Image.new("RGBA", (8, 6), (0, 255, 0, 50)) for _ in range(2)]
first.save(source, save_all=True, append_images=rest)
dest = tmp_path / "multi-no-alpha.tiff"
remove_alpha_channel(source, dest)
with Image.open(dest) as out:
assert out.n_frames == 3
for index in range(out.n_frames):
out.seek(index)
assert out.mode == "RGB"
def test_keeps_dpi(self, tmp_path: Path) -> None:
"""
GIVEN:
- An alpha image with a DPI set
WHEN:
- The alpha channel is removed
THEN:
- The DPI is unchanged, since OCR scaling depends on it
"""
source = tmp_path / "source.png"
Image.new("RGBA", (8, 6)).save(source, dpi=(300, 300))
dest = tmp_path / "dest.png"
remove_alpha_channel(source, dest)
with Image.open(dest) as out:
assert round(out.info["dpi"][0]) == 300
+44
View File
@@ -6176,6 +6176,50 @@ class TestApplyAISuggestionsWorkflowAction(
self.assertEqual(self.doc.tags.count(), 0) self.assertEqual(self.doc.tags.count(), 0)
def test_unowned_document_does_not_get_private_objects_by_id(self) -> None:
"""
GIVEN:
- An unowned document
- AI suggestions selecting another user's private tag and
correspondent by ID, plus an unowned tag
WHEN:
- The suggestions are applied
THEN:
- Only the unowned tag is assigned; an unowned document must not
be treated as unrestricted
"""
self.doc.owner = None
self.doc.save(update_fields=["owner"])
other = User.objects.create(username="someone-else")
private_tag = Tag.objects.create(name="Private Tag", owner=other)
public_tag = Tag.objects.create(name="Public Tag", owner=None)
private_correspondent = Correspondent.objects.create(
name="Private Correspondent",
owner=other,
)
action = self.make_action(
ai_suggestion_fields=[
WorkflowAction.AISuggestionField.TAGS,
WorkflowAction.AISuggestionField.CORRESPONDENT,
],
)
suggestions: ClassificationSuggestions = {
**SUGGESTIONS,
"tags": {
"existing_ids": [private_tag.pk, public_tag.pk],
"new_names": [],
},
"correspondents": {
"existing_ids": [private_correspondent.pk],
"new_names": [],
},
}
self.apply(action, suggestions)
self.assertEqual(list(self.doc.tags.all()), [public_tag])
self.assertIsNone(self.doc.correspondent)
def test_unparsable_dates_are_skipped(self) -> None: def test_unparsable_dates_are_skipped(self) -> None:
""" """
GIVEN: GIVEN:
-26
View File
@@ -9,13 +9,11 @@ from pathlib import Path
from subprocess import CompletedProcess from subprocess import CompletedProcess
from subprocess import run from subprocess import run
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any
from typing import Generic from typing import Generic
from typing import TypeVar from typing import TypeVar
from django.conf import settings from django.conf import settings
from PIL import Image from PIL import Image
from PIL import ImageSequence
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import Model from django.db.models import Model
@@ -130,30 +128,6 @@ def maybe_override_pixel_limit() -> None:
Image.MAX_IMAGE_PIXELS = pixel_count Image.MAX_IMAGE_PIXELS = pixel_count
def remove_alpha_channel(source: Path, dest: Path) -> Path:
"""
Writes a copy of the image with its alpha channel dropped, keeping the
format, every frame, the DPI and any ICC profile.
The channel is dropped, not composited, so transparent pixels keep their
underlying color. Returns dest.
"""
with Image.open(source) as im:
image_format = im.format
opaque_mode = "L" if im.mode == "LA" else "RGB"
frames = [frame.convert(opaque_mode) for frame in ImageSequence.Iterator(im)]
save_kwargs: dict[str, Any] = {}
if "dpi" in im.info:
save_kwargs["dpi"] = im.info["dpi"]
if "icc_profile" in im.info:
save_kwargs["icc_profile"] = im.info["icc_profile"]
if len(frames) > 1:
save_kwargs["save_all"] = True
save_kwargs["append_images"] = frames[1:]
frames[0].save(dest, format=image_format, **save_kwargs)
return dest
def run_subprocess( def run_subprocess(
arguments: list[str], arguments: list[str],
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
+17
View File
@@ -17,9 +17,26 @@ from django.db.models.functions import RowNumber
from documents.models import Document from documents.models import Document
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable
from rest_framework.request import Request from rest_framework.request import Request
def root_document_ids(ids: Iterable[int]) -> QuerySet[int]:
"""
The ids of the root documents of the given documents: a root stands for
itself and a version for its root. Only the indexes' bookkeeping needs
this, since they hold root documents only.
"""
return (
Document.objects.filter(pk__in=ids)
.annotate(root_id=Coalesce("root_document_id", "id"))
.order_by()
.values_list("root_id", flat=True)
.distinct()
)
def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]: def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
""" """
Sorts versions so the newest one comes first using version_index and not on id, Sorts versions so the newest one comes first using version_index and not on id,
+65 -76
View File
@@ -174,7 +174,6 @@ from documents.permissions import TrashPermissions
from documents.permissions import ViewDocumentsPermissions from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_by_ids from documents.permissions import annotate_document_count_by_ids
from documents.permissions import annotate_document_count_for_related_queryset from documents.permissions import annotate_document_count_for_related_queryset
from documents.permissions import documents_without_permitted_root
from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_global_statistics_permission from documents.permissions import has_global_statistics_permission
@@ -290,6 +289,7 @@ logger = logging.getLogger("paperless.api")
# clauses efficiently, so this threshold mainly protects SQLite users. # clauses efficiently, so this threshold mainly protects SQLite users.
_TANTIVY_INTERSECT_THRESHOLD = 5_000 _TANTIVY_INTERSECT_THRESHOLD = 5_000
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id") _TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
_TANTIVY_COMBINABLE_SEARCH_PARAMS = ({"text", "query"}, {"title_search", "query"})
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the # whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters: # whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
@@ -309,6 +309,18 @@ _TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
_MAX_QUERY_LENGTH: Final[int] = 4096 _MAX_QUERY_LENGTH: Final[int] = 4096
def _validate_tantivy_search_params(active: list[str]) -> None:
# may also have a filter query alongside a simple search e.g. relative dates
if len(active) > 1 and set(active) not in _TANTIVY_COMBINABLE_SEARCH_PARAMS:
raise ValidationError(
{
"detail": _(
"Specify only one of text, title_search, query, or more_like_id.",
),
},
)
def _get_tantivy_query_and_mode(params): def _get_tantivy_query_and_mode(params):
from documents.search import QueryTooLongError from documents.search import QueryTooLongError
from documents.search import SearchMode from documents.search import SearchMode
@@ -322,17 +334,24 @@ def _get_tantivy_query_and_mode(params):
else: else:
return None # pragma: no cover return None # pragma: no cover
if len(raw) > _MAX_QUERY_LENGTH: filter_query = (
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH) str(params["query"])
return raw, mode if mode is not SearchMode.QUERY and "query" in params
else None
)
for value in (raw, filter_query):
if value is not None and len(value) > _MAX_QUERY_LENGTH:
raise QueryTooLongError(len(value), _MAX_QUERY_LENGTH)
return raw, mode, filter_query
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int: def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
try: try:
more_like_doc_id = int(query_params["more_like_id"]) more_like_doc_id = int(query_params["more_like_id"])
more_like_doc = Document.objects.select_related("owner").get( more_like_doc = Document.objects.select_related(
pk=more_like_doc_id, "owner",
) "root_document__owner",
).get(pk=more_like_doc_id)
except (TypeError, ValueError, Document.DoesNotExist): except (TypeError, ValueError, Document.DoesNotExist):
raise PermissionDenied(_("Invalid more_like_id")) raise PermissionDenied(_("Invalid more_like_id"))
@@ -343,7 +362,8 @@ def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
): ):
raise PermissionDenied(_("Insufficient permissions.")) raise PermissionDenied(_("Insufficient permissions."))
return more_like_doc_id # Only root documents are indexed, a version stands for its root
return more_like_doc.root_document_id or more_like_doc.pk
class SearchParams(NamedTuple): class SearchParams(NamedTuple):
@@ -1560,7 +1580,7 @@ class DocumentViewSet(
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -1623,7 +1643,7 @@ class DocumentViewSet(
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -1876,7 +1896,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"view_document", "view_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions to view notes") return HttpResponseForbidden("Insufficient permissions to view notes")
except Document.DoesNotExist: except Document.DoesNotExist:
@@ -1898,7 +1918,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden( return HttpResponseForbidden(
"Insufficient permissions to create notes", "Insufficient permissions to create notes",
@@ -1941,7 +1961,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions to delete notes") return HttpResponseForbidden("Insufficient permissions to delete notes")
@@ -1991,7 +2011,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden( return HttpResponseForbidden(
"Insufficient permissions to add share link", "Insufficient permissions to add share link",
@@ -2117,7 +2137,7 @@ class DocumentViewSet(
documents = Document.objects.filter(pk__in=document_ids) documents = Document.objects.filter(pk__in=document_ids)
if ( if (
request.user is not None request.user is not None
and documents_without_permitted_root(documents, request.user).exists() and documents.exclude(id__in=permitted_document_ids(request.user)).exists()
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -2452,7 +2472,7 @@ class ChatStreamingView(GenericAPIView[Any]):
if not has_perms_owner_aware( if not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
get_root_document(document), document,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -2565,15 +2585,9 @@ class UnifiedSearchViewSet(DocumentViewSet):
def parse_search_params() -> SearchParams: def parse_search_params() -> SearchParams:
"""Extract query string, search mode, and ordering from request.""" """Extract query string, search mode, and ordering from request."""
active = self._get_active_search_params(request) _validate_tantivy_search_params(
if len(active) > 1: self._get_active_search_params(request),
raise ValidationError( )
{
"detail": _(
"Specify only one of text, title_search, query, or more_like_id.",
),
},
)
ordering_param = request.query_params.get("ordering", "") ordering_param = request.query_params.get("ordering", "")
sort_reverse = ordering_param.startswith("-") sort_reverse = ordering_param.startswith("-")
@@ -2635,7 +2649,9 @@ class UnifiedSearchViewSet(DocumentViewSet):
filtered_qs: QuerySet[Document], filtered_qs: QuerySet[Document],
) -> SearchResultPage: ) -> SearchResultPage:
"""Handle text/title/query search: IDs, ORM intersection, page highlights.""" """Handle text/title/query search: IDs, ORM intersection, page highlights."""
query_str, search_mode = _get_tantivy_query_and_mode(request.query_params) query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
request.query_params,
)
# "score" is not a real Tantivy sort field — it means relevance order, # "score" is not a real Tantivy sort field — it means relevance order,
# which is Tantivy's default when no sort field is specified. # which is Tantivy's default when no sort field is specified.
@@ -2648,6 +2664,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
), ),
sort_reverse=sort_reverse, sort_reverse=sort_reverse,
search_mode=search_mode, search_mode=search_mode,
filter_query=filter_query,
) )
ordered_ids = intersect_and_order( ordered_ids = intersect_and_order(
all_ids, all_ids,
@@ -2882,14 +2899,7 @@ class DocumentSelectionMixin:
] ]
if not search_filters: if not search_filters:
return None return None
if len(search_filters) > 1: _validate_tantivy_search_params(search_filters)
raise ValidationError(
{
"detail": _(
"Specify only one of text, title_search, query, or more_like_id.",
),
},
)
from documents.search import SearchQueryError from documents.search import SearchQueryError
from documents.search import get_backend from documents.search import get_backend
@@ -2908,11 +2918,14 @@ class DocumentSelectionMixin:
user=search_user, user=search_user,
) )
else: else:
query_str, search_mode = _get_tantivy_query_and_mode(filters) query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
filters,
)
search_ids = backend.search_ids( search_ids = backend.search_ids(
query_str, query_str,
user=search_user, user=search_user,
search_mode=search_mode, search_mode=search_mode,
filter_query=filter_query,
) )
except SearchQueryError as e: except SearchQueryError as e:
# Same user-fixable-query mapping as the search list endpoint: # Same user-fixable-query mapping as the search list endpoint:
@@ -3009,12 +3022,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
user.has_perm( user.has_perm(
"documents.change_document", "documents.change_document",
) )
and not Document.global_objects.filter( and not Document.global_objects.filter(pk__in=documents)
pk__in=[doc.pk for doc in root_docs], .exclude(pk__in=permitted_document_ids(user, perm="change_document"))
)
.exclude(
pk__in=permitted_document_ids(user, perm="change_document"),
)
.exists() .exists()
) )
@@ -3627,7 +3636,7 @@ class SelectionDataView(DocumentSelectionMixin, GenericAPIView[Any]):
documents = Document.objects.filter(pk__in=ids) documents = Document.objects.filter(pk__in=ids)
if ( if (
documents.count() != len(ids) documents.count() != len(ids)
or documents_without_permitted_root(documents, request.user).exists() or documents.exclude(id__in=permitted_document_ids(request.user)).exists()
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -4124,21 +4133,16 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
validated_data=serializer.validated_data, validated_data=serializer.validated_data,
) )
documents = Document.objects.filter(pk__in=ids) documents = Document.objects.filter(pk__in=ids)
versioned_documents = []
compression = serializer.validated_data.get("compression") compression = serializer.validated_data.get("compression")
content = serializer.validated_data.get("content") content = serializer.validated_data.get("content")
follow_filename_format = serializer.validated_data.get("follow_formatting") follow_filename_format = serializer.validated_data.get("follow_formatting")
permitted_ids = set(permitted_document_ids(request.user)) if documents.exclude(id__in=permitted_document_ids(request.user)).exists():
for document in documents: return HttpResponseForbidden("Insufficient permissions")
root_doc = get_root_document(document) versioned_documents = [
if root_doc.pk not in permitted_ids: get_latest_version_for_root(get_root_document(document))
return HttpResponseForbidden("Insufficient permissions") for document in documents
versioned_documents.append( ]
get_latest_version_for_root(
root_doc,
),
)
if content == "both": if content == "both":
strategy_class = OriginalAndArchiveStrategy strategy_class = OriginalAndArchiveStrategy
@@ -4811,7 +4815,7 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
) )
denied_id = ( denied_id = (
documents_without_permitted_root(documents_qs, request.user) documents_qs.exclude(id__in=permitted_document_ids(request.user))
.order_by("pk") .order_by("pk")
.values_list("pk", flat=True) .values_list("pk", flat=True)
.first() .first()
@@ -5610,23 +5614,7 @@ class TrashView(ListModelMixin, PassUserMixin):
class _TrashPermittedObjectsFilter(PermittedObjectsFilter): class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
include_granted = False include_granted = False
parent_field = "root_document"
def filter_queryset(self, request, queryset, view):
if request.user.is_superuser or not request.user.is_active:
return super().filter_queryset(request, queryset, view)
# A version belongs to whoever owns its root
def owned_or_unowned(prefix: str) -> Q:
return Q(**{f"{prefix}owner": request.user}) | Q(
**{f"{prefix}owner__isnull": True},
)
return queryset.filter(
(Q(root_document__isnull=True) & owned_or_unowned(""))
| (
Q(root_document__isnull=False) & owned_or_unowned("root_document__")
),
)
filter_backends = (_TrashPermittedObjectsFilter,) filter_backends = (_TrashPermittedObjectsFilter,)
pagination_class = StandardPagination pagination_class = StandardPagination
@@ -5657,11 +5645,12 @@ class TrashView(ListModelMixin, PassUserMixin):
if doc_ids is not None if doc_ids is not None
else self.filter_queryset(self.get_queryset()).all() else self.filter_queryset(self.get_queryset()).all()
) )
if documents_without_permitted_root( if docs.exclude(
docs, id__in=permitted_document_ids(
request.user, request.user,
perm="delete_document", perm="delete_document",
include_deleted=True, include_deleted=True,
),
).exists(): ).exists():
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
action = serializer.validated_data.get("action") action = serializer.validated_data.get("action")
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more