Compare commits

..
Author SHA1 Message Date
stumpylog 642511c7b9 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.
2026-10-08 15:22:03 -07:00
stumpylog 210d97a522 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: adding or deleting a note on
a version, restoring a trashed document together with its versions, and
reprocessing a version all wrote the version into the index under its own id,
where it could be returned as a separate search result. The paths that walk the
whole library did the same: the document_index reindex command put every
version in the search index, a full LLM index rebuild embedded every version,
and an incremental LLM update scoped to a version id indexed it under its own
id. Asking for documents like a version looked the version's own id up in the
index and silently found nothing.

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.
2026-10-08 15:22:02 -07:00
stumpylog 8a96190359 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-08 15:21:45 -07:00
stumpylog ece8769f59 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 looks grants up as an EXISTS keyed on the row id cast
to a string, which uses guardian's unique index, and matches the user's groups
with an IN subquery. It 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.
2026-10-08 15:21:28 -07:00
299 changed files with 34880 additions and 37680 deletions

No files matched your search

+16
View File
@@ -39,6 +39,7 @@ ARG RUNTIME_PACKAGES="\
ghostscript \
gnupg \
icc-profiles-free \
imagemagick \
# PostgreSQL
postgresql-client \
# 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 \
&& 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
# dependencies
ARG BUILD_PACKAGES="\
@@ -66,7 +66,7 @@ services:
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=^file:///tmp/.*"
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped
+1 -11
View File
@@ -31,19 +31,9 @@ body:
value: |
#### 🤖 Use of AI tools
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).
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).
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
id: description
attributes:
+4 -1
View File
@@ -111,7 +111,10 @@ jobs:
timeout-minutes: 12
uses: $/.github/actions/apt-install
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
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
+7 -24
View File
@@ -43,12 +43,6 @@ jobs:
const haystack = `${issue.title}\n${issue.body ?? ''}`;
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
// 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.
@@ -61,30 +55,19 @@ jobs:
if (labels.some((label) => templateLabels.includes(label))) break;
}
const bypassedTemplate = !labels.some((label) => templateLabels.includes(label));
core.info(
`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, AI usage: "${aiUsage}", ` +
`bypassed template: ${bypassedTemplate}`
);
core.info(`Labels: [${labels.join(', ')}], honeypot: ${honeypot}, bypassed template: ${bypassedTemplate}`);
if (!bypassedTemplate && (disclosedAI || !honeypot)) {
if (disclosedAI) {
core.info('AI use disclosed, labeling only');
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
} else {
core.info('No slop signals found');
}
if (!honeypot && !bypassedTemplate) {
core.info('No slop signals found');
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
// is reason enough to close it, but not proof of how it was written. It takes priority:
// 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
// is reason enough to close it, but not proof of how it was written.
const body = honeypot
? '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, but the "AI usage" field does not ' +
'disclose this.\n\n' +
'automated tools, which indicates it was generated by an AI agent without being disclosed as such.\n\n' +
`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.'
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
+10
View File
@@ -38,6 +38,16 @@ jobs:
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
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
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+13 -4
View File
@@ -43,17 +43,26 @@ 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.
## PR Review Process
## Non-Trivial Requests
Our community review process for PRs is the following:
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).
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.
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).
- You may be asked to make changes or rebase.
4. One or more members of the team will approve and finally merge the request into `dev` 🎉.
- The team may ask for additional testing done by @paperless-ngx/test
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 thorough and of high quality.
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.
# Use of AI Tools
+3
View File
@@ -129,6 +129,7 @@ ARG RUNTIME_PACKAGES="\
ghostscript \
gnupg \
icc-profiles-free \
imagemagick \
# PostgreSQL
postgresql-client \
# 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 \
&& echo "Installing jbig2enc" \
&& 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" \
&& rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
+8 -4
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)
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._
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._
- [Features](#features)
- [Getting started](#getting-started)
@@ -33,9 +33,13 @@ Thanks to the generous folks at [Fyra Stack](https://fyrastack.com), a demo is a
- [Related Projects](#related-projects)
- [Important Note](#important-note)
<p align="right">Demo hosting provided by:<br/>
<a href="https://fyrastack.com">
<img src="docs/assets/sponsors/fyra-stack.svg" alt="" width="28px" align="absmiddle"> Fyra Stack
<p align="right">This project is supported by:<br/>
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
<picture>
<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>
</p>
+1 -1
View File
@@ -14,7 +14,7 @@ services:
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=^file:///tmp/.*"
- "--chromium-allow-list=file:///tmp/.*"
- "--log-level=warn"
- "--log-format=text"
tika:
@@ -40,6 +40,7 @@ services:
volumes:
- dbdata:/var/lib/mysql
environment:
MARIADB_HOST: paperless
MARIADB_DATABASE: paperless
MARIADB_USER: paperless
MARIADB_PASSWORD: paperless
@@ -78,7 +79,7 @@ services:
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=^file:///tmp/.*"
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped
@@ -36,6 +36,7 @@ services:
volumes:
- dbdata:/var/lib/mysql
environment:
MARIADB_HOST: paperless
MARIADB_DATABASE: paperless
MARIADB_USER: paperless
MARIADB_PASSWORD: paperless
@@ -74,7 +74,7 @@ services:
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=^file:///tmp/.*"
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped
@@ -63,7 +63,7 @@ services:
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=^file:///tmp/.*"
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:3.3.1.0
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,32 +101,3 @@ h4 code {
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
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 641 B

-233
View File
@@ -1,238 +1,5 @@
# Changelog
## paperless-ngx 3.3.3
### Bug Fixes
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
### All App Changes
<details>
<summary>10 changes</summary>
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
</details>
## paperless-ngx 3.3.2
### Bug Fixes
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
### Dependencies
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
### All App Changes
<details>
<summary>5 changes</summary>
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
</details>
## 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
### Bug Fixes
+20 -14
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}
!!! 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
ImageMagick. It will be removed in a future release.
For more information on how to use this value, you should search the
web for "MAGICK_MEMORY_LIMIT".
Defaults to 0, which disables the limit.
#### [`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
ImageMagick. It will be removed in a future release.
For more information on how to use this value, you should search the
web for "MAGICK_TMPDIR".
Default is none, which disables the temporary directory.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
@@ -1873,17 +1885,11 @@ the literal path for that program.
#### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
!!! warning
Deprecated and has no effect, since Paperless-ngx no longer uses
ImageMagick. It will be removed in a future release.
: Defaults to "convert".
#### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
!!! warning
Deprecated and has no effect, since nothing in Paperless-ngx calls
Ghostscript by this path. It will be removed in a future release.
: Defaults to "gs".
## Docker-specific options {#docker}
+1 -3
View File
@@ -76,9 +76,7 @@ is not supported by any of the available parsers.
**A:** Not by default. As of v3, a file whose contents match an existing document is still
consumed, and the duplicate is flagged in the UI — open the document and check the
**Duplicates** tab to review documents that share the same content, or filter the document
list by **Duplicates** to find all of them (see
[Duplicate documents](usage.md#duplicate-documents)). If you prefer the old
**Duplicates** tab to review documents that share the same content. If you prefer the old
behavior of rejecting duplicates during consumption, set
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
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 }
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
<div class="demo-sponsor">
<span>Demo hosting by</span>
<a href="https://fyrastack.com" target="_blank" rel="noopener">
<img src="assets/sponsors/fyra-stack.svg" alt="" class="no-lightbox" width="20" height="20"> Fyra Stack
<div style="display: flex; justify-content: end; margin-top: -1.5rem;">
<a href="https://m.do.co/c/8d70b916d462" target="_blank">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px">
</a>
</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)
- `fonts-liberation` for generating thumbnails for plain text
files
- `imagemagick` >= 6 for PDF conversion
- `gnupg` for decrypting GPG-encrypted email
- `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection
- `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:
```
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
@@ -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:
`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.**
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.
+1 -60
View File
@@ -129,7 +129,7 @@ change in the `docker-compose.yml` file:
command:
- 'gotenberg'
- '--chromium-disable-javascript=true'
- '--chromium-allow-list=^file:///tmp/.*'
- '--chromium-allow-list=file:///tmp/.*'
- '--api-timeout=60s'
```
@@ -272,65 +272,6 @@ This error can occur in installations which have upgraded from a version of Pape
$ 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
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).
+8 -7
View File
@@ -299,18 +299,19 @@ for details.
### Duplicate documents
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
contents match an existing document (same original or archive checksum), the new copy is
still consumed and a warning is logged.
contents exactly match an existing document (same checksum), the new copy is still
consumed and a warning is logged. The task entry for the upload also flags that a
duplicate was detected and links to the existing document(s).
When a document has duplicates, a **Duplicates** tab appears on its detail page, listing
the other documents you can view that share the same content (including any in the trash).
To find all documents with duplicates, choose **Duplicates** in the document list's text
filter dropdown, or use `has_duplicates=true` in the REST API.
To review duplicates, open a document and switch to the **Duplicates** tab on the
document detail page. It lists other documents that share the same content, including any
that are in the trash (shown with a badge), and links to each so you can decide which to
keep.
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
[`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
a "Document already exists" message linking to the existing document.
a "document already exists" message.
## Document Suggestions
+7
View File
@@ -50,6 +50,8 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_OCR_ROTATE_PAGES=true
#PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0
#PAPERLESS_OCR_USER_ARGS={}
#PAPERLESS_CONVERT_MEMORY_LIMIT=0
#PAPERLESS_CONVERT_TMPDIR=/var/tmp/paperless
# Software tweaks
@@ -85,3 +87,8 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_TIKA_ENABLED=false
#PAPERLESS_TIKA_ENDPOINT=http://localhost:9998
#PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://localhost:3000
# Binaries
#PAPERLESS_CONVERT_BINARY=/usr/bin/convert
#PAPERLESS_GS_BINARY=/usr/bin/gs
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "3.3.3"
version = "3.2.1"
description = """\
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-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1",
"filelock>=3.32,<4.1",
"filelock~=3.32.0",
"flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0",
"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 -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
+1 -3
View File
@@ -15,8 +15,6 @@ module.exports = {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
useESM: true,
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
isolatedModules: false,
},
],
},
@@ -28,7 +26,7 @@ module.exports = {
'abstract-paperless-service',
],
transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
],
moduleNameMapper: {
...esmPreset.moduleNameMapper,
+744 -859
View File
File diff suppressed because it is too large. Load diff
+21 -22
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.3.3",
"version": "3.2.1",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -15,22 +15,21 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^22.2.2",
"@angular/common": "~22.2.2",
"@angular/compiler": "~22.2.2",
"@angular/core": "~22.2.2",
"@angular/forms": "~22.2.2",
"@angular/localize": "~22.2.2",
"@angular/platform-browser": "~22.2.2",
"@angular/router": "~22.2.2",
"@angular/cdk": "^22.2.0",
"@angular/common": "~22.2.0",
"@angular/compiler": "~22.2.0",
"@angular/core": "~22.2.0",
"@angular/forms": "~22.2.0",
"@angular/localize": "~22.2.0",
"@angular/platform-browser": "~22.2.0",
"@angular/router": "~22.2.0",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~24.2.1",
"@ng-select/ng-select": "~24.1.2",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"file-saver": "^2.0.5",
"lodash-es": "^4.18.1",
"marked": "~18.0.14",
"mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0",
@@ -46,16 +45,16 @@
},
"devDependencies": {
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.2.2",
"@angular-devkit/schematics": "^22.2.2",
"@angular-eslint/builder": "22.6.0",
"@angular-eslint/eslint-plugin": "22.6.0",
"@angular-eslint/eslint-plugin-template": "22.6.0",
"@angular-eslint/schematics": "22.6.0",
"@angular-eslint/template-parser": "22.6.0",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "~22.2.2",
"@angular-devkit/core": "^22.2.0",
"@angular-devkit/schematics": "^22.2.0",
"@angular-eslint/builder": "22.5.0",
"@angular-eslint/eslint-plugin": "22.5.0",
"@angular-eslint/eslint-plugin-template": "22.5.0",
"@angular-eslint/schematics": "22.5.0",
"@angular-eslint/template-parser": "22.5.0",
"@angular/build": "22.2.0",
"@angular/cli": "22.2.0",
"@angular/compiler-cli": "~22.2.0",
"@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.6.2",
@@ -66,7 +65,7 @@
"jest": "30.5.2",
"jest-environment-jsdom": "^30.5.2",
"jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.3",
"jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.9",
"prettier-plugin-organize-imports": "^4.3.0",
+333 -301
View File
File diff suppressed because it is too large. Load diff
+9 -15
View File
@@ -19,7 +19,6 @@ import { PermissionsGuard } from './guards/permissions.guard'
import { HotKeyService } from './services/hot-key.service'
import { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { Toast, ToastService } from './services/toast.service'
import {
FileStatus,
@@ -35,7 +34,6 @@ describe('AppComponent', () => {
let toastService: ToastService
let router: Router
let settingsService: SettingsService
let tasksService: TasksService
let hotKeyService: HotKeyService
beforeEach(async () => {
@@ -62,7 +60,6 @@ describe('AppComponent', () => {
websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
tasksService = TestBed.inject(TasksService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService)
@@ -120,33 +117,30 @@ describe('AppComponent', () => {
expect(toast.action).toBeUndefined()
})
it('should only reload tasks when a document is detected', () => {
it('should display toast on document added', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const toastSpy = jest.spyOn(toastService, 'show')
const reloadSpy = jest.spyOn(tasksService, 'reload')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
})
it('should add consumed documents to the bell without a popup', () => {
it('should suppress dashboard notifications if set', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const popupSpy = jest.fn()
toastService.showToast.subscribe(popupSpy)
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
jest.spyOn(settingsService, 'get').mockReturnValue(true)
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toasts).toHaveLength(1)
expect(popupSpy).not.toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
})
it('should display toast on document failed', () => {
+50 -19
View File
@@ -14,7 +14,7 @@ import {
} from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { ToastLevel, ToastService } from './services/toast.service'
import { ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service'
@Component({
@@ -61,6 +61,18 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
private showNotification(key) {
if (
this.router.url == '/dashboard' &&
this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
)
) {
return false
}
return this.settings.get(key)
}
ngOnInit(): void {
this.websocketStatusService.connect()
@@ -68,22 +80,29 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFinished()
.subscribe((status) => {
this.tasksService.reload()
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
const canView = this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
// the upload status tray already shows this, so only keep a record
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
level: ToastLevel.Success,
silent: true,
actionName: canView ? $localize`Open document` : undefined,
action: canView
? () => this.router.navigate(['documents', status.documentId])
: undefined,
})
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', status.documentId])
},
})
} else {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
})
}
}
})
@@ -91,7 +110,9 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFailed()
.subscribe((status) => {
this.tasksService.reload()
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) {
this.toastService.showError(
$localize`Could not add ${status.filename}\: ${status.message}`
)
@@ -100,8 +121,18 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected()
.subscribe(() => {
.subscribe((status) => {
this.tasksService.reload()
if (
this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`,
delay: 5000,
})
}
})
this.hotKeyService
@@ -190,7 +190,7 @@ export class ConfigComponent
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showSuccess($localize`Configuration updated`)
this.toastService.showInfo($localize`Configuration updated`)
},
error: (e) => {
this.loading.set(false)
@@ -217,7 +217,7 @@ export class ConfigComponent
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showSuccess($localize`File successfully updated`)
this.toastService.showInfo($localize`File successfully updated`)
},
error: (e) => {
this.loading.set(false)
@@ -425,8 +425,10 @@
<div class="row">
<div class="col">
<pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
</div>
</div>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledTimes(35)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
@@ -63,7 +63,7 @@ import {
SettingsService,
} from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component'
@@ -190,8 +190,10 @@ export class SettingsComponent
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null),
savedViewsWarnOnUnsavedChange: new FormControl(null),
sidebarViewsShowCount: new FormControl(null),
@@ -369,12 +371,18 @@ export class SettingsComponent
updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
),
notificationsConsumerNewDocument: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
),
notificationsConsumerSuccess: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
),
notificationsConsumerFailed: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
),
notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
),
savedViewsWarnOnUnsavedChange: this.settings.get(
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
),
@@ -563,6 +571,10 @@ export class SettingsComponent
SETTINGS_KEYS.DATE_FORMAT,
this.settingsForm.value.dateFormat
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
this.settingsForm.value.notificationsConsumerNewDocument
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
this.settingsForm.value.notificationsConsumerSuccess
@@ -571,6 +583,10 @@ export class SettingsComponent
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
this.settingsForm.value.notificationsConsumerFailed
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.set(
SETTINGS_KEYS.NOTES_ENABLED,
this.settingsForm.value.notesEnabled
@@ -648,11 +664,8 @@ export class SettingsComponent
let savedToast: Toast = {
content: $localize`Settings were saved successfully.`,
delay: 5000,
level: ToastLevel.Success,
persistent: false,
}
if (reloadRequired) {
savedToast.persistent = true
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now`
savedToast.action = () => {
@@ -102,34 +102,25 @@
</button>
}
@if (selectedSection !== TaskSection.All && totalTasks() > pageSize) {
<ngb-pagination
[pageSize]="pageSize"
[collectionSize]="totalTasks()"
[page]="page"
[maxSize]="5"
[rotate]="true"
size="sm"
aria-label="Tasks pagination"
(pageChange)="setPage($event)">
</ngb-pagination>
}
<ngb-pagination
[pageSize]="pageSize"
[collectionSize]="totalTasks()"
[page]="page"
[maxSize]="5"
[rotate]="true"
size="sm"
aria-label="Tasks pagination"
(pageChange)="setPage($event)">
</ngb-pagination>
</div>
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
<div class="section-header mb-2">
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
@let count = sectionCount(section);
@let hasMore = selectedSection === TaskSection.All && count > tasks.length;
<div class="small text-muted">
@if (hasMore) {
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
<ng-container i18n>Show all</ng-container><i-bs class="ms-1" width="1em" height="1em" name="arrow-right"></i-bs>
</button>
} @else {
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
}
<div class="section-header d-flex align-items-center justify-content-between mb-2">
<div>
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
<div class="small text-muted">
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
</div>
</div>
</div>
@@ -178,9 +169,10 @@
<div>{{ taskDisplayName(task) }}</div>
<div class="small text-muted">
@if (taskShowsSeparateTypeLabel(task)) {
<span class="me-2">{{ task.task_type_display }}</span>
<span>{{ task.task_type_display }}</span>
<span class="mx-1">&bull;</span>
}
<span class="text-nowrap"><i-bs class="me-1" width="1em" height="1em" name="box-arrow-in-right"></i-bs>{{ task.trigger_source_display }}</span>
<span>{{ task.trigger_source_display }}</span>
</div>
</td>
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
@@ -72,7 +72,3 @@ pre {
tbody tr:nth-last-child(2) td {
border-bottom: none !important;
}
.section-header .show-all {
font-size: inherit;
}
@@ -223,6 +223,16 @@ describe('TasksComponent', () => {
)
.flush(paginatedTasks)
httpTestingController
.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '25' &&
req.params.get('page') === '1'
)
.flush(paginatedTasks)
httpTestingController
.expectOne(
(req) =>
@@ -231,30 +241,8 @@ describe('TasksComponent', () => {
!req.params.has('status')
)
.flush(sectionCountResponse)
flushTaskLists(10)
})
// answers task list requests with the fixture tasks matching their status filter
function flushTaskLists(pageSize: number, count?: number) {
httpTestingController
.match(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('page_size') === `${pageSize}`
)
.forEach((req) => {
const statuses = req.request.params.getAll('status')
const results = tasks.filter((task) => statuses.includes(task.status))
req.flush({ count: count ?? results.length, results })
})
}
function selectSection(section: TaskSection, count: number) {
component.setSection(section)
flushTaskLists(25, count)
}
it('should display task sections with counts', () => {
expect(component.selectedSection).toBe(TaskSection.All)
expect(component.selectedTaskType).toBeNull()
@@ -350,7 +338,6 @@ describe('TasksComponent', () => {
})
it('should render pagination controls next to the task filter', () => {
selectSection(TaskSection.Completed, 30)
fixture.detectChanges()
const controls = fixture.debugElement.query(By.css('.task-controls'))
@@ -361,51 +348,6 @@ describe('TasksComponent', () => {
expect(pagination).not.toBeNull()
})
it('should only paginate a section with more than one page of tasks', () => {
const pagination = () =>
fixture.debugElement.query(By.css('ngb-pagination'))
fixture.detectChanges()
expect(pagination()).toBeNull()
selectSection(TaskSection.Completed, 2)
fixture.detectChanges()
expect(pagination()).toBeNull()
})
it('should load the all tasks overview one section at a time', () => {
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
2
)
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(3)
expect(component.tasksForSection(TaskSection.Completed)).toHaveLength(2)
expect(component.totalTasks()).toBe(7)
})
it('should show section totals and link truncated sections to their full list', () => {
component.sectionCounts.update((counts) => ({
...counts,
[TaskSection.Completed]: 27,
}))
fixture.detectChanges()
const header = (label: string) =>
fixture.debugElement
.queryAll(By.css('.section-header'))
.find((h) => h.nativeElement.textContent.includes(label))
expect(header('Needs attention').nativeElement.textContent).toContain(
'2 tasks'
)
expect(header('Needs attention').query(By.css('button'))).toBeNull()
expect(header('Recently completed').nativeElement.textContent).toContain(
'2 of 27 tasks'
)
header('Recently completed').query(By.css('button')).nativeElement.click()
expect(component.selectedSection).toBe(TaskSection.Completed)
})
it('should apply the selected section to the server-side task query', () => {
component.setSection(TaskSection.NeedsAttention)
@@ -426,56 +368,55 @@ describe('TasksComponent', () => {
it('should apply task type and trigger source filters to the server-side task query', () => {
component.setTaskType(PaperlessTaskType.SanityCheck)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck
)
).toHaveLength(3)
.flush({ count: 1, results: [tasks[6]] })
component.setTriggerSource(PaperlessTaskTriggerSource.System)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
request.params.get('trigger_source') ===
PaperlessTaskTriggerSource.System
)
).toHaveLength(3)
.flush({ count: 1, results: [tasks[6]] })
})
it('should apply text filters to the server-side task query', () => {
component.filterText = 'invoice'
jest.advanceTimersByTime(150)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('name') === 'invoice'
)
).toHaveLength(3)
.flush({ count: 1, results: [tasks[0]] })
component.setFilterTarget(TaskFilterTargetID.Result)
expect(
httpTestingController.match(
httpTestingController
.expectOne(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '10' &&
request.params.get('page_size') === '25' &&
request.params.get('result') === 'invoice'
)
).toHaveLength(3)
.flush({ count: 0, results: [] })
})
it('should load a different task page when pagination changes', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2)
const pageTwoTasks = {
@@ -499,7 +440,6 @@ describe('TasksComponent', () => {
})
it('should not replace section counts with current-page counts', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2)
httpTestingController
@@ -515,21 +455,11 @@ describe('TasksComponent', () => {
results: [tasks[0]],
})
expect(component.sectionCount(TaskSection.All)).toBe(7)
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
})
it('should stop loading when the overview fails to load', () => {
component.setSection(TaskSection.All)
httpTestingController
.match((req) => req.params.get('page_size') === '10')[0]
.flush('', { status: 500, statusText: 'Error' })
expect(component.loading()).toBe(false)
})
it('should expose stable task type options and disable empty ones', () => {
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
PaperlessTaskType.TrainClassifier
@@ -15,9 +15,6 @@ import {
distinctUntilChanged,
filter,
first,
forkJoin,
map,
Observable,
Subject,
takeUntil,
timer,
@@ -28,7 +25,6 @@ import {
PaperlessTaskTriggerSource,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
import { Results } from 'src/app/data/results'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service'
@@ -173,7 +169,6 @@ export class TasksComponent
public expandedTask: number
public autoRefreshEnabled: boolean = true
public readonly pageSize = 25
public readonly overviewSize = 10
public page: number = 1
readonly totalTasks = signal(0)
readonly sectionCounts = signal<Record<TaskSection, number>>({
@@ -489,6 +484,13 @@ export class TasksComponent
return this.sectionCounts()[section]
}
private setSectionCount(section: TaskSection, count: number) {
this.sectionCounts.update((counts) => ({
...counts,
[section]: count,
}))
}
sectionShowsResults(section: TaskSection): boolean {
return section !== TaskSection.InProgress
}
@@ -735,12 +737,21 @@ export class TasksComponent
this.reloadSectionCounts()
this.loading.set(true)
this.listTasks()
this.tasksService
.list(
this.page,
this.pageSize,
this.getParamsForSection(this.selectedSection)
)
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
this.pagedTasks.set(result.results)
this.totalTasks.set(result.count)
this.setSectionCount(TaskSection.All, result.count)
if (this.selectedSection !== TaskSection.All) {
this.setSectionCount(this.selectedSection, result.count)
}
this.loading.set(false)
if (
this.page > 1 &&
@@ -756,30 +767,4 @@ export class TasksComponent
},
})
}
private listTasks(): Observable<Results<PaperlessTask>> {
if (this.selectedSection !== TaskSection.All) {
return this.tasksService.list(
this.page,
this.pageSize,
this.getParamsForSection(this.selectedSection)
)
}
// All is an overview of each section to avoid splitting sections across pages
return forkJoin(
this.sections.map((section) =>
this.tasksService.list(
1,
this.overviewSize,
this.getParamsForSection(section)
)
)
).pipe(
map((results) => ({
count: results.reduce((sum, result) => sum + result.count, 0),
results: results.flatMap((result) => result.results),
}))
)
}
}
@@ -99,16 +99,11 @@ describe('TrashComponent', () => {
modal.componentInstance.confirmClicked.next()
expect(toastErrorSpy).toHaveBeenCalled()
let toasts
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
trashSpy.mockReturnValue(of('OK'))
component.delete(documentsInTrash[0])
expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled()
expect(toasts[0].content).toContain('deleted')
})
it('should support empty trash, show error if needed', () => {
@@ -11,7 +11,7 @@ import { takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
@@ -87,11 +87,9 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled.set(false)
this.trashService.emptyTrash([document.id]).subscribe({
next: () => {
this.toastService.show({
content: $localize`Document "${document.title}" deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.toastService.showInfo(
$localize`Document "${document.title}" deleted`
)
modal.close()
this.reload()
},
@@ -124,11 +122,7 @@ export class TrashComponent
.emptyTrash(documents ? Array.from(documents) : null)
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Document(s) deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.toastService.showInfo($localize`Document(s) deleted`)
this.allToggled.set(false)
modal.close()
this.reload()
@@ -150,7 +144,6 @@ export class TrashComponent
this.toastService.show({
content: $localize`Document "${document.title}" restored`,
delay: 5000,
level: ToastLevel.Success,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', document.id])
@@ -172,11 +165,7 @@ export class TrashComponent
.restoreDocuments(documents ? Array.from(documents) : null)
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Document(s) restored`,
delay: 5000,
level: ToastLevel.Success,
})
this.toastService.showInfo($localize`Document(s) restored`)
this.allToggled.set(false)
this.reload()
},
@@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => {
component.editUser(users[0])
const editDialog = modal.componentInstance as UserEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser.set(users[1]) // simulate logged in as different user
@@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(userService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(userService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting user'))
@@ -154,31 +154,6 @@ describe('UsersAndGroupsComponent', () => {
jest.useRealTimers()
})
it('should logout current user under base href if password changed', () => {
jest.useFakeTimers()
const base = document.createElement('base')
base.href = '/paperless/'
document.head.appendChild(base)
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0])
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
const editDialog = modal.componentInstance as UserEditDialogComponent
editDialog.passwordIsSet = true
settingsService.currentUser.set(users[0]) // simulate logged in as same user
editDialog.succeeded.emit(users[0])
fixture.detectChanges()
jest.advanceTimersByTime(2600)
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
)
base.remove()
jest.useRealTimers()
})
it('should support edit / create group, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
@@ -186,7 +161,7 @@ describe('UsersAndGroupsComponent', () => {
component.editGroup(groups[0])
const editDialog = modal.componentInstance as GroupEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0])
@@ -204,7 +179,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(groupService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting group'))
@@ -116,13 +116,12 @@ export class UsersAndGroupsComponent
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
const basePath = new URL(document.baseURI).pathname
setLocationHref(
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
} else {
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Saved user "${newUser.username}".`
)
this.usersService.listAll().subscribe((r) => {
@@ -151,9 +150,7 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Deleted user "${user.username}"`
)
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.usersService.listAll().subscribe((r) => {
this.users.set(r.results)
})
@@ -180,9 +177,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => {
this.toastService.showSuccess(
$localize`Saved group "${newGroup.name}".`
)
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
})
@@ -208,9 +203,7 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Deleted group "${group.name}"`
)
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
})
@@ -29,20 +29,8 @@
</div>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
<pngx-global-search class="flex-grow-1"></pngx-global-search>
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
</button>
@if (uploadsInProgress > 0) {
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
</button>
}
</div>
<div class="col-12 header-search mx-auto">
<pngx-global-search></pngx-global-search>
</div>
</div>
<ul ngbNav class="order-sm-3">
@@ -82,9 +70,6 @@
</ul>
</nav>
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
@@ -434,6 +419,5 @@
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet>
</main>
<pngx-upload-status-tray></pngx-upload-status-tray>
</div>
</div>
@@ -66,7 +66,6 @@
}
.search-container {
min-width: 0;
padding-left: 1rem;
padding-right: 1rem;
}
@@ -500,11 +499,6 @@ main {
// true center the search with equal flex widths
@media (min-width: 768px) {
.search-container {
// Prevent input padding and the conditional search button from resizing
flex-basis: 55rem;
}
.navbar-brand-container,
.navbar > ul {
display: flex; // so the brand link stays as wide as its contents
@@ -538,36 +532,7 @@ main {
.header-search {
width: 100%;
max-width: 50rem;
}
.header-search pngx-global-search {
min-width: 0;
}
// light w shadow to stand out against search
.header-upload {
--bs-btn-bg: rgba(255, 255, 255, .15);
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
--bs-btn-active-bg: rgba(255, 255, 255, .3);
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
& + & {
border-left: 1px solid rgba(255, 255, 255, .2) !important;
}
&:focus {
box-shadow: none;
}
&:focus-visible {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
}
}
.header-upload-group {
border-radius: var(--bs-border-radius-sm);
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
max-width: 55rem;
}
@media screen and (max-width: 575.98px) {
@@ -659,7 +624,8 @@ main {
}
.dropdown-toggle::after {
display: none;
margin-left: 0.4em;
vertical-align: 0.155em;
}
.navbar .dropdown-menu {
@@ -696,7 +662,3 @@ main {
::ng-deep .navItemDrag .position-absolute i-bs {
display: none;
}
.smaller {
font-size: 0.75rem;
}
@@ -6,7 +6,7 @@ import {
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule, By } from '@angular/platform-browser'
import { BrowserModule } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
@@ -33,11 +33,8 @@ import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { AppFrameComponent } from './app-frame.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -465,45 +462,6 @@ describe('AppFrameComponent', () => {
expect(routerSpy).toHaveBeenCalled()
})
it('should upload files selected from the navbar upload button', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest
.spyOn(uploadDocumentsService, 'uploadFiles')
.mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const file = new File(['testing'], 'file.pdf')
const input = window.document.createElement('input')
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
component.onFileSelected({ target: input } as unknown as Event)
expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalledWith([file])
})
it('should show in-progress uploads and expand the status tray', () => {
const websocketStatusService = TestBed.inject(WebsocketStatusService)
expect(component.uploadsInProgress).toEqual(0)
websocketStatusService.newFileUpload('file.pdf')
expect(component.uploadsInProgress).toEqual(1)
fixture.detectChanges()
const tray = fixture.debugElement.query(
By.directive(UploadStatusTrayComponent)
).componentInstance as UploadStatusTrayComponent
tray.toggleCollapsed()
expect(tray.collapsed()).toBe(true)
component.showUploadStatus()
expect(tray.collapsed()).toBe(false)
})
it('should not upload when no files are selected from the navbar', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
const input = window.document.createElement('input')
component.onFileSelected({ target: input } as unknown as Event)
expect(uploadSpy).not.toHaveBeenCalled()
})
it('should close all documents on logout', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
component.onLogout()
@@ -525,7 +483,7 @@ describe('AppFrameComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[]
@@ -567,7 +525,6 @@ describe('AppFrameComponent', () => {
it('should show toasts for django messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(messagesService, 'get').mockReturnValue([
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
@@ -578,8 +535,7 @@ describe('AppFrameComponent', () => {
])
component.ngOnInit()
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
})
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
@@ -6,14 +6,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common'
import {
Component,
HostListener,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
@@ -55,15 +48,12 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -94,7 +84,6 @@ const SCROLL_THRESHOLD = 16
TourNgBootstrap,
FormsModule,
SwitchComponent,
UploadStatusTrayComponent,
],
})
export class AppFrameComponent
@@ -112,10 +101,6 @@ export class AppFrameComponent
private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService)
private uploadDocumentsService = inject(UploadDocumentsService)
private websocketStatusService = inject(WebsocketStatusService)
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true)
@@ -186,8 +171,6 @@ export class AppFrameComponent
this.toastService.showError(message.message)
break
case DjangoMessageLevel.SUCCESS:
this.toastService.showSuccess(message.message)
break
case DjangoMessageLevel.INFO:
case DjangoMessageLevel.DEBUG:
this.toastService.showInfo(message.message)
@@ -494,7 +477,7 @@ export class AppFrameComponent
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
next: () => {
this.toastService.showSuccess($localize`Sidebar views updated`)
this.toastService.showInfo($localize`Sidebar views updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating sidebar views`, e)
@@ -528,22 +511,6 @@ export class AppFrameComponent
}
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (!files.length) return
this.uploadDocumentsService.uploadFiles(files)
}
get uploadsInProgress(): number {
return this.websocketStatusService.getConsumerStatusNotCompleted().length
}
showUploadStatus() {
this.uploadStatusTray()?.expand()
}
onLogout() {
this.openDocumentsService.closeAll()
}
@@ -70,17 +70,6 @@ form {
max-height: 75vh;
}
.dropdown.show {
// Hide the search icon when the dropdown is shown
i-bs[name="search"] {
display: none !important;
}
.form-control {
padding-left: 0.5rem;
}
}
.dropdown-item {
&:has(button:focus) {
background-color: var(--pngx-bg-darker);
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
@@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showSuccess($localize`Successfully updated object.`)
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showSuccess($localize`Successfully updated object.`)
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -1,31 +1,28 @@
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
@if (unread().length) {
<span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
@if (toasts().length) {
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
}
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
<div class="d-flex align-items-center px-1 mb-2">
<h6 class="mb-0" i18n>Notifications</h6>
@if (toasts().length) {
<button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
(click)="toastService.clearToasts()">
<i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
</button>
}
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar">
<h6 i18n>Notifications</h6>
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()"
[disabled]="toasts().length === 0"
i18n>Clear All</button>
</div>
</div>
@if (toasts().length === 0) {
<div class="text-center text-body-secondary small py-4">
<i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
<ng-container i18n>No notifications</ng-container>
</div>
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
}
<div class="scroll-list d-flex flex-column gap-2">
<div class="scroll-list">
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
}
</div>
</div>
</div>
</li>
@@ -30,7 +30,3 @@
right: -3rem;
}
}
.clear-all:hover {
color: var(--bs-body-color) !important;
}
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [
@@ -90,73 +90,4 @@ describe('ToastsDropdownComponent', () => {
component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true)
})
it('should badge unread toasts, red when one is an error', () => {
toastsSubject.next([
{ id: 'a', content: 'Read', delay: 5000, read: true },
{ id: 'b', content: 'Unread', delay: 5000, read: false },
])
fixture.detectChanges()
let badge: HTMLElement = fixture.nativeElement.querySelector(
'.notification-count'
)
expect(badge.textContent.trim()).toEqual('1')
expect(badge.classList).toContain('bg-secondary')
toastsSubject.next([
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
])
fixture.detectChanges()
badge = fixture.nativeElement.querySelector('.notification-count')
expect(badge.classList).toContain('bg-danger')
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.notification-count')
).toBeNull()
})
it('should mark all toasts read when opened', () => {
const markSpy = jest.spyOn(toastService, 'markAllRead')
component.onOpenChange(false)
expect(markSpy).not.toHaveBeenCalled()
component.onOpenChange(true)
expect(markSpy).toHaveBeenCalled()
})
it('should tick the clock only while open', () => {
jest.useFakeTimers()
component.onOpenChange(true)
const opened = component.now()
jest.advanceTimersByTime(30000)
const ticked = component.now()
expect(ticked).not.toBe(opened)
component.onOpenChange(false)
jest.advanceTimersByTime(60000)
expect(component.now()).toBe(ticked)
jest.useRealTimers()
})
it('should mark toasts that were unread when opened as new', () => {
toastsSubject.next([
{ id: 'a', content: 'Seen', delay: 5000, read: true },
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
])
fixture.detectChanges()
component.onOpenChange(true)
expect([...component.newIds()]).toEqual(['b'])
component.onOpenChange(false)
expect(component.newIds().size).toEqual(0)
})
it('should clear the toast and close the menu when an action is used', () => {
const toast = { id: 'a', content: 'Moved', delay: 5000 }
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.onActioned(toast)
expect(closeToastSpy).toHaveBeenCalledWith(toast)
expect(closeSpy).toHaveBeenCalled()
})
})
@@ -1,20 +1,12 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
Component,
DestroyRef,
ViewChild,
computed,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import {
NgbDropdown,
NgbDropdownModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, interval, map } from 'rxjs'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { map } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component'
@Component({
@@ -30,44 +22,13 @@ import { ToastComponent } from '../../common/toast/toast.component'
})
export class ToastsDropdownComponent {
toastService = inject(ToastService)
private destroyRef = inject(DestroyRef)
private clock: Subscription
@ViewChild(NgbDropdown) dropdown: NgbDropdown
/** Drives the relative timestamps, ticks only while the dropdown is open */
readonly now = signal(new Date())
/** Notifications that were unread when the dropdown was opened */
readonly newIds = signal(new Set<string>())
readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] }
)
readonly unread = computed(() => this.toasts().filter((t) => !t.read))
readonly hasUnreadError = computed(() =>
this.unread().some((t) => t.level === ToastLevel.Error)
)
onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open
this.clock?.unsubscribe()
this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : []))
if (open) {
this.toastService.markAllRead()
this.now.set(new Date())
this.clock = interval(30000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.now.set(new Date()))
}
}
/** Using an action (Undo, Open document...) clears it and closes the menu */
onActioned(toast: Toast): void {
this.toastService.closeToast(toast)
this.dropdown?.close()
}
}
@@ -1,85 +1,49 @@
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
<div class="d-flex align-items-center border-bottom px-3 py-2">
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
<span class="fw-semibold">
@if (documentId()) {
<ng-container i18n>Ask about this document</ng-container>
} @else {
<ng-container i18n>Ask about your documents</ng-container>
}
</span>
@if (messages().length) {
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
</button>
}
</div>
<div class="chat-messages px-2">
@if (!messages().length) {
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
}
@for (message of messages(); track message) {
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
@if (message.isStreaming && !message.content) {
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (slowResponse()) {
<ng-container i18n>Still thinking...</ng-container>
} @else {
<ng-container i18n>Thinking...</ng-container>
}
</div>
} @else if (message.role === 'assistant') {
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
} @else {
<span class="text-break">{{ message.content }}</span>
}
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
<div class="chat-references list-group mt-3">
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
</a>
}
</div>
}
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small">
@for (message of messages(); track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break">
{{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span>
@if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a>
}
</div>
}
</div>
</div>
</div>
}
<div #scrollAnchor></div>
</div>
}
<div #scrollAnchor></div>
</div>
<form class="chat-input border-top p-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control" name="chatInput" type="text"
<form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder"
[disabled]="loading()"
[ngModel]="input()"
(ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)"
/>
@if (loading()) {
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
</button>
} @else {
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
</button>
}
</div>
</form>
/>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
</div>
</form>
</div>
</div>
</li>
@@ -1,13 +1,17 @@
.dropdown-menu {
width: min(36rem, calc(100vw - 20px));
width: var(--pngx-toast-max-width);
}
.chat-messages {
max-height: min(60dvh, 36rem);
max-height: 350px;
overflow-y: auto;
white-space: pre-wrap;
}
.chat-references {
font-family: var(--bs-font-sans-serif);
}
.dropdown-toggle::after {
display: none;
}
@@ -16,44 +20,23 @@
white-space: initial;
}
@media screen and (max-width: 575.98px) {
.chat-messages {
max-height: calc(100dvh - 12rem);
@media screen and (max-width: 400px) {
:host ::ng-deep .dropdown-menu-end {
right: -3rem;
}
}
.chat-markdown.streaming ::ng-deep {
> :last-child:not(ul, ol, table)::after,
> :is(ul, ol):last-child > li:last-child::after {
content: '';
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 2px;
background-color: currentColor;
vertical-align: -0.15em;
animation: blink 1s step-end infinite;
}
.blinking-cursor {
font-weight: bold;
font-size: 1.2em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% {
from, to {
opacity: 0;
}
}
.chat-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
> :last-child {
margin-bottom: 0;
50% {
opacity: 1;
}
}
@@ -80,16 +80,8 @@ describe('ChatComponent', () => {
expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true)
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
expect(messagesEl.textContent).toContain('Thinking...')
mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H')
fixture.detectChanges()
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
mockStream$.next('Hi there')
// advance time to process the typewriter effect
await jest.runAllTimersAsync()
@@ -104,8 +96,6 @@ describe('ChatComponent', () => {
await fixture.whenStable()
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
fixture.detectChanges()
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
@@ -133,10 +123,6 @@ describe('ChatComponent', () => {
mockStream$.next(
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
)
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
mockStream$.complete()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
@@ -170,96 +156,13 @@ describe('ChatComponent', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error')
expect(component.messages()[1].content).toBe(
'Partial\n\n⚠️ Error receiving response.'
expect(component.messages()[1].content).toContain(
'⚠️ Error receiving response.'
)
expect(component.loading()).toBe(false)
})
it('should keep streaming until the typewriter catches up', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
mockStream$.complete()
expect(component.messages()[1].isStreaming).toBe(true)
expect(component.loading()).toBe(true)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
})
it('should show a slow response message while waiting', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.textContent).not.toContain('Still thinking...')
jest.advanceTimersByTime(8000)
fixture.detectChanges()
expect(messagesEl.textContent).toContain('Still thinking...')
mockStream$.next('Hi')
expect(component.slowResponse()).toBe(false)
})
it('should stop streaming and keep the partial response', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
mockStream$.next('Hi there')
jest.advanceTimersByTime(20)
stopButton.click()
expect(mockStream$.observed).toBe(false)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
})
it('should drop the empty response when stopped before any content', () => {
component.input.set('Hello')
component.sendMessage()
component.stopStreaming()
expect(component.messages()).toHaveLength(1)
expect(component.loading()).toBe(false)
component.stopStreaming() // no-op once stopped
expect(component.messages()).toHaveLength(1)
})
it('should clear the conversation', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
fixture.detectChanges()
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
expect(component.messages()).toHaveLength(0)
expect(component.loading()).toBe(false)
expect(mockStream$.observed).toBe(false)
fixture.detectChanges()
jest.advanceTimersByTime(10)
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('#chatInput')
)
expect(fixture.nativeElement.textContent).toContain(
'Answers come from your documents'
)
})
it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null
@@ -304,33 +207,4 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled()
})
it('should render markdown for assistant messages only', () => {
component.messages.set([
{ role: 'user', content: '**user**' },
{ role: 'assistant', content: '**assistant** <script>x</script>' },
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
'assistant'
)
expect(el.querySelector('.chat-markdown script')).toBeNull()
expect(el.textContent).toContain('**user**')
})
it('should keep the destination hostname visible after sanitizing chat links', () => {
component.messages.set([
{
role: 'assistant',
content:
'[View original document](https://otherdomain.example/?data=secret)',
},
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
const link = el.querySelector('.chat-markdown a')
expect(link?.textContent).toBe('View original document')
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
})
})
@@ -10,16 +10,12 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { filter, map } from 'rxjs'
import {
ChatMessage,
ChatService,
parseChatResponse,
} from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({
selector: 'pngx-chat',
@@ -29,7 +25,6 @@ const SLOW_RESPONSE_DELAY_MS = 8000
RouterModule,
NgxBootstrapIconsModule,
NgbDropdownModule,
MarkdownPipe,
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
@@ -39,8 +34,6 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false)
readonly input = signal('')
readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@@ -50,11 +43,6 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = []
private typewriterActive = false
private typewriterTimeout: ReturnType<typeof setTimeout>
private streamSubscription: Subscription
private streamingMessage: ChatMessage
private streamFinished = false
private slowResponseTimeout: ReturnType<typeof setTimeout>
public get placeholder(): string {
return this.documentId()
@@ -93,96 +81,47 @@ export class ChatComponent implements OnInit {
}
this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = ''
this.streamSubscription = this.chatService
.streamChat(this.documentId(), this.input())
.subscribe({
next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk)
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
next: (chunk) => {
const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content
} else {
const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length
)
lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage)
}
if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content
} else {
const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length
)
lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage)
}
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
this.flushTypewriter(assistantMessage)
assistantMessage.content += '\n\n⚠️ Error receiving response.'
this.finishStreaming(assistantMessage)
},
complete: () => {
// let the typewriter catch up before hiding the cursor
this.streamFinished = true
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
},
})
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
},
complete: () => {
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
},
})
this.input.set('')
}
stopStreaming(): void {
const message = this.streamingMessage
if (!message?.isStreaming) return
this.streamSubscription?.unsubscribe()
this.flushTypewriter(message)
if (!message.content) {
this.messages.update((messages) => messages.filter((m) => m !== message))
}
this.finishStreaming(message)
}
clearChat(): void {
this.stopStreaming()
this.messages.set([])
setTimeout(() => this.chatInput?.nativeElement.focus())
}
private finishStreaming(message: ChatMessage): void {
this.clearSlowResponse()
this.streamFinished = false
message.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
}
private clearSlowResponse(): void {
clearTimeout(this.slowResponseTimeout)
this.slowResponse.set(false)
}
private flushTypewriter(message: ChatMessage): void {
message.content += this.typewriterBuffer.join('')
this.cancelTypewriter()
}
private cancelTypewriter(): void {
clearTimeout(this.typewriterTimeout)
private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = []
this.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content
this.notifyMessagesChanged()
this.scrollToBottom()
@@ -202,7 +141,6 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return
}
@@ -211,8 +149,7 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged()
this.scrollToBottom()
// 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
}
private notifyMessagesChanged(): void {
@@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any,
'getFields'
@@ -129,9 +129,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
@@ -15,7 +15,7 @@
</a>
}
</div>
<div class="input-group input-group-sm ps-1 pe-2">
<div class="input-group input-group-sm small ps-1 pe-2">
<ng-select class="w-100" name="createdRelativeDate"
[items]="relativeDates" [(ngModel)]="createdRelativeDate"
bindValue="id"
@@ -92,7 +92,7 @@
</a>
}
</div>
<div class="input-group input-group-sm ps-1 pe-2">
<div class="input-group input-group-sm small ps-1 pe-2">
<ng-select class="w-100" name="addedRelativeDate"
[items]="relativeDates" [(ngModel)]="addedRelativeDate"
bindValue="id"
@@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => {
'deactivateTotp'
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
@@ -132,7 +132,7 @@ export class UserEditDialogComponent
next: (result) => {
this.totpLoading.set(false)
if (result) {
this.toastService.showSuccess($localize`Totp deactivated`)
this.toastService.showInfo($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
} else {
this.toastService.showError($localize`Totp deactivation failed`)
@@ -209,7 +209,6 @@
@if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags
[allowCreate]="false"
[preserveHierarchy]="false"
[title]="null"
formControlName="values"
></pngx-input-tags>
@@ -321,7 +320,7 @@
<h6 class="form-label" i18n>Remove tags</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
</div>
<h6 class="form-label" i18n>Remove correspondents</h6>
@@ -52,7 +52,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending single document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds.set([1])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
@@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds.set([1, 2, 3])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailSubject = ''
this.emailMessage = ''
this.close()
this.toastService.showSuccess($localize`Email sent`)
this.toastService.showInfo($localize`Email sent`)
},
error: (e) => {
this.loading.set(false)
@@ -51,20 +51,20 @@
@if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<span class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</span>
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if (filteredItems.length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<span class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</span>
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button>
}
}
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<span class="ms-2 fw-bold">{{extraButtonTitle}}</span>
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button>
}
@@ -29,7 +29,7 @@
line-height: 1;
small {
font-size: 0.65rem;
font-size: 65%;
}
}
@@ -23,7 +23,7 @@
@if (isTag) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
} @else {
{{item.name}}
<small>{{item.name}}</small>
}
</div>
</div>
@@ -23,9 +23,4 @@
.badge {
flex-shrink: 0;
font-size: 0.75rem;
}
pngx-tag {
font-size: 1rem;
}
@@ -1,7 +1,5 @@
@if (!accordion) {
@if (title?.length > 0) {
<h5>{{title}}</h5>
}
<h5 i18n>Permissions</h5>
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
}
@if (accordion) {
@@ -59,9 +59,6 @@ export class PermissionsFormComponent
@Input()
accordion: boolean = false
@Input()
title: string = $localize`Permissions`
form = new FormGroup({
owner: new FormControl(null),
set_permissions: new FormGroup({
@@ -222,22 +222,6 @@ describe('TagsComponent', () => {
expect(propagated).toEqual([2, 1])
})
it('should not add parents or remove children when not preserving hierarchy', () => {
const parent: Tag = { id: 1, name: 'parent' }
const child: Tag = { id: 2, name: 'child', parent: 1 }
parent.children = [child]
component.tags = [parent, child]
component.preserveHierarchy = false
component.value = []
component.addTag(2)
expect(component.value).toEqual([2])
component.value = [1, 2]
component.removeTag(1)
expect(component.value).toEqual([2])
})
it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
@@ -109,9 +109,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
multiple: boolean = true
@Input()
preserveHierarchy: boolean = true
@Output()
filterDocuments = new EventEmitter<Tag[]>()
@@ -151,9 +148,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1)
// remove children
if (this.preserveHierarchy) {
oldValue = this.removeChildren(oldValue, tag)
}
oldValue = this.removeChildren(oldValue, tag)
this.value = [...oldValue]
this.onChange(this.value)
@@ -172,7 +167,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
public onAdd(tag: Tag) {
if (this.preserveHierarchy && tag?.parent) {
if (tag?.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
if (parent && !this.value.includes(parent.id)) {
@@ -3,13 +3,13 @@
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span>
@if (id()) {
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title>
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id()}}
}
</button>
</span>
}
@if (subTitle()) {
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
@@ -43,13 +43,6 @@ describe('PageHeaderComponent', () => {
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
})
it('should set html title to just the app title until a title is available', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
fixture.componentRef.setInput('title', undefined)
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(environment.appTitle)
})
it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers()
fixture.componentRef.setInput('id', 42)
@@ -27,10 +27,7 @@ export class PageHeaderComponent {
constructor() {
effect(() => {
const title = this.title()
this.titleService.setTitle(
title ? `${title} - ${environment.appTitle}` : environment.appTitle
)
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
})
}
@@ -12,7 +12,7 @@
}
</div>
<div class="me-1">
<span i18n>All</span>
<small i18n>All</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
@@ -22,7 +22,7 @@
}
</div>
<div class="me-1">
{{ownerFilterLabel}}
<small>{{ownerFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
@@ -32,7 +32,7 @@
}
</div>
<div class="me-1">
{{ownerExclusionFilterLabel}}
<small>{{ownerExclusionFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
@@ -42,7 +42,7 @@
}
</div>
<div class="me-1">
{{sharedByFilterLabel}}
<small>{{sharedByFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
@@ -52,7 +52,7 @@
}
</div>
<div class="me-1">
<span i18n>Unowned</span>
<small i18n>Unowned</small>
</div>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
@@ -67,7 +67,7 @@
<div class="me-1 w-100">
<ng-select
name="user"
class="user-select"
class="user-select small"
[ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)"
[disabled]="disabled"
@@ -87,7 +87,7 @@
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
<div class="form-check form-switch w-100">
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
<label class="form-check-label w-100" for="hideUnowned"><ng-container i18n>Hide unowned</ng-container></label>
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
</div>
</div>
}
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
expect(errorSpy).toHaveBeenCalled()
updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showSuccess')
const infoSpy = jest.spyOn(toastService, 'showInfo')
component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile))
component.save()
@@ -218,38 +218,6 @@ describe('ProfileEditDialogComponent', () => {
jest.useRealTimers()
})
it('should logout under base href on save if password changed', () => {
jest.useFakeTimers()
const base = document.createElement('base')
base.href = '/paperless/'
document.head.appendChild(base)
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
const getProvidersSpy = jest.spyOn(
profileService,
'getSocialAccountProviders'
)
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.ngOnInit()
component['newPassword'] = 'new*pass'
component.form.get('password').patchValue('new*pass')
component.form.get('password_confirm').patchValue('new*pass')
const updateSpy = jest.spyOn(profileService, 'update')
updateSpy.mockReturnValue(of(null))
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
component.save()
expect(updateSpy).toHaveBeenCalled()
jest.advanceTimersByTime(2600)
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
)
base.remove()
jest.useRealTimers()
})
it('should support auth token copy', () => {
jest.useFakeTimers()
const getSpy = jest.spyOn(profileService, 'get')
@@ -373,7 +341,7 @@ describe('ProfileEditDialogComponent', () => {
it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({
@@ -405,7 +373,7 @@ describe('ProfileEditDialogComponent', () => {
it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp()
@@ -204,15 +204,14 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showSuccess($localize`Profile updated successfully`)
this.toastService.showInfo($localize`Profile updated successfully`)
if (passwordChanged) {
this.toastService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
const basePath = new URL(document.baseURI).pathname
setLocationHref(
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
}
@@ -307,9 +306,7 @@ export class ProfileEditDialogComponent
this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showSuccess(
$localize`TOTP activated successfully`
)
this.toastService.showInfo($localize`TOTP activated successfully`)
} else {
this.toastService.showError($localize`Error activating TOTP`)
}
@@ -333,9 +330,7 @@ export class ProfileEditDialogComponent
this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null)
if (success) {
this.toastService.showSuccess(
$localize`TOTP deactivated successfully`
)
this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else {
this.toastService.showError($localize`Error deactivating TOTP`)
}
@@ -1,6 +1,6 @@
<div class="d-flex align-items-center">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading()" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
@@ -18,44 +18,46 @@
}
</button>
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<span class="visually-hidden" i18n>Show suggestions</span>
</button>
@if (aiEnabled()) {
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<span class="visually-hidden" i18n>Show suggestions</span>
</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
@if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
}
@if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
@if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
}
@if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
@if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
}
@if (fieldSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
</div>
}
@if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@if (fieldSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
@if (aiEnabled()) {
@@ -251,37 +251,6 @@ describe('SuggestionsDropdownComponent', () => {
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
})
it('should open ML date suggestions with AI disabled', () => {
const getSuggestions = jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('aiEnabled', false)
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
fixture.componentRef.setInput('suggestions', {
dates: ['2026-01-04', '2026-02-01'],
})
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
fixture.detectChanges()
const button = fixture.nativeElement.querySelector('button')
expect(button.disabled).toBeFalsy()
expect(button.querySelector('.badge').textContent.trim()).toBe('1')
button.click()
fixture.detectChanges()
expect(component.dropdown.isOpen()).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain(
'1 suggestion available below'
)
expect(getSuggestions).not.toHaveBeenCalled()
expect(
fixture.nativeElement.querySelector('button[title="Suggestion options"]')
).toBeNull()
button.click()
fixture.detectChanges()
expect(component.dropdown.isOpen()).toBeFalsy()
})
it('should fetch unfetched sources and show existing suggestions', () => {
jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('aiEnabled', true)
@@ -53,7 +53,11 @@ export class SuggestionsDropdownComponent {
addCorrespondent: EventEmitter<string> = new EventEmitter()
public clickSuggest(): void {
if (this.disabled() || this.loading()) {
if (
this.disabled() ||
this.loading() ||
(this.suggestions() && !this.aiEnabled())
) {
return
}
@@ -1,33 +1,22 @@
<ngb-toast
[autohide]="false"
[class.mb-2]="autohide"
[class.toast-list-item]="!autohide"
[class.toast-new]="isNew"
[class.toast-success]="toast.level === ToastLevel.Success"
[class.toast-error]="toast.level === ToastLevel.Error"
(mouseenter)="setHovered(true)"
(mouseleave)="setHovered(false)"
(focusin)="setFocused(true)"
(focusout)="setFocused(false)"
[autohide]="autohide"
[delay]="toast.delay"
[class]="toast.classname"
[class.mb-2]="true"
(shown)="onShown(toast)"
(hidden)="hidden.emit(toast)">
@if (autohide) {
<ngb-progressbar class="position-absolute w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
<span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
}
<div class="d-flex align-items-top">
@switch (toast.level) {
@case (ToastLevel.Success) {
<i-bs width="0.9em" height="0.9em" name="check-circle" class="toast-icon"></i-bs>
}
@case (ToastLevel.Error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle" class="toast-icon"></i-bs>
}
@default {
<i-bs width="0.9em" height="0.9em" name="info-circle" class="toast-icon"></i-bs>
}
@if (!toast.error) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
}
<div class="toast-content">
@if (toast.error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
}
<div>
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
@if (toast.error) {
<details class="ms-2">
@@ -59,12 +48,9 @@
</details>
}
@if (toast.action) {
<button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
}
</div>
@if (!autohide && toast.timestamp) {
<span class="toast-time small text-body-secondary text-nowrap ms-auto ps-2">{{relativeTimestamp}}</span>
}
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
</div>
</ngb-toast>
@@ -2,105 +2,19 @@
position: relative;
}
.toast {
--pngx-toast-accent: var(--pngx-toast-info);
background-color: var(--pngx-toast-bg);
color: var(--bs-body-color);
overflow: hidden;
&.toast-success {
--pngx-toast-accent: var(--pngx-toast-success);
background-color: var(--pngx-toast-success-bg);
border-color: color-mix(in srgb, var(--pngx-toast-success) 30%, transparent);
}
&.toast-error {
--pngx-toast-accent: var(--pngx-toast-error);
background-color: var(--pngx-toast-error-bg);
border-color: color-mix(in srgb, var(--pngx-toast-error) 35%, transparent);
}
::ng-deep .toast.error {
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
}
.toast-icon {
color: var(--pngx-toast-accent);
::ng-deep .toast.error .toast-body {
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
border-top-left-radius: inherit;
border-top-right-radius: inherit;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
.toast-content {
flex: 1 1 auto;
min-width: 0;
}
.toast .btn-close {
font-size: .6rem;
margin-top: .3rem;
margin-left: .75rem !important;
opacity: .45;
}
.toast:hover .btn-close {
opacity: .85;
}
// compact rows in the notifications dropdown
.toast.toast-list-item {
width: 100%;
max-width: none;
background-color: var(--pngx-toast-row-bg);
border: 0;
box-shadow: none;
&:hover {
background-color: var(--pngx-toast-row-hover-bg);
}
&.toast-error {
background-color: var(--pngx-toast-error-bg);
}
::ng-deep .toast-body {
padding: .625rem .75rem;
}
.toast-time {
margin-top: .1rem;
}
// marks notifications that arrived since the dropdown was last opened
&.toast-new .toast-time::before {
content: '';
display: inline-block;
width: .45rem;
height: .45rem;
margin-right: .4rem;
vertical-align: .05rem;
border-radius: 50%;
background-color: var(--pngx-toast-info);
}
}
.toast-action {
--bs-btn-padding-y: .1rem;
--bs-btn-padding-x: .6rem;
--bs-btn-font-size: .8rem;
--bs-btn-font-weight: 600;
--bs-btn-color: var(--pngx-toast-accent);
--bs-btn-bg: color-mix(in srgb, var(--pngx-toast-accent) 14%, transparent);
--bs-btn-border-color: transparent;
--bs-btn-hover-color: var(--pngx-toast-accent);
--bs-btn-hover-bg: color-mix(in srgb, var(--pngx-toast-accent) 24%, transparent);
--bs-btn-hover-border-color: transparent;
--bs-btn-active-color: var(--pngx-toast-accent);
--bs-btn-active-bg: color-mix(in srgb, var(--pngx-toast-accent) 30%, transparent);
--bs-btn-active-border-color: transparent;
}
// countdown thin line
.progress {
height: 2px;
background-color: transparent;
::ng-deep .progress-bar {
background-color: var(--bs-secondary-color);
opacity: .5;
}
background-color: var(--pngx-primary);
opacity: .07;
}
@@ -1,11 +1,7 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ToastLevel } from 'src/app/services/toast.service'
import { ToastComponent } from './toast.component'
const toast1 = {
@@ -34,11 +30,6 @@ describe('ToastComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
DatePipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(ToastComponent)
@@ -55,117 +46,11 @@ describe('ToastComponent', () => {
component.toast = toast2
fixture.detectChanges()
component.onShown(toast2)
jest.advanceTimersByTime(5000 + 150) // ticks every 150ms
expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers()
})
it('should start the progress bar full before the countdown begins', () => {
component.toast = { content: 'Info', delay: 3000 }
fixture.detectChanges()
expect(component.delayRemaining()).toEqual(3000)
})
it('should hide the toast when the countdown ends', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
component.onShown(toast)
jest.advanceTimersByTime(2850)
expect(hideSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(300)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while hovered', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
jest.advanceTimersByTime(1500)
el.dispatchEvent(new MouseEvent('mouseenter'))
const remaining = component.delayRemaining()
jest.advanceTimersByTime(10000)
expect(component.delayRemaining()).toEqual(remaining)
expect(hideSpy).not.toHaveBeenCalled()
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(remaining + 150)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while focus is inside', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
el.dispatchEvent(new FocusEvent('focusin'))
// mouse leaving doesn't resume while focus is still inside
el.dispatchEvent(new MouseEvent('mouseenter'))
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toEqual(3000)
el.dispatchEvent(new FocusEvent('focusout'))
jest.advanceTimersByTime(3000)
expect(component.delayRemaining()).toEqual(0)
expect(component.toast.delayRemaining).toEqual(0)
jest.useRealTimers()
})
it('should not count down without autohide', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
component.autohide = false
fixture.detectChanges()
component.onShown(toast)
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toBeNull()
jest.useRealTimers()
})
it('should show an icon and colors for each level', () => {
component.toast = {
content: 'Saved',
delay: 5000,
level: ToastLevel.Success,
}
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('i-bs[name="check-circle"]')).not.toBeNull()
expect(el.querySelector('ngb-toast').classList).not.toContain('toast-error')
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = {
content: 'Failed',
delay: 5000,
level: ToastLevel.Error,
}
fixture.detectChanges()
const errorEl: HTMLElement = fixture.nativeElement
expect(
errorEl.querySelector('i-bs[name="exclamation-triangle"]')
).not.toBeNull()
expect(errorEl.querySelector('ngb-toast').classList).toContain(
'toast-error'
)
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = { content: 'Started', delay: 5000 }
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('i-bs[name="info-circle"]')
).not.toBeNull()
})
it('should show an error if given with toast', () => {
component.toast = toast1
fixture.detectChanges()
@@ -203,70 +88,4 @@ describe('ToastComponent', () => {
component.getErrorText({ error: new Array(205).join('a') })
).toContain('...')
})
it('should emit actioned, not closed, when the action is clicked', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
}
fixture.detectChanges()
const closedSpy = jest.spyOn(component.closed, 'emit')
const actionedSpy = jest.spyOn(component.actioned, 'emit')
const button: HTMLButtonElement = [
...fixture.nativeElement.querySelectorAll('button'),
].find((b) => b.textContent.includes('Undo'))
button.click()
expect(actionedSpy).toHaveBeenCalled()
expect(closedSpy).not.toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
it('should show a relative timestamp in the notifications list', () => {
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
})
it('should update the relative timestamp as time passes', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-10-19T12:00:00Z'))
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
jest.setSystemTime(new Date('2026-10-19T12:03:00Z'))
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('3 minutes ago')
jest.useRealTimers()
})
it('should show a compact action link in the notifications list', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
timestamp: new Date(),
}
component.autohide = false
fixture.detectChanges()
const link: HTMLButtonElement =
fixture.nativeElement.querySelector('.toast-action')
expect(link.textContent).toContain('Undo')
expect(
fixture.nativeElement.querySelector('.btn-outline-secondary')
).toBeNull()
const actionedSpy = jest.spyOn(component.actioned, 'emit')
link.click()
expect(actionedSpy).toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
})
@@ -1,26 +1,20 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { DatePipe, DecimalPipe } from '@angular/common'
import { DecimalPipe } from '@angular/common'
import {
Component,
DestroyRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
NgbProgressbarModule,
NgbToast,
NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, interval, takeWhile } from 'rxjs'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { Toast, ToastLevel } from 'src/app/services/toast.service'
import { interval, take } from 'rxjs'
import { Toast } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-toast',
@@ -32,79 +26,32 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
],
templateUrl: './toast.component.html',
styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe],
})
export class ToastComponent implements OnInit {
export class ToastComponent {
private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef)
readonly copied = signal(false)
readonly ToastLevel = ToastLevel
readonly delayRemaining = signal<number>(null)
@ViewChild(NgbToast) ngbToast: NgbToast
private hovered = false
private focused = false
private countdown: Subscription
@Input() toast: Toast
@Input() autohide: boolean = true
/** Each new value re-renders the relative timestamp */
@Input() now: Date
/** Marks a notification that arrived since the dropdown was last opened */
@Input() isNew: boolean = false
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
/** The toast's action button was clicked */
@Output() actioned: EventEmitter<Toast> = new EventEmitter<Toast>()
ngOnInit(): void {
if (this.autohide) this.delayRemaining.set(this.toast.delay)
}
get relativeTimestamp(): string {
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
}
get paused(): boolean {
return this.hovered || this.focused
}
setHovered(hovered: boolean) {
this.hovered = hovered
}
setFocused(focused: boolean) {
this.focused = focused
}
onShown(toast: Toast) {
if (!this.autohide) return
const refreshInterval = 150
this.delayRemaining.set(toast.delay)
const delay = toast.delay - 500 // for fade animation
this.countdown?.unsubscribe()
this.countdown = interval(refreshInterval)
.pipe(
takeWhile(() => this.delayRemaining() > 0),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
if (this.paused) return
this.delayRemaining.update((remaining) =>
Math.max(0, remaining - refreshInterval)
interval(refreshInterval)
.pipe(take(Math.round(delay / refreshInterval)))
.subscribe((count) => {
toast.delayRemaining = Math.max(
0,
delay - refreshInterval * (count + 1)
)
if (this.delayRemaining() === 0) {
this.ngbToast?.hide()
}
})
}
@@ -1,3 +1,3 @@
@for (toast of toasts(); track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (closed)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
}
@@ -1,11 +1,10 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
import { ToastsComponent } from './toasts.component'
const toast = {
content: 'Error 2 content',
@@ -51,7 +50,7 @@ describe('ToastsComponent', () => {
it('should close toast', () => {
toastSubject.next(toast)
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast(toast)
component.closeToast()
expect(component.toasts()).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast)
})
@@ -60,59 +59,4 @@ describe('ToastsComponent', () => {
toastSubject.next(toast)
expect(component.toasts()).toEqual([toast])
})
it('should stack popups newest first, up to the maximum', () => {
const toasts: Toast[] = Array.from(
{ length: MAX_VISIBLE_TOASTS + 1 },
(_, i) => ({ id: `${i}`, content: `Toast ${i}`, delay: 5000 })
)
toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating
toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
})
it('should remove a popup when it hides without closing it', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
component.removePopup(first)
expect(component.toasts()).toEqual([second])
expect(closeToastSpy).not.toHaveBeenCalled()
})
it('should clear popups when toastService emits null', () => {
toastSubject.next(toast)
toastSubject.next(null)
expect(component.toasts()).toEqual([])
})
it('should render one toast component per popup', () => {
toastSubject.next({ id: 'a', content: 'A', delay: 5000 })
toastSubject.next({ id: 'b', content: 'B', delay: 5000 })
fixture.detectChanges()
expect(fixture.nativeElement.querySelectorAll('pngx-toast')).toHaveLength(2)
})
it('should only hide the popup on close, but clear it on action', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
fixture.detectChanges()
const popups = fixture.debugElement.queryAll(By.css('pngx-toast'))
popups[1].triggerEventHandler('closed', first)
expect(closeToastSpy).not.toHaveBeenCalled()
expect(component.toasts()).toEqual([second])
popups[0].triggerEventHandler('actioned', second)
expect(closeToastSpy).toHaveBeenCalledWith(second)
expect(component.toasts()).toEqual([])
})
})
@@ -1,18 +1,14 @@
import { Component, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NgbAccordionModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map, merge, Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component'
/**
* Maximum number of popup toasts shown at once
*/
export const MAX_VISIBLE_TOASTS = 3
@Component({
selector: 'pngx-toasts',
templateUrl: './toasts.component.html',
@@ -27,31 +23,18 @@ export const MAX_VISIBLE_TOASTS = 3
export class ToastsComponent {
toastService = inject(ToastService)
readonly toasts = signal<Toast[]>([])
private readonly closedToast = new Subject<void>()
constructor() {
this.toastService.showToast
.pipe(takeUntilDestroyed())
.subscribe((toast) => {
if (!toast) {
this.toasts.set([])
return
}
this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
0,
MAX_VISIBLE_TOASTS
)
)
})
}
readonly toasts = toSignal(
merge(
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))),
this.closedToast.pipe(map(() => [] as Toast[]))
),
{ initialValue: [] as Toast[] }
)
closeToast(toast: Toast) {
this.toastService.closeToast(toast)
this.removePopup(toast)
}
removePopup(toast: Toast) {
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
closeToast() {
this.toastService.closeToast(this.toasts()[0])
this.closedToast.next()
}
}
@@ -1,52 +0,0 @@
@if (canAddDocuments && getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center column-gap-2">
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
<div class="d-flex align-items-center gap-2 ms-auto">
@if (getStatusCompleted().length > 0) {
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
</button>
</div>
</div>
@if (!collapsed()) {
@for (status of getStatus(); track status) {
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
}
}
</div>
</div>
</div>
</div>
}
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -1,36 +0,0 @@
:host ::ng-deep i-bs svg {
margin-top: -3px;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: 60vh;
z-index: 1030;
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
@@ -1,206 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadStatusTrayComponent } from './upload-status-tray.component'
const FAILED_STATUSES = [new FileStatus()]
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
const SUCCESS_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
const DEFAULT_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
describe('UploadStatusTrayComponent', () => {
let component: UploadStatusTrayComponent
let fixture: ComponentFixture<UploadStatusTrayComponent>
let websocketStatusService: WebsocketStatusService
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes(routes),
NgxBootstrapIconsModule.pick(allIcons),
UploadStatusTrayComponent,
],
providers: [
PermissionsGuard,
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should not render without statuses', () => {
expect(
fixture.nativeElement.querySelector('.consumer-status-card')
).toBeNull()
})
it('should render statuses and support collapsing', () => {
const status = new FileStatus()
status.filename = 'invoice.pdf'
status.phase = FileStatusPhase.WORKING
jest
.spyOn(websocketStatusService, 'getConsumerStatus')
.mockReturnValue([status])
// a mocked return value doesn't mark the zoneless view dirty, so re-create it
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).not.toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.textContent).toContain('invoice.pdf')
})
it('should expand again once all statuses are dismissed', () => {
const status = websocketStatusService.newFileUpload('invoice.pdf')
fixture.detectChanges()
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
websocketStatusService.dismiss(status)
fixture.detectChanges()
expect(component.collapsed()).toBe(false)
})
it('should expand on request', () => {
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
component.expand()
expect(component.collapsed()).toBe(false)
})
it('should expand when new uploads start', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
jest
.spyOn(uploadDocumentsService, 'uploadFile')
.mockImplementation(() => {})
websocketStatusService.newFileUpload('invoice.pdf')
fixture.detectChanges()
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
uploadDocumentsService.uploadFiles([new File(['one'], 'one.pdf')])
expect(component.collapsed()).toBe(false)
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
})
})
function mockConsumerStatuses(consumerStatusService) {
const partialUpload1 = new FileStatus()
partialUpload1.currentPhaseProgress = 50
partialUpload1.currentPhaseMaxProgress = 50
const partialUpload2 = new FileStatus()
partialUpload2.currentPhaseProgress = 25
partialUpload2.currentPhaseMaxProgress = 50
jest
.spyOn(consumerStatusService, 'getConsumerStatus')
.mockImplementation((phase) => {
switch (phase) {
case FileStatusPhase.FAILED:
return FAILED_STATUSES
case FileStatusPhase.WORKING:
return WORKING_STATUSES
case FileStatusPhase.STARTED:
return STARTED_STATUSES
case FileStatusPhase.SUCCESS:
return SUCCESS_STATUSES
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return DEFAULT_STATUSES
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return DEFAULT_STATUSES
})
}
@@ -1,165 +0,0 @@
import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, effect, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { RouterModule } from '@angular/router'
import {
NgbAlertModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
/** Floating list of document consumption statuses, shown on every page */
@Component({
selector: 'pngx-upload-status-tray',
templateUrl: './upload-status-tray.component.html',
styleUrls: ['./upload-status-tray.component.scss'],
imports: [
IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class UploadStatusTrayComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private settingsService = inject(SettingsService)
private permissionsService = inject(PermissionsService)
private uploadDocumentsService = inject(UploadDocumentsService)
readonly collapsed = signal(false)
constructor() {
super()
// start expanded again for the next uploads once everything is dismissed
effect(() => {
if (this.getStatus().length === 0) this.collapsed.set(false)
})
this.uploadDocumentsService
.onUploadsStarted()
.pipe(takeUntilDestroyed())
.subscribe(() => this.expand())
}
get canAddDocuments(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
}
getStatus() {
return this.websocketStatusService.getConsumerStatus()
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
}
if (countFailed > 0) {
strings.push($localize`Failed: ${countFailed}`)
}
if (countSuccess > 0) {
strings.push($localize`Added: ${countSuccess}`)
}
return strings.join(
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
)
}
getStatusUploading() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
this.getStatusUploading().forEach((status) => {
current += status.currentPhaseProgress
max += status.currentPhaseMaxProgress
})
return current / Math.max(max, 1)
}
isFinished(status: FileStatus) {
return (
status.phase == FileStatusPhase.FAILED ||
status.phase == FileStatusPhase.SUCCESS
)
}
getStatusColor(status: FileStatus) {
switch (status.phase) {
case FileStatusPhase.UPLOADING:
case FileStatusPhase.STARTED:
case FileStatusPhase.WORKING:
return 'primary'
case FileStatusPhase.FAILED:
return 'danger'
case FileStatusPhase.SUCCESS:
return 'success'
}
}
toggleCollapsed() {
this.collapsed.update((collapsed) => !collapsed)
}
expand() {
this.collapsed.set(false)
}
dismiss(status: FileStatus) {
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.websocketStatusService.dismiss(status)
)
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
}
@@ -173,7 +173,7 @@ describe('DashboardComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[]
@@ -87,7 +87,7 @@ export class DashboardComponent extends ComponentWithPermissions {
.updateDashboardViewsSort(this.dashboardViews())
.subscribe({
next: () => {
this.toastService.showSuccess($localize`Dashboard updated`)
this.toastService.showInfo($localize`Dashboard updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e)
@@ -25,8 +25,6 @@
@if (displayFields().includes(field)) {
<th
scope="col"
class="text-truncate"
[title]="getColumnTitle(field)"
[ngClass]="{
'd-none d-md-table-cell': i > 1,
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
@@ -8,5 +8,59 @@
</button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form>
@if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="row">
<div class="col d-flex justify-content-between align-items-center">
@if (getStatus().length > 0) {
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
}
@if (getStatusCompleted().length > 0) {
<a class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
</div>
</div>
<div class="row">
<div class="col">
@for (status of getStatus(); track status) {
<div>
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
</pngx-widget-frame>
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more