Compare commits

..
Author SHA1 Message Date
GitHub Actions 54a6f0fd2b Auto translate strings 2026-09-10 05:02:05 +00:00
shamoon 2d64684043 Enhancement: customize sidebar items (#14052) 2026-09-09 22:00:37 -07:00
Trenton HandClaude Opus 5 60709b8319 Performance: cut redundant per-document lookups in bulk modify_custom_fields (#13807)
* Perf: batch the repeated lookups in modify_custom_fields

`modify_custom_fields()` re-resolved the same objects inside its
per-document loop: `custom_fields.get(id=field_id)` re-ran a CustomField
query for every document, and doc link fields called
`Document.objects.get(id=doc_id)` a second time for a document that was
already known.

Resolve both up front with `in_bulk()` and hand the resolved objects to
`update_or_create()` rather than bare ids. Passing the objects also
populates the FK cache on the newly created instance, so auditlog's
post_save receiver touching `.document`/`.field` no longer costs a reload
per row. The document map defers `content`, the one field here that is
both large and unused. The symmetrical-link removal pass and
`remove_doclink()` get `select_related()` for the same auditlog reason.

Measured over 50 documents, sqlite, audit log enabled:

                        before   after
  add 3 string fields    1502    1054
  update 1 string field   451     403
  add doc link            851     653
  remove doc link         604     354

`update_or_create()` is kept as-is. Dropping it for a hand-rolled
get-or-construct loop removes a further ~4 statements per row, but those
are the SAVEPOINT/RELEASE pairs of its `transaction.atomic()`, and the
`select_for_update()` and IntegrityError fallback that go with them. The
(document, field) unique constraint depends on that when two bulk edits
overlap, and the wall clock did not move to pay for it (331 ms vs 310 ms
for the string case above).

Also normalises the field ids to int once at the top so the old dict API,
whose keys may arrive as strings, indexes the resolved map correctly.

The `if custom_field:` branch it replaces was dead: `.get()` raises
DoesNotExist, it never returns None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fixes the comment

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:47:07 +00:00
GitHub Actions 1c96819625 Auto translate strings 2026-09-09 22:41:48 +00:00
Philipp Defner 3e56dace73 Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold (#12164) 2026-09-09 22:40:24 +00:00
Trenton H 310628699d Fix: If a file remains stable but has zero size after the stability window has passed, drop it from tracking (#14047)
A slow writer might create a zero byte file, then take longer than the window to
finish the write.  We would then queue a zero byte file for consumption and race the writer
to most likely fail due to still being empty.  Instead, drop zero size files at yeild time.
The slow writer may or may not finish, but if it does, there will be a Change.modified
event fired again
2026-09-09 16:24:17 +00:00
GitHub Actions aff0f9cf41 Auto translate strings 2026-09-09 16:04:18 +00:00
shamoon bf716ebfd1 Fixhancement: better LLM errors (#14031) 2026-09-09 16:02:43 +00:00
GitHub Actions 7d67a10a35 Auto translate strings 2026-09-09 15:50:33 +00:00
shamoon 8d1bc5dd24 Fix: prevent orphaned versions from bulk delete (#14030) 2026-09-09 08:48:59 -07:00
Trenton H 43a8d7d412 Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates (#13820)
This brings users without an embedding backend configured to closer
parity with those who do.  Reuse the search backend to locate similar
documents and use them to provide the LLM with the better suggestion pool
to draw from
2026-09-09 15:15:15 +00:00
Trenton H c40922440b Fix: prevent overlapping mail-account processing runs (#14046)
process_mail_accounts had no guard against a scheduled run still being
in progress when the next one fires. Skip a run outright
if another MAIL_FETCH task is already PENDING/STARTED.
2026-09-09 07:49:49 -07:00
GitHub Actions b989b74140 Auto translate strings 2026-09-08 15:57:05 +00:00
shamoon 5194f47291 Performance: ensure version-aware content filters on querysets (#13792) 2026-09-08 15:55:45 +00:00
GitHub Actions 714885d7a5 Auto translate strings 2026-09-08 15:32:41 +00:00
Trenton H 73e777a48c Fix: skip nested TagSerializer construction when a tag has no children (#14039)
TagSerializer.get_children() built a full nested TagSerializer(many=True)
for every tag, even when it had zero children, likely the common case for
most tags and maybe even most installs. Constructing a DRF ModelSerializer isn't
free (field introspection, deepcopy of declared fields, i18n lookups
all re-run per instantiation), so this scaled GET /api/tags/ linearly
with tag count in pure Python overhead, unrelated to SQL query count.
2026-09-08 15:30:58 +00:00
GitHub Actions e9141366bb Auto translate strings 2026-09-08 14:40:59 +00:00
shamoon 7813375123 Enhancement (QoL): surface externally-set options in Config UI (#13989) 2026-09-08 14:39:22 +00:00
shamoon 0132c7bd6e Fix pr-bot timing 2026-09-07 22:59:41 -07:00
GitHub Actions 4d5897ec80 Auto translate strings 2026-09-07 22:05:44 +00:00
shamoon f197d09b3e Enhancement: allow disabling auto-suggestions for inbox documents (#13946) 2026-09-07 22:04:13 +00:00
shamoon 937feb1bef Change: skip documents with empty content in apply AI suggestions WF (#13985) 2026-09-07 21:56:37 +00:00
Trenton H f5ff18326d Performance: resolve index-write permissions and effective content in bulk (#13869)
* fix(search): resolve index-write permissions and effective content in bulk

Add WriteBatch.add_or_update_ids() and use it in bulk_update_documents
and trash restore, cutting index writes from ~8 queries per document
to a constant handful per batch

* Always these new ones with xdist, try a better condition
2026-09-07 14:43:13 -07:00
Berk D. Demir d52cc1b609 Fix: Use PAPERLESS_REDIS_PREFIX for Celery result backend keys (#14015)
PR #12741 sets the result backend for Celery to Redis, but forget to carryover
transport option `global_keyprefix`. This resulted keys with prefix
`celery-task-meta-` prefix to be created.

This fix unbreaks strict Redis ACLs that allow a single prefix.
2026-09-07 21:08:34 +00:00
GitHub Actions 3f5f4f3ed4 Auto translate strings 2026-09-07 20:48:12 +00:00
shamoon 9a47b20d2a Enhancement: duplicates filter (#13994) 2026-09-07 20:46:49 +00:00
GitHub Actions 05905287b3 Auto translate strings 2026-09-07 20:29:01 +00:00
shamoon d65de00ca1 Fix: correct setting ai_enabled to false via UI (#13987) 2026-09-07 20:27:33 +00:00
dependabot[bot]andTrenton Holmes f287a4cb8c Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates (#13988)
* Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates

Bumps the utilities-minor group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [django-guardian](https://github.com/django-guardian/django-guardian) | `3.3.3` | `3.4.0` |
| [django-treenode](https://github.com/fabiocaccamo/django-treenode) | `0.24.0` | `0.25.0` |
| [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) | `2026.7.1` | `2026.8.1` |
| [imap-tools](https://github.com/ikvk/imap_tools) | `1.14.0` | `1.15.0` |
| [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF) | `17.10.0` | `17.11.0` |
| [prek](https://github.com/j178/prek) | `0.4.11` | `0.5.0` |
| [faker](https://github.com/joke2k/faker) | `40.36.0` | `40.37.0` |
| [pytest-django](https://github.com/pytest-dev/pytest-django) | `4.12.0` | `4.14.0` |
| [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) | `16.4` | `16.6` |
| [time-machine](https://github.com/adamchainz/time-machine) | `3.2.0` | `3.5.0` |
| [types-pygments](https://github.com/python/typeshed) | `2.20.0.20260408` | `2.21.0.20260819` |



Updates `django-guardian` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/django-guardian/django-guardian/releases)
- [Commits](https://github.com/django-guardian/django-guardian/compare/3.3.3...3.4.0)

Updates `django-treenode` from 0.24.0 to 0.25.0
- [Release notes](https://github.com/fabiocaccamo/django-treenode/releases)
- [Changelog](https://github.com/fabiocaccamo/django-treenode/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fabiocaccamo/django-treenode/compare/0.24.0...0.25.0)

Updates `drf-spectacular-sidecar` from 2026.7.1 to 2026.8.1
- [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2026.7.1...2026.8.1)

Updates `imap-tools` from 1.14.0 to 1.15.0
- [Release notes](https://github.com/ikvk/imap_tools/releases)
- [Changelog](https://github.com/ikvk/imap_tools/blob/master/docs/release_notes.rst)
- [Commits](https://github.com/ikvk/imap_tools/compare/v1.14.0...v1.15.0)

Updates `ocrmypdf` from 17.10.0 to 17.11.0
- [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases)
- [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v17.10.0...v17.11.0)

Updates `prek` from 0.4.11 to 0.5.0
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.4.11...v0.5.0)

Updates `faker` from 40.36.0 to 40.37.0
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v40.36.0...v40.37.0)

Updates `pytest-django` from 4.12.0 to 4.14.0
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.12.0...v4.14.0)

Updates `pytest-rerunfailures` from 16.4 to 16.6
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/16.4...16.6)

Updates `time-machine` from 3.2.0 to 3.5.0
- [Changelog](https://github.com/adamchainz/time-machine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/adamchainz/time-machine/compare/3.2.0...3.5.0)

Updates `types-pygments` from 2.20.0.20260408 to 2.21.0.20260819
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: django-guardian
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: django-treenode
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: drf-spectacular-sidecar
  dependency-version: 2026.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: faker
  dependency-version: 40.37.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: imap-tools
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: ocrmypdf
  dependency-version: 17.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: prek
  dependency-version: 0.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: pytest-django
  dependency-version: 4.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: pytest-rerunfailures
  dependency-version: '16.6'
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: time-machine
  dependency-version: 3.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: types-pygments
  dependency-version: 2.21.0.20260819
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Handles pytest-django

* Formatting

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
2026-09-07 20:13:15 +00:00
GitHub Actions a415d1bf74 Auto translate strings 2026-09-07 15:50:11 +00:00
shamoon 4b1434f876 Fix: catch some frontend failed object retrievals (#14023) 2026-09-07 08:48:34 -07:00
shamoon 43fd109bc6 Refactor issue comment for clarity and conciseness 2026-09-07 01:27:44 -07:00
shamoon ec70e4f423 Fix: more v3 icons cleanup (#14017) 2026-09-06 16:12:45 -07:00
GitHub Actions 340118ad51 Auto translate strings 2026-09-06 22:53:12 +00:00
shamoon 3899e0f0d6 Fix: correct add version actor parity (#14016) 2026-09-06 15:51:53 -07:00
shamoon d648526858 Fix: fix v3 favicon file (#14014) 2026-09-06 15:43:37 -07:00
134 changed files with 4346 additions and 2408 deletions
+2 -4
View File
@@ -72,11 +72,9 @@ jobs:
'You are welcome to open a new issue that describes the problem you observed in your own words.' 'You are welcome to open a new issue that describes the problem you observed in your own words.'
: 'This issue was automatically closed because it was not opened using our bug report form. ' + : 'This issue was automatically closed because it was not opened using our bug report form. ' +
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' + 'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
`If the problem is still there, please [open a new issue](${newIssue}) using the form — that is all it takes ` + `If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
'to get it looked at, and no other action is needed here.\n\n' +
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' + 'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
`contributions are a violation of our [Code of Conduct](${codeOfConduct}), and such reports must describe the ` + `contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
`behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`;
await github.rest.issues.createComment({ ...common, body }); await github.rest.issues.createComment({ ...common, body });
await github.rest.issues.addLabels({ ...common, labels: ['ai'] }); await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
+23 -2
View File
@@ -25,6 +25,10 @@ jobs:
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
# instead of racing it. Still runs if that job fails, so labeling is not lost.
needs: Anti-slop
if: ${{ !cancelled() }}
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
@@ -99,8 +103,25 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const user = context.payload.pull_request.user.login;
const user = pr.user.login;
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
if (pr.state === 'closed') {
core.info('Skipping comment: PR is already closed');
return;
}
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
if (labels.includes('ai')) {
core.info('Skipping comment: PR is labeled ai');
return;
}
const { data: members } = await github.rest.orgs.listMembers({ const { data: members } = await github.rest.orgs.listMembers({
org: 'paperless-ngx', org: 'paperless-ngx',
+3 -1
View File
@@ -138,7 +138,9 @@ for suggested generation and embedding models.
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type, With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
storage path and dates by sending the document to the LLM. This is **opt-in per request** storage path and dates by sending the document to the LLM. This is **opt-in per request**
and surfaces through the "Suggest" control on the document detail page, alongside the and surfaces through the "Suggest" control on the document detail page, alongside the
classic classifier-based suggestions — it does not disable them. Suggestion output classic classifier-based suggestions — it does not disable them. Suggestions are requested
automatically when you open a document that carries an inbox tag unless "Automatically request
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
language can be steered with language can be steered with
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
(otherwise it follows the user's UI language). (otherwise it follows the user's UI language).
+9
View File
@@ -1200,6 +1200,15 @@ still perform some basic text pre-processing before matching.
Defaults to true, enabling the feature. Defaults to true, enabling the feature.
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD}
: Sets the minimum confidence score (0.0-1.0) required for the automatic
classifier to assign a correspondent, document type, or storage path to a
document. Predictions below this threshold are discarded and the field is
left unassigned, preventing low-confidence guesses from being applied.
Defaults to 0.6.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES} #### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
: Specifies which language Paperless should use when parsing dates from documents. : Specifies which language Paperless should use when parsing dates from documents.
+4 -1
View File
@@ -317,6 +317,8 @@ a "document already exists" message.
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user. Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents.
## AI Features ## AI Features
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration. Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
@@ -684,7 +686,8 @@ It requires [AI features](configuration.md#ai) to be enabled. You can specify:
never replace the document's existing tags. never replace the document's existing tags.
The action works with every trigger **except Consumption Started**, because suggestions are made from The action works with every trigger **except Consumption Started**, because suggestions are made from
the document's text, which does not exist until after the document has been processed. the document's text, which does not exist until after the document has been processed. Documents whose
processed text is empty or contains only whitespace are skipped.
Because the query to the AI service is slow, the action is queued and runs in the background rather Because the query to the AI service is slow, the action is queued and runs in the background rather
than as part of the workflow run itself. The document is updated once the suggestions come back. than as part of the workflow run itself. The document is updated once the suggestions come back.
+7 -7
View File
@@ -32,21 +32,21 @@ dependencies = [
"django-cors-headers~=4.9.0", "django-cors-headers~=4.9.0",
"django-extensions~=4.1", "django-extensions~=4.1",
"django-filter~=25.1", "django-filter~=25.1",
"django-guardian~=3.3.3", "django-guardian>=3.3.3,<3.5",
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
"django-soft-delete~=1.0.18", "django-soft-delete~=1.0.18",
"django-treenode>=0.24", "django-treenode>=0.24",
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar~=2026.7.1", "drf-spectacular-sidecar>=2026.7.1,<2026.9",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock~=3.32.0",
"flower>=2.0.1,<2.2", "flower>=2.0.1,<2.2",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
"ijson>=3.5.1", "ijson>=3.5.1",
"imap-tools~=1.14.0", "imap-tools>=1.14,<1.16",
"jinja2~=3.1.6", "jinja2~=3.1.6",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"llama-index-core>=0.14.23", "llama-index-core>=0.14.23",
@@ -56,7 +56,7 @@ dependencies = [
"llama-index-llms-ollama>=0.9.1", "llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1", "llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0", "nltk~=3.10.0",
"ocrmypdf>=17.7,<17.11", "ocrmypdf>=17.7,<17.12",
"openai>=2.48", "openai>=2.48",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
@@ -103,17 +103,17 @@ docs = [
"zensical>=0.0.51", "zensical>=0.0.51",
] ]
lint = [ lint = [
"prek~=0.4.11", "prek>=0.4.11,<0.6",
"ruff~=0.16.1", "ruff~=0.16.1",
] ]
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker~=40.36.0", "faker>=40.36,<40.38",
"imagehash", "imagehash",
"pytest~=9.1.1", "pytest~=9.1.1",
"pytest-cov~=7.1.0", "pytest-cov~=7.1.0",
"pytest-django~=4.12.0", "pytest-django>=4.12,<4.15",
"pytest-env~=1.7.0", "pytest-env~=1.7.0",
"pytest-httpx", "pytest-httpx",
"pytest-mock~=3.15.1", "pytest-mock~=3.15.1",
+3 -1
View File
@@ -71,8 +71,10 @@
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"localize": true, "localize": true,
"assets": [ "assets": [
"src/favicon.ico",
"src/apple-touch-icon.png", "src/apple-touch-icon.png",
"src/icon-192.png",
"src/icon-512.png",
"src/icon-512-maskable.png",
"src/assets", "src/assets",
"src/manifest.webmanifest", "src/manifest.webmanifest",
{ {
+537 -372
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -14,7 +14,6 @@ import { DocumentListComponent } from './components/document-list/document-list.
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component' import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component' import { MailComponent } from './components/manage/mail/mail.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component' import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component' import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { NotFoundComponent } from './components/not-found/not-found.component' import { NotFoundComponent } from './components/not-found/not-found.component'
import { DirtyDocGuard } from './guards/dirty-doc.guard' import { DirtyDocGuard } from './guards/dirty-doc.guard'
@@ -311,24 +310,6 @@ export const routes: Routes = [
componentName: 'SavedViewsComponent', componentName: 'SavedViewsComponent',
}, },
}, },
{
path: 'share-links',
component: ShareLinksComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermissionAny: [
{
action: PermissionAction.View,
type: PermissionType.ShareLink,
},
{
action: PermissionAction.View,
type: PermissionType.ShareLinkBundle,
},
],
componentName: 'ShareLinksComponent',
},
},
], ],
}, },
@@ -23,17 +23,30 @@
<div class="col"> <div class="col">
<div class="card bg-light"> <div class="card bg-light">
<div class="card-body"> <div class="card-body">
<div class="card-title d-flex align-items-center"> <div class="card-title d-flex align-items-center flex-wrap">
<h6 class="mb-0"> <h6 class="mb-0">
{{option.title}} {{option.title}}
</h6> </h6>
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer"> <a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
<i-bs name="info-circle"></i-bs> <i-bs name="info-circle"></i-bs>
</a> </a>
@if (isExternallyConfigured(option.config_key)) {
@if (isSet(option.key)) {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
} @else {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
}
}
@if (isSet(option.key)) { @if (isSet(option.key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)"> @if (isExternallyConfigured(option.config_key)) {
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container> <button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
</button> <i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
</button>
} @else {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
} }
</div> </div>
<div class="mb-n3"> <div class="mb-n3">
@@ -163,6 +163,19 @@ describe('ConfigComponent', () => {
expect(component.configForm.get('barcodes_enabled').value).toBeNull() expect(component.configForm.get('barcodes_enabled').value).toBeNull()
}) })
it('should identify externally configured options', () => {
component.externallyConfiguredVariables = new Set([
'PAPERLESS_OCR_LANGUAGE',
])
expect(
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
).toBeTruthy()
expect(
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
).toBeFalsy()
})
it('should group options into sections within a category, or not', () => { it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR) const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR]) expect(sections).toEqual([null, ConfigSection.RemoteOCR])
@@ -69,6 +69,7 @@ export class ConfigComponent
public configForm = new FormGroup({}) public configForm = new FormGroup({})
public errors = {} public errors = {}
public externallyConfiguredVariables = new Set<string>()
get optionCategories(): string[] { get optionCategories(): string[] {
return Object.values(ConfigCategory) return Object.values(ConfigCategory)
@@ -152,6 +153,9 @@ export class ConfigComponent
} }
private initialize(config: PaperlessConfig) { private initialize(config: PaperlessConfig) {
this.externallyConfiguredVariables = new Set(
config.externally_configured_variables ?? []
)
if (!this.store) { if (!this.store) {
this.store = new BehaviorSubject(config) this.store = new BehaviorSubject(config)
@@ -162,7 +166,9 @@ export class ConfigComponent
this.configForm.patchValue(state, { emitEvent: false }) this.configForm.patchValue(state, { emitEvent: false })
}) })
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable()) this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
excludeKeys: ['externally_configured_variables'],
})
} }
this.configForm.patchValue(config) this.configForm.patchValue(config)
@@ -227,6 +233,10 @@ export class ConfigComponent
return this.configForm.get(key).value != null return this.configForm.get(key).value != null
} }
public isExternallyConfigured(configKey: string): boolean {
return this.externallyConfiguredVariables.has(configKey)
}
public resetOption(key: string) { public resetOption(key: string) {
this.configForm.get(key).setValue(null) this.configForm.get(key).setValue(null)
} }
@@ -112,6 +112,22 @@
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check> <pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
@for (option of sidebarItemOptions; track option.id) {
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
[id]="'sidebar-item-setting-' + option.id"
[checked]="isSidebarItemShown(option.id)"
(change)="toggleSidebarItem(option.id, $event.target.checked)"
/>
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
{{ option.label }}
</label>
</div>
}
</div> </div>
</div> </div>
@@ -237,6 +253,12 @@
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check> <pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
@@ -24,7 +24,7 @@ import {
SystemStatus, SystemStatus,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
@@ -209,6 +209,45 @@ describe('SettingsComponent', () => {
fixture.detectChanges() fixture.detectChanges()
} }
it('supports configuring sidebar items and canceling changes', () => {
completeSetup()
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
fixture.detectChanges()
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
HideableSidebarItemID.Workflows
)
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Mail,
false
)
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
HideableSidebarItemID.Mail
)
component.reset()
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
HideableSidebarItemID.Workflows
)
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
HideableSidebarItemID.Mail
)
})
it('enables sidebar item controls on general settings until destroyed', () => {
completeSetup()
expect(settingsService.organizingSidebarItems()).toBe(true)
component.ngOnDestroy()
expect(settingsService.organizingSidebarItems()).toBe(false)
})
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => { it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
completeSetup() completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate') const navigateSpy = jest.spyOn(router, 'navigate')
@@ -249,6 +288,7 @@ describe('SettingsComponent', () => {
it('should support save local settings updating appearance settings and calling API, show error', () => { it('should support save local settings updating appearance settings and calling API, show error', () => {
completeSetup() completeSetup()
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSpy = jest.spyOn(toastService, 'show') const toastSpy = jest.spyOn(toastService, 'show')
const storeSpy = jest.spyOn(settingsService, 'storeSettings') const storeSpy = jest.spyOn(settingsService, 'storeSettings')
@@ -267,7 +307,10 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(32) expect(setSpy).toHaveBeenCalledTimes(34)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
// succeed // succeed
storeSpy.mockReturnValueOnce(of(true)) storeSpy.mockReturnValueOnce(of(true))
@@ -39,7 +39,12 @@ import {
SystemStatus, SystemStatus,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings' import {
GlobalSearchType,
HIDEABLE_SIDEBAR_ITEM_IDS,
HideableSidebarItemID,
SETTINGS_KEYS,
} from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -102,6 +107,14 @@ const documentDetailFieldOptions = [
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` }, { id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
] ]
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
[HideableSidebarItemID.Mail]: $localize`Mail`,
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
}
@Component({ @Component({
selector: 'pngx-settings', selector: 'pngx-settings',
templateUrl: './settings.component.html', templateUrl: './settings.component.html',
@@ -149,6 +162,7 @@ export class SettingsComponent
bulkEditApplyOnClose: new FormControl(null), bulkEditApplyOnClose: new FormControl(null),
documentListItemPerPage: new FormControl(null), documentListItemPerPage: new FormControl(null),
slimSidebarEnabled: new FormControl(null), slimSidebarEnabled: new FormControl(null),
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
darkModeUseSystem: new FormControl(null), darkModeUseSystem: new FormControl(null),
darkModeEnabled: new FormControl(null), darkModeEnabled: new FormControl(null),
darkModeInvertThumbs: new FormControl(null), darkModeInvertThumbs: new FormControl(null),
@@ -168,6 +182,7 @@ export class SettingsComponent
pdfEditorDefaultEditMode: new FormControl(null), pdfEditorDefaultEditMode: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null),
documentEditingAutoSuggest: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]), documentDetailsHiddenFields: new FormControl([]),
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
searchLink: new FormControl(null), searchLink: new FormControl(null),
@@ -185,6 +200,7 @@ export class SettingsComponent
store: BehaviorSubject<any> store: BehaviorSubject<any>
storeSub: Subscription storeSub: Subscription
sidebarItemsSub: Subscription
isDirty$: Observable<boolean> isDirty$: Observable<boolean>
isDirty: boolean = false isDirty: boolean = false
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@@ -202,6 +218,10 @@ export class SettingsComponent
public readonly PdfEditorEditMode = PdfEditorEditMode public readonly PdfEditorEditMode = PdfEditorEditMode
public readonly documentDetailFieldOptions = documentDetailFieldOptions public readonly documentDetailFieldOptions = documentDetailFieldOptions
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
id,
label: sidebarItemLabels[id],
}))
get systemStatusHasErrors(): boolean { get systemStatusHasErrors(): boolean {
const status = this.systemStatus() const status = this.systemStatus()
@@ -229,6 +249,10 @@ export class SettingsComponent
constructor() { constructor() {
super() super()
this.sidebarItemsSub =
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
)
this.settings.settingsSaved.subscribe(() => { this.settings.settingsSaved.subscribe(() => {
if (!this.savePending) this.initialize() if (!this.savePending) this.initialize()
this.savedViewsService.maybeRefreshDocumentCounts() this.savedViewsService.maybeRefreshDocumentCounts()
@@ -278,14 +302,21 @@ export class SettingsComponent
this.activatedRoute.paramMap.subscribe((paramMap) => { this.activatedRoute.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section') const section = paramMap.get('section')
let navID = SettingsNavIDs.General
if (section) { if (section) {
const navIDKey: string = Object.keys(SettingsNavIDs).find( const navIDKey: string = Object.keys(SettingsNavIDs).find(
(navID) => navID.toLowerCase() == section (navID) => navID.toLowerCase() == section
) )
if (navIDKey) { if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey]) navID = SettingsNavIDs[navIDKey]
} }
} }
this.activeNavID.set(navID)
this.settings.sidebarHiddenItemsEditing.set(
navID === SettingsNavIDs.General
? [...this.settingsForm.controls.sidebarHiddenItems.value]
: null
)
}) })
} }
@@ -309,6 +340,7 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_LIST_SIZE SETTINGS_KEYS.DOCUMENT_LIST_SIZE
), ),
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR), slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM), darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED), darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
darkModeInvertThumbs: this.settings.get( darkModeInvertThumbs: this.settings.get(
@@ -368,6 +400,9 @@ export class SettingsComponent
documentEditingOverlayThumbnail: this.settings.get( documentEditingOverlayThumbnail: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
), ),
documentEditingAutoSuggest: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
),
documentDetailsHiddenFields: this.settings.get( documentDetailsHiddenFields: this.settings.get(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
), ),
@@ -432,6 +467,12 @@ export class SettingsComponent
this.settingsForm.patchValue(currentFormValue) this.settingsForm.patchValue(currentFormValue)
} }
if (this.settings.organizingSidebarItems()) {
this.settings.sidebarHiddenItemsEditing.set([
...this.settingsForm.controls.sidebarHiddenItems.value,
])
}
if (this.canViewSystemStatus) { if (this.canViewSystemStatus) {
this.systemStatusService.get().subscribe((status) => { this.systemStatusService.get().subscribe((status) => {
this.systemStatus.set(status) this.systemStatus.set(status)
@@ -440,8 +481,18 @@ export class SettingsComponent
} }
ngOnDestroy() { ngOnDestroy() {
this.settings.sidebarHiddenItemsEditing.set(null)
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
this.storeSub && this.storeSub.unsubscribe() this.storeSub && this.storeSub.unsubscribe()
this.sidebarItemsSub.unsubscribe()
}
isSidebarItemShown(item: HideableSidebarItemID): boolean {
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
}
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
this.settings.updateSidebarItemVisibility(item, checked)
} }
public saveSettings() { public saveSettings() {
@@ -469,6 +520,10 @@ export class SettingsComponent
SETTINGS_KEYS.SLIM_SIDEBAR, SETTINGS_KEYS.SLIM_SIDEBAR,
this.settingsForm.value.slimSidebarEnabled this.settingsForm.value.slimSidebarEnabled
) )
this.settings.set(
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
this.settingsForm.value.sidebarHiddenItems
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM, SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
this.settingsForm.value.darkModeUseSystem this.settingsForm.value.darkModeUseSystem
@@ -565,6 +620,10 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
this.settingsForm.value.documentEditingOverlayThumbnail this.settingsForm.value.documentEditingOverlayThumbnail
) )
this.settings.set(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
this.settingsForm.value.documentEditingAutoSuggest
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
this.settingsForm.value.documentDetailsHiddenFields this.settingsForm.value.documentDetailsHiddenFields
@@ -624,6 +683,11 @@ export class SettingsComponent
reset() { reset() {
this.settingsForm.patchValue(this.store.getValue()) this.settingsForm.patchValue(this.store.getValue())
if (this.settings.organizingSidebarItems()) {
this.settings.sidebarHiddenItemsEditing.set([
...this.settingsForm.controls.sidebarHiddenItems.value,
])
}
} }
clearThemeColor() { clearThemeColor() {
@@ -86,12 +86,15 @@
} }
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around"> <div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item app-link"> <li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span> <i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
</a> </a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
}
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" <a class="nav-link" routerLink="documents" routerLinkActive="active"
@@ -237,38 +240,38 @@
</div> </div>
</li> </li>
} }
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span> <i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
</a> </a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
}
</li> </li>
@if (canManageShareLinks) { <li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
<li class="nav-item app-link">
<a class="nav-link" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
</a>
</li>
}
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows"> tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span> <i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
</a> </a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
}
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span> <i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
</a> </a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
}
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash" <a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
@@ -331,13 +334,16 @@
</a> </a>
</li> </li>
} }
<li class="nav-item mt-2" tourAnchor="tour.outro"> <li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" <a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span> <i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
</a> </a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
}
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="text-muted small d-flex align-items-center flex-wrap nav-label"> <div class="text-muted small d-flex align-items-center flex-wrap nav-label">
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { import {
@@ -287,6 +287,82 @@ describe('AppFrameComponent', () => {
jest.useRealTimers() jest.useRealTimers()
}) })
it('should hide configured sidebar items', () => {
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Dashboard,
HideableSidebarItemID.Workflows,
])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList
).toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="workflows"]')
.parentElement.classList
).toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
.classList
).not.toContain('d-none')
})
it('should show hidden items and visibility switches while customizing', () => {
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Dashboard,
])
settingsService.sidebarHiddenItemsEditing.set([
HideableSidebarItemID.Dashboard,
])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
).toBe(5)
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList
).not.toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
).toContain('opacity-50')
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
fixture.detectChanges()
expect(
Array.from(
fixture.nativeElement.querySelectorAll('pngx-input-switch')
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
).toBe(true)
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
).not.toContain('pe-5')
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
component.slimSidebarAnimating.set(true)
fixture.detectChanges()
expect(
Array.from(
fixture.nativeElement.querySelectorAll('pngx-input-switch')
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
).toBe(true)
component.slimSidebarAnimating.set(false)
fixture.detectChanges()
expect(
Array.from(
fixture.nativeElement.querySelectorAll('pngx-input-switch')
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
).toBe(true)
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
).toContain('pe-5')
})
it('should show error on toggle slim sidebar if store settings fails', () => { it('should show error on toggle slim sidebar if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {}) jest.spyOn(console, 'warn').mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'showError')
@@ -7,6 +7,7 @@ import {
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit, signal } 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 { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { import {
NgbCollapseModule, NgbCollapseModule,
@@ -21,7 +22,11 @@ import { Observable } from 'rxjs'
import { first } from 'rxjs/operators' import { first } from 'rxjs/operators'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings' import {
CollapsibleSection,
HideableSidebarItemID,
SETTINGS_KEYS,
} from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard' import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -48,6 +53,7 @@ import { ChatComponent } from '../chat/chat/chat.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component' import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component' import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -76,6 +82,8 @@ const SCROLL_THRESHOLD = 16
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
DragDropModule, DragDropModule,
TourNgBootstrap, TourNgBootstrap,
FormsModule,
SwitchComponent,
], ],
}) })
export class AppFrameComponent export class AppFrameComponent
@@ -98,6 +106,7 @@ export class AppFrameComponent
readonly isMenuCollapsed = signal(true) readonly isMenuCollapsed = signal(true)
readonly slimSidebarAnimating = signal(false) readonly slimSidebarAnimating = signal(false)
readonly mobileSearchHidden = signal(false) readonly mobileSearchHidden = signal(false)
readonly HideableSidebarItemID = HideableSidebarItemID
private readonly versionSetting = this.settingsService.getSignal<string>( private readonly versionSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.VERSION SETTINGS_KEYS.VERSION
) )
@@ -195,6 +204,10 @@ export class AppFrameComponent
}, 200) // slightly longer than css animation for slim sidebar }, 200) // slightly longer than css animation for slim sidebar
} }
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
this.settingsService.updateSidebarItemVisibility(item, visible)
}
toggleAttributesSections(event?: Event): void { toggleAttributesSections(event?: Event): void {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
@@ -221,19 +234,6 @@ export class AppFrameComponent
return this.appTitleSetting() || environment.appTitle return this.appTitleSetting() || environment.appTitle
} }
get canManageShareLinks(): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLink
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLinkBundle
)
)
}
get customAppTitle(): string { get customAppTitle(): string {
return this.appTitleSetting() return this.appTitleSetting()
} }
@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { import {
MailAction, MailAction,
MailMetadataCorrespondentOption, MailMetadataCorrespondentOption,
@@ -15,6 +15,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component' import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
@@ -81,6 +82,41 @@ describe('MailRuleEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('should use empty related object lists when retrieval fails', () => {
const failed = () => throwError(() => new Error('Forbidden'))
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
jest
.spyOn(TestBed.inject(MailAccountService), 'listAll')
.mockReturnValue(failed())
jest
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
.mockReturnValue(failed())
jest
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
.mockReturnValue(failed())
const failedFixture = TestBed.createComponent(MailRuleEditDialogComponent)
const failedComponent = failedFixture.componentInstance
expect(failedComponent.accounts()).toEqual([])
expect(failedComponent.correspondents()).toEqual([])
expect(failedComponent.documentTypes()).toEqual([])
expect(() => failedFixture.detectChanges()).not.toThrow()
expect(toastSpy).toHaveBeenCalledTimes(3)
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving mail accounts',
expect.any(Error)
)
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving correspondents',
expect.any(Error)
)
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving document types',
expect.any(Error)
)
})
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -6,7 +6,7 @@ import {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { map } from 'rxjs' import { catchError, map, of } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
@@ -26,6 +26,7 @@ import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { SelectComponent } from '../../input/select/select.component' import { SelectComponent } from '../../input/select/select.component'
@@ -158,17 +159,45 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
private readonly accountService = inject(MailAccountService) private readonly accountService = inject(MailAccountService)
private readonly correspondentService = inject(CorrespondentService) private readonly correspondentService = inject(CorrespondentService)
private readonly documentTypeService = inject(DocumentTypeService) private readonly documentTypeService = inject(DocumentTypeService)
private readonly toastService = inject(ToastService)
readonly accounts = toSignal( readonly accounts = toSignal(
this.accountService.listAll().pipe(map((result) => result.results)), this.accountService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError(
$localize`Error retrieving mail accounts`,
error
)
return of([])
})
),
{ initialValue: undefined as MailAccount[] } { initialValue: undefined as MailAccount[] }
) )
readonly correspondents = toSignal( readonly correspondents = toSignal(
this.correspondentService.listAll().pipe(map((result) => result.results)), this.correspondentService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError(
$localize`Error retrieving correspondents`,
error
)
return of([])
})
),
{ initialValue: undefined as Correspondent[] } { initialValue: undefined as Correspondent[] }
) )
readonly documentTypes = toSignal( readonly documentTypes = toSignal(
this.documentTypeService.listAll().pipe(map((result) => result.results)), this.documentTypeService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError(
$localize`Error retrieving document types`,
error
)
return of([])
})
),
{ initialValue: undefined as DocumentType[] } { initialValue: undefined as DocumentType[] }
) )
@@ -81,6 +81,23 @@ describe('UserEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('should use an empty group list when retrieval fails', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(TestBed.inject(GroupService), 'listAll')
.mockReturnValue(throwError(() => new Error('Forbidden')))
const failedFixture = TestBed.createComponent(UserEditDialogComponent)
const failedComponent = failedFixture.componentInstance
expect(failedComponent.groups()).toEqual([])
expect(() => failedFixture.detectChanges()).not.toThrow()
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving groups',
expect.any(Error)
)
})
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -6,7 +6,7 @@ import {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { first, map } from 'rxjs' import { catchError, first, map, of } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group' import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
@@ -42,7 +42,13 @@ export class UserEditDialogComponent
private readonly groupsService = inject(GroupService) private readonly groupsService = inject(GroupService)
readonly groups = toSignal( readonly groups = toSignal(
this.groupsService.listAll().pipe(map((result) => result.results)), this.groupsService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError($localize`Error retrieving groups`, error)
return of([])
})
),
{ initialValue: undefined as Group[] } { initialValue: undefined as Group[] }
) )
readonly passwordIsSet = signal(false) readonly passwordIsSet = signal(false)
@@ -11,7 +11,7 @@ import {
} from '@angular/forms' } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component' import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query' import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
@@ -39,6 +39,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
@@ -206,6 +207,44 @@ describe('WorkflowEditDialogComponent', () => {
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai) settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
} }
it('should use empty related object lists when access is forbidden', () => {
const forbidden = () => throwError(() => new Error('Forbidden'))
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
jest
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
.mockReturnValue(forbidden())
jest
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
.mockReturnValue(forbidden())
jest
.spyOn(TestBed.inject(StoragePathService), 'listAll')
.mockReturnValue(forbidden())
jest
.spyOn(TestBed.inject(MailRuleService), 'listAll')
.mockReturnValue(forbidden())
jest
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
.mockReturnValue(forbidden())
const forbiddenFixture = TestBed.createComponent(
WorkflowEditDialogComponent
)
const forbiddenComponent = forbiddenFixture.componentInstance
expect(forbiddenComponent.correspondents()).toEqual([])
expect(forbiddenComponent.documentTypes()).toEqual([])
expect(forbiddenComponent.storagePaths()).toEqual([])
expect(forbiddenComponent.mailRules()).toEqual([])
expect(forbiddenComponent.customFields()).toEqual([])
expect(forbiddenComponent.dateCustomFields()).toEqual([])
expect(() => forbiddenFixture.detectChanges()).not.toThrow()
expect(toastSpy).toHaveBeenCalledTimes(1)
expect(toastSpy).toHaveBeenCalledWith(
'Some workflow options could not be loaded.',
expect.any(Error)
)
})
it('should support create and edit modes, support adding triggers and actions on new workflow', () => { it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -16,7 +16,7 @@ import {
} from '@angular/forms' } from '@angular/forms'
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, map, takeUntil } from 'rxjs' import { Subscription, catchError, map, of, takeUntil } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
@@ -48,6 +48,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { import {
@@ -512,26 +513,43 @@ export class WorkflowEditDialogComponent
private readonly storagePathService = inject(StoragePathService) private readonly storagePathService = inject(StoragePathService)
private readonly mailRuleService = inject(MailRuleService) private readonly mailRuleService = inject(MailRuleService)
private readonly customFieldsService = inject(CustomFieldsService) private readonly customFieldsService = inject(CustomFieldsService)
private readonly toastService = inject(ToastService)
private relatedObjectLoadErrorShown = false
readonly templates = signal<Workflow[]>(undefined) readonly templates = signal<Workflow[]>(undefined)
readonly correspondents = toSignal( readonly correspondents = toSignal(
this.correspondentService.listAll().pipe(map((result) => result.results)), this.correspondentService.listAll().pipe(
map((result) => result.results),
catchError((error) => this.handleRelatedObjectLoadError(error))
),
{ initialValue: undefined as Correspondent[] } { initialValue: undefined as Correspondent[] }
) )
readonly documentTypes = toSignal( readonly documentTypes = toSignal(
this.documentTypeService.listAll().pipe(map((result) => result.results)), this.documentTypeService.listAll().pipe(
map((result) => result.results),
catchError((error) => this.handleRelatedObjectLoadError(error))
),
{ initialValue: undefined as DocumentType[] } { initialValue: undefined as DocumentType[] }
) )
readonly storagePaths = toSignal( readonly storagePaths = toSignal(
this.storagePathService.listAll().pipe(map((result) => result.results)), this.storagePathService.listAll().pipe(
map((result) => result.results),
catchError((error) => this.handleRelatedObjectLoadError(error))
),
{ initialValue: undefined as StoragePath[] } { initialValue: undefined as StoragePath[] }
) )
readonly mailRules = toSignal( readonly mailRules = toSignal(
this.mailRuleService.listAll().pipe(map((result) => result.results)), this.mailRuleService.listAll().pipe(
map((result) => result.results),
catchError((error) => this.handleRelatedObjectLoadError(error))
),
{ initialValue: undefined as MailRule[] } { initialValue: undefined as MailRule[] }
) )
readonly customFields = toSignal( readonly customFields = toSignal(
this.customFieldsService.listAll().pipe(map((result) => result.results)), this.customFieldsService.listAll().pipe(
map((result) => result.results),
catchError((error) => this.handleRelatedObjectLoadError(error))
),
{ initialValue: undefined as CustomField[] } { initialValue: undefined as CustomField[] }
) )
readonly dateCustomFields = computed(() => readonly dateCustomFields = computed(() =>
@@ -545,6 +563,17 @@ export class WorkflowEditDialogComponent
SETTINGS_KEYS.AI_ENABLED SETTINGS_KEYS.AI_ENABLED
) )
private handleRelatedObjectLoadError(error) {
if (!this.relatedObjectLoadErrorShown) {
this.relatedObjectLoadErrorShown = true
this.toastService.showError(
$localize`Some workflow options could not be loaded.`,
error
)
}
return of([])
}
expandedItem: number = null expandedItem: number = null
private readonly triggerFilterOptionsMap = new WeakMap< private readonly triggerFilterOptionsMap = new WeakMap<
@@ -7,8 +7,9 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { ToastService } from 'src/app/services/toast.service'
import { PermissionsGroupComponent } from './permissions-group.component' import { PermissionsGroupComponent } from './permissions-group.component'
describe('PermissionsGroupComponent', () => { describe('PermissionsGroupComponent', () => {
@@ -60,4 +61,19 @@ describe('PermissionsGroupComponent', () => {
expect(component.value).toEqual({ id: 2, name: 'Group 2' }) expect(component.value).toEqual({ id: 2, name: 'Group 2' })
expect(groupServiceSpy).toHaveBeenCalled() expect(groupServiceSpy).toHaveBeenCalled()
}) })
it('should use an empty group list when retrieval fails', () => {
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
groupServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
const failedFixture = TestBed.createComponent(PermissionsGroupComponent)
const failedComponent = failedFixture.componentInstance
expect(failedComponent.groups()).toEqual([])
expect(() => failedFixture.detectChanges()).not.toThrow()
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving groups',
expect.any(Error)
)
})
}) })
@@ -6,9 +6,10 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectComponent } from '@ng-select/ng-select' import { NgSelectComponent } from '@ng-select/ng-select'
import { map } from 'rxjs/operators' import { catchError, map, of } from 'rxjs'
import { Group } from 'src/app/data/group' import { Group } from 'src/app/data/group'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { ToastService } from 'src/app/services/toast.service'
import { AbstractInputComponent } from '../../abstract-input' import { AbstractInputComponent } from '../../abstract-input'
@Component({ @Component({
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
}) })
export class PermissionsGroupComponent extends AbstractInputComponent<Group> { export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
private readonly groupService = inject(GroupService) private readonly groupService = inject(GroupService)
private readonly toastService = inject(ToastService)
readonly groups = toSignal( readonly groups = toSignal(
this.groupService.listAll().pipe(map((result) => result.results)), this.groupService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError($localize`Error retrieving groups`, error)
return of([])
})
),
{ initialValue: undefined as Group[] } { initialValue: undefined as Group[] }
) )
} }
@@ -7,8 +7,9 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { PermissionsUserComponent } from './permissions-user.component' import { PermissionsUserComponent } from './permissions-user.component'
describe('PermissionsUserComponent', () => { describe('PermissionsUserComponent', () => {
@@ -60,4 +61,19 @@ describe('PermissionsUserComponent', () => {
expect(component.value).toEqual({ id: 2, name: 'User 2' }) expect(component.value).toEqual({ id: 2, name: 'User 2' })
expect(userServiceSpy).toHaveBeenCalled() expect(userServiceSpy).toHaveBeenCalled()
}) })
it('should use an empty user list when retrieval fails', () => {
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
userServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
const failedFixture = TestBed.createComponent(PermissionsUserComponent)
const failedComponent = failedFixture.componentInstance
expect(failedComponent.users()).toEqual([])
expect(() => failedFixture.detectChanges()).not.toThrow()
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving users',
expect.any(Error)
)
})
}) })
@@ -6,9 +6,10 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectComponent } from '@ng-select/ng-select' import { NgSelectComponent } from '@ng-select/ng-select'
import { map } from 'rxjs/operators' import { catchError, map, of } from 'rxjs'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { AbstractInputComponent } from '../../abstract-input' import { AbstractInputComponent } from '../../abstract-input'
@Component({ @Component({
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
}) })
export class PermissionsUserComponent extends AbstractInputComponent<User[]> { export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
private readonly userService = inject(UserService) private readonly userService = inject(UserService)
private readonly toastService = inject(ToastService)
readonly users = toSignal( readonly users = toSignal(
this.userService.listAll().pipe(map((result) => result.results)), this.userService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError($localize`Error retrieving users`, error)
return of([])
})
),
{ initialValue: undefined as User[] } { initialValue: undefined as User[] }
) )
} }
@@ -1,6 +1,6 @@
<div class="mb-3"> <div [class.mb-3]="!compact">
<div class="row"> <div [class.row]="!compact">
@if (!horizontal) { @if (!horizontal && !compact) {
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3"> <div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end"> <label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
{{title}} {{title}}
@@ -17,8 +17,8 @@
} }
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}"> <div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled"> <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
@if (horizontal) { @if (horizontal && !compact) {
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end"> <label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
{{title}} {{title}}
@if (showUnsetNote && isUnset) { @if (showUnsetNote && isUnset) {
@@ -48,4 +48,14 @@ describe('SwitchComponent', () => {
component.value = undefined component.value = undefined
expect(component.isUnset).toBeTruthy() expect(component.isUnset).toBeTruthy()
}) })
it('should support a compact layout', () => {
component.compact = true
component.title = 'Test switch'
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
expect(input.getAttribute('aria-label')).toEqual('Test switch')
})
}) })
@@ -25,6 +25,9 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
@Input() @Input()
showUnsetNote: boolean = false showUnsetNote: boolean = false
@Input()
compact: boolean = false
constructor() { constructor() {
super() super()
} }
@@ -4,8 +4,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component' import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component' import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component' import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
@@ -77,6 +78,23 @@ describe('PermissionsDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('should use an empty user list when retrieval fails', () => {
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
jest
.spyOn(TestBed.inject(UserService), 'listAll')
.mockReturnValue(throwError(() => new Error('Forbidden')))
const failedFixture = TestBed.createComponent(PermissionsDialogComponent)
const failedComponent = failedFixture.componentInstance
expect(failedComponent.users()).toEqual([])
expect(() => failedFixture.detectChanges()).not.toThrow()
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving users',
expect.any(Error)
)
})
it('should return permissions', () => { it('should return permissions', () => {
expect(component.permissions).toEqual({ expect(component.permissions).toEqual({
owner: null, owner: null,
@@ -14,10 +14,11 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { map } from 'rxjs' import { catchError, map, of } from 'rxjs'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component' import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
import { SwitchComponent } from '../input/switch/switch.component' import { SwitchComponent } from '../input/switch/switch.component'
@@ -35,9 +36,16 @@ import { SwitchComponent } from '../input/switch/switch.component'
export class PermissionsDialogComponent { export class PermissionsDialogComponent {
activeModal = inject(NgbActiveModal) activeModal = inject(NgbActiveModal)
private userService = inject(UserService) private userService = inject(UserService)
private toastService = inject(ToastService)
readonly users = toSignal( readonly users = toSignal(
this.userService.listAll().pipe(map((r) => r.results)), this.userService.listAll().pipe(
map((r) => r.results),
catchError((error) => {
this.toastService.showError($localize`Error retrieving users`, error)
return of([])
})
),
{ initialValue: undefined as User[] } { initialValue: undefined as User[] }
) )
readonly title = signal($localize`Set permissions`) readonly title = signal($localize`Set permissions`)
@@ -1,22 +1,38 @@
<div class="border border-top-0 rounded-bottom p-3"> <div class="modal-header">
<h4 class="modal-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (loading()) {
<div class="d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span i18n>Loading share link bundles…</span>
</div>
}
@if (!loading() && error()) { @if (!loading() && error()) {
<div class="alert alert-danger mb-0" role="alert"> <div class="alert alert-danger mb-0" role="alert">
{{ error() }} {{ error() }}
</div> </div>
} }
@if (!loading() && !error()) { @if (!loading() && !error()) {
<div class="d-flex justify-content-between align-items-center mb-2">
<p class="mb-0 text-muted small">
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
</p>
</div>
@if (bundles().length === 0) { @if (bundles().length === 0) {
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p> <p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
} }
@if (bundles().length > 0) { @if (bundles().length > 0) {
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle mb-0 bg-body"> <table class="table table-sm align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th> <th scope="col" i18n>Created</th>
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th> <th scope="col" i18n>Status</th>
<th scope="col" i18n>Size</th> <th scope="col" i18n>Size</th>
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th> <th scope="col" i18n>Expires</th>
<th scope="col" i18n>Documents</th> <th scope="col" i18n>Documents</th>
<th scope="col" i18n>File version</th> <th scope="col" i18n>File version</th>
<th scope="col" class="text-end" i18n>Actions</th> <th scope="col" class="text-end" i18n>Actions</th>
@@ -80,9 +96,6 @@
<td> <td>
@if (bundle.expiration) { @if (bundle.expiration) {
{{ bundle.expiration | date: 'short' }} {{ bundle.expiration | date: 'short' }}
@if (isExpired(bundle.expiration)) {
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
}
} }
@if (!bundle.expiration) { @if (!bundle.expiration) {
<span i18n>Never</span> <span i18n>Never</span>
@@ -91,49 +104,42 @@
<td>{{ bundle.document_count }}</td> <td>{{ bundle.document_count }}</td>
<td>{{ fileVersionLabel(bundle.file_version) }}</td> <td>{{ fileVersionLabel(bundle.file_version) }}</td>
<td class="text-end"> <td class="text-end">
<div class="d-inline-block position-relative"> <div class="btn-group btn-group-sm">
<span <button
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap" type="button"
[class.show]="copiedSlug() === bundle.slug" class="btn btn-outline-primary"
i18n [disabled]="bundle.status !== statuses.Ready"
>Copied!</span> (click)="copy(bundle)"
<div class="btn-group btn-group-sm"> title="Copy share link"
i18n-title
>
@if (copiedSlug() === bundle.slug) {
<i-bs name="clipboard-check"></i-bs>
}
@if (copiedSlug() !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
@if (bundle.status === statuses.Failed) {
<button <button
type="button" type="button"
class="btn btn-outline-primary" class="btn btn-outline-warning"
[disabled]="bundle.status !== statuses.Ready"
(click)="copy(bundle)"
title="Copy share link"
i18n-title
>
@if (copiedSlug() === bundle.slug) {
<i-bs name="clipboard-check"></i-bs>
}
@if (copiedSlug() !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
@if (bundle.status === statuses.Failed) {
<button
type="button"
class="btn btn-outline-warning"
[disabled]="loading()"
(click)="retry(bundle)"
>
<i-bs name="arrow-clockwise"></i-bs>
<span class="visually-hidden" i18n>Retry</span>
</button>
}
<pngx-confirm-button
buttonClasses="btn btn-sm btn-outline-danger"
[disabled]="loading()" [disabled]="loading()"
(confirm)="delete(bundle)" (click)="retry(bundle)"
iconName="trash"
> >
<span class="visually-hidden" i18n>Delete share link bundle</span> <i-bs name="arrow-clockwise"></i-bs>
</pngx-confirm-button> <span class="visually-hidden" i18n>Retry</span>
</div> </button>
}
<pngx-confirm-button
buttonClasses="btn btn-sm btn-outline-danger"
[disabled]="loading()"
(confirm)="delete(bundle)"
iconName="trash"
>
<span class="visually-hidden" i18n>Delete share link bundle</span>
</pngx-confirm-button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -141,32 +147,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3">
<div class="d-flex align-items-center">
<label class="small text-muted me-2" for="shareLinkBundlePageSize" i18n>Show:</label>
<select id="shareLinkBundlePageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
</div>
@if (total() > pageSize) {
<ngb-pagination
class="mb-0"
[pageSize]="pageSize"
[collectionSize]="total()"
[page]="page()"
[maxSize]="5"
(pageChange)="setPage($event)"
size="sm"
aria-label="Share link bundles pagination"
i18n-aria-label
></ngb-pagination>
}
</div>
</div>
} }
} }
</div> </div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
</div>
@@ -1,5 +1,6 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { FileVersion } from 'src/app/data/share-link' import { FileVersion } from 'src/app/data/share-link'
@@ -7,15 +8,13 @@ import {
ShareLinkBundleStatus, ShareLinkBundleStatus,
ShareLinkBundleSummary, ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle' } from 'src/app/data/share-link-bundle'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ShareLinkBundleListComponent } from './share-link-bundle-list.component' import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
class MockShareLinkBundleService { class MockShareLinkBundleService {
list = jest.fn() listAllBundles = jest.fn()
delete = jest.fn() delete = jest.fn()
rebuildBundle = jest.fn() rebuildBundle = jest.fn()
} }
@@ -25,12 +24,13 @@ class MockToastService {
showError = jest.fn() showError = jest.fn()
} }
describe('ShareLinkBundleListComponent', () => { describe('ShareLinkBundleManageDialogComponent', () => {
let component: ShareLinkBundleListComponent let component: ShareLinkBundleManageDialogComponent
let fixture: ComponentFixture<ShareLinkBundleListComponent> let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
let service: MockShareLinkBundleService let service: MockShareLinkBundleService
let toastService: MockToastService let toastService: MockToastService
let clipboard: Clipboard let clipboard: Clipboard
let activeModal: NgbActiveModal
let originalApiBaseUrl: string let originalApiBaseUrl: string
beforeEach(() => { beforeEach(() => {
@@ -38,24 +38,26 @@ describe('ShareLinkBundleListComponent', () => {
toastService = new MockToastService() toastService = new MockToastService()
originalApiBaseUrl = environment.apiBaseUrl originalApiBaseUrl = environment.apiBaseUrl
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(of(true)) service.delete.mockReturnValue(of(true))
service.rebuildBundle.mockReturnValue(of(sampleBundle())) service.rebuildBundle.mockReturnValue(of(sampleBundle()))
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
ShareLinkBundleListComponent, ShareLinkBundleManageDialogComponent,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
], ],
providers: [ providers: [
NgbActiveModal,
{ provide: ShareLinkBundleService, useValue: service }, { provide: ShareLinkBundleService, useValue: service },
{ provide: ToastService, useValue: toastService }, { provide: ToastService, useValue: toastService },
], ],
}) })
fixture = TestBed.createComponent(ShareLinkBundleListComponent) fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
component = fixture.componentInstance component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
activeModal = TestBed.inject(NgbActiveModal)
}) })
afterEach(() => { afterEach(() => {
@@ -82,28 +84,28 @@ describe('ShareLinkBundleListComponent', () => {
it('loads bundles on init and polls periodically', () => { it('loads bundles on init and polls periodically', () => {
jest.useFakeTimers() jest.useFakeTimers()
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })] const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
service.list.mockReset() service.listAllBundles.mockReset()
service.list service.listAllBundles
.mockReturnValueOnce(of({ count: bundles.length, results: bundles })) .mockReturnValueOnce(of(bundles))
.mockReturnValue(of({ count: bundles.length, results: bundles })) .mockReturnValue(of(bundles))
fixture.detectChanges() fixture.detectChanges()
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true) expect(service.listAllBundles).toHaveBeenCalledTimes(1)
expect(component.bundles()).toEqual(bundles) expect(component.bundles()).toEqual(bundles)
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
expect(component.error()).toBeNull() expect(component.error()).toBeNull()
jest.advanceTimersByTime(5000) jest.advanceTimersByTime(5000)
expect(service.list).toHaveBeenCalledTimes(2) expect(service.listAllBundles).toHaveBeenCalledTimes(2)
}) })
it('handles errors when loading bundles', () => { it('handles errors when loading bundles', () => {
jest.useFakeTimers() jest.useFakeTimers()
service.list.mockReset() service.listAllBundles.mockReset()
service.list service.listAllBundles
.mockReturnValueOnce(throwError(() => new Error('load fail'))) .mockReturnValueOnce(throwError(() => new Error('load fail')))
.mockReturnValue(of({ count: 0, results: [] })) .mockReturnValue(of([]))
fixture.detectChanges() fixture.detectChanges()
@@ -112,57 +114,7 @@ describe('ShareLinkBundleListComponent', () => {
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
jest.advanceTimersByTime(5000) jest.advanceTimersByTime(5000)
expect(service.list).toHaveBeenCalledTimes(2) expect(service.listAllBundles).toHaveBeenCalledTimes(2)
})
it('loads another page', () => {
fixture.detectChanges()
component.setPage(2)
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
})
it('sorts bundles and returns to the first page', () => {
fixture.detectChanges()
component.page.set(2)
component.onSort({ column: 'status', reverse: false })
expect(component.page()).toBe(1)
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'status', false)
})
it('marks expired share link bundles', () => {
service.list.mockReturnValue(
of({
count: 1,
results: [sampleBundle({ expiration: '2000-01-01T00:00:00.000Z' })],
})
)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Expired')
})
it('stores a changed page size and reloads from the first page', () => {
fixture.detectChanges()
const settingsService = TestBed.inject(SettingsService)
jest
.spyOn(settingsService, 'get')
.mockReturnValueOnce({ share_link_bundles: 25 })
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
component.page.set(2)
component.pageSize = 100
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
share_link_bundles: 100,
})
expect(component.page()).toBe(1)
expect(service.list).toHaveBeenLastCalledWith(1, 100, 'created', true)
}) })
it('copies bundle links when ready', () => { it('copies bundle links when ready', () => {
@@ -174,24 +126,16 @@ describe('ShareLinkBundleListComponent', () => {
slug: 'ready-slug', slug: 'ready-slug',
status: ShareLinkBundleStatus.Ready, status: ShareLinkBundleStatus.Ready,
}) })
component.bundles.set([readyBundle])
fixture.detectChanges()
component.copy(readyBundle) component.copy(readyBundle)
expect(clipboard.copy).toHaveBeenCalledWith( expect(clipboard.copy).toHaveBeenCalledWith(
component.getShareUrl(readyBundle) component.getShareUrl(readyBundle)
) )
expect(component.copiedSlug()).toBe('ready-slug') expect(component.copiedSlug()).toBe('ready-slug')
expect(toastService.showInfo).not.toHaveBeenCalled() expect(toastService.showInfo).toHaveBeenCalled()
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.badge.show').textContent
).toContain('Copied!')
jest.advanceTimersByTime(3000) jest.advanceTimersByTime(3000)
expect(component.copiedSlug()).toBeNull() expect(component.copiedSlug()).toBeNull()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
}) })
it('ignores copy requests for non-ready bundles', () => { it('ignores copy requests for non-ready bundles', () => {
@@ -202,7 +146,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('deletes bundles and refreshes list', () => { it('deletes bundles and refreshes list', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(of(true)) service.delete.mockReturnValue(of(true))
fixture.detectChanges() fixture.detectChanges()
@@ -213,12 +157,12 @@ describe('ShareLinkBundleListComponent', () => {
expect(toastService.showInfo).toHaveBeenCalledWith( expect(toastService.showInfo).toHaveBeenCalledWith(
expect.stringContaining('deleted.') expect.stringContaining('deleted.')
) )
expect(service.list).toHaveBeenCalledTimes(2) expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
}) })
it('handles delete errors gracefully', () => { it('handles delete errors gracefully', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(throwError(() => new Error('delete fail'))) service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
fixture.detectChanges() fixture.detectChanges()
@@ -230,7 +174,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('retries bundle build and replaces existing entry', () => { it('retries bundle build and replaces existing entry', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready }) const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
service.rebuildBundle.mockReturnValue(of(updated)) service.rebuildBundle.mockReturnValue(of(updated))
@@ -245,7 +189,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('adds new bundle when retry returns unknown entry', () => { it('adds new bundle when retry returns unknown entry', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue( service.rebuildBundle.mockReturnValue(
of(sampleBundle({ id: 99, slug: 'new-slug' })) of(sampleBundle({ id: 99, slug: 'new-slug' }))
) )
@@ -259,7 +203,7 @@ describe('ShareLinkBundleListComponent', () => {
}) })
it('handles retry errors', () => { it('handles retry errors', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail'))) service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
fixture.detectChanges() fixture.detectChanges()
@@ -269,8 +213,8 @@ describe('ShareLinkBundleListComponent', () => {
expect(toastService.showError).toHaveBeenCalled() expect(toastService.showError).toHaveBeenCalled()
}) })
it('maps status and file version helpers', () => { it('maps helpers and closes dialog', () => {
service.list.mockReturnValue(of({ count: 0, results: [] })) service.listAllBundles.mockReturnValue(of([]))
fixture.detectChanges() fixture.detectChanges()
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
@@ -283,5 +227,9 @@ describe('ShareLinkBundleListComponent', () => {
environment.apiBaseUrl = 'https://example.com/api/' environment.apiBaseUrl = 'https://example.com/api/'
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' })) const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
expect(url).toBe('https://example.com/share/sluggy') expect(url).toBe('https://example.com/share/sluggy')
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
}) })
}) })
@@ -1,11 +1,7 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core' import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms' import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import {
NgbPaginationModule,
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs' import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
import { FileVersion } from 'src/app/data/share-link' import { FileVersion } from 'src/app/data/share-link'
@@ -15,77 +11,42 @@ import {
ShareLinkBundleStatus, ShareLinkBundleStatus,
ShareLinkBundleSummary, ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle' } from 'src/app/data/share-link-bundle'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import {
SortEvent,
SortableDirective,
} from 'src/app/directives/sortable.directive'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe' import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component' import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
@Component({ @Component({
selector: 'pngx-share-link-bundle-list', selector: 'pngx-share-link-bundle-manage-dialog',
templateUrl: './share-link-bundle-list.component.html', templateUrl: './share-link-bundle-manage-dialog.component.html',
styleUrls: ['./share-link-bundle-list.component.scss'], styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
imports: [ imports: [
ConfirmButtonComponent, ConfirmButtonComponent,
CommonModule, CommonModule,
FormsModule,
NgbPaginationModule,
NgbPopoverModule, NgbPopoverModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
SortableDirective,
FileSizePipe, FileSizePipe,
], ],
}) })
export class ShareLinkBundleListComponent export class ShareLinkBundleManageDialogComponent
extends LoadingComponentWithPermissions extends LoadingComponentWithPermissions
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
private readonly activeModal = inject(NgbActiveModal)
private readonly shareLinkBundleService = inject(ShareLinkBundleService) private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly settingsService = inject(SettingsService)
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
private readonly clipboard = inject(Clipboard) private readonly clipboard = inject(Clipboard)
title = $localize`Share link bundles`
readonly bundles = signal<ShareLinkBundleSummary[]>([]) readonly bundles = signal<ShareLinkBundleSummary[]>([])
readonly error = signal<string | null>(null) readonly error = signal<string | null>(null)
readonly copiedSlug = signal<string | null>(null) readonly copiedSlug = signal<string | null>(null)
readonly total = signal(0)
readonly page = signal(1)
readonly sortField = signal('created')
readonly sortReverse = signal(true)
readonly statuses = ShareLinkBundleStatus readonly statuses = ShareLinkBundleStatus
readonly fileVersions = FileVersion readonly fileVersions = FileVersion
get pageSize(): number {
return (
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)
?.share_link_bundles || 25
)
}
set pageSize(pageSize: number) {
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
share_link_bundles: pageSize,
})
this.settingsService.storeSettings().subscribe({
next: () => {
this.page.set(1)
this.triggerRefresh(false)
},
error: (error) => {
this.toastService.showError($localize`Error saving settings`, error)
},
})
}
private readonly refresh$ = new Subject<boolean>() private readonly refresh$ = new Subject<boolean>()
ngOnInit(): void { ngOnInit(): void {
@@ -96,33 +57,25 @@ export class ShareLinkBundleListComponent
this.loading.set(true) this.loading.set(true)
} }
this.error.set(null) this.error.set(null)
return this.shareLinkBundleService return this.shareLinkBundleService.listAllBundles().pipe(
.list( catchError((error) => {
this.page(), if (!silent) {
this.pageSize, this.loading.set(false)
this.sortField(), }
this.sortReverse() this.error.set($localize`Failed to load share link bundles.`)
) this.toastService.showError(
.pipe( $localize`Error retrieving share link bundles.`,
catchError((error) => { error
if (!silent) { )
this.loading.set(false) return of(null)
} })
this.error.set($localize`Failed to load share link bundles.`) )
this.toastService.showError(
$localize`Error retrieving share link bundles.`,
error
)
return of(null)
})
)
}), }),
takeUntil(this.unsubscribeNotifier) takeUntil(this.unsubscribeNotifier)
) )
.subscribe((results) => { .subscribe((results) => {
if (results) { if (results) {
this.bundles.set(results.results) this.bundles.set(results)
this.total.set(results.count)
this.copiedSlug.set(null) this.copiedSlug.set(null)
} }
this.loading.set(false) this.loading.set(false)
@@ -145,18 +98,6 @@ export class ShareLinkBundleListComponent
}` }`
} }
setPage(page: number): void {
this.page.set(page)
this.triggerRefresh(false)
}
onSort(event: SortEvent): void {
this.sortField.set(event.column || 'created')
this.sortReverse.set(event.column ? event.reverse : true)
this.page.set(1)
this.triggerRefresh(false)
}
copy(bundle: ShareLinkBundleSummary): void { copy(bundle: ShareLinkBundleSummary): void {
if (bundle.status !== ShareLinkBundleStatus.Ready) { if (bundle.status !== ShareLinkBundleStatus.Ready) {
return return
@@ -167,6 +108,7 @@ export class ShareLinkBundleListComponent
setTimeout(() => { setTimeout(() => {
this.copiedSlug.set(null) this.copiedSlug.set(null)
}, 3000) }, 3000)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
} }
} }
@@ -175,9 +117,6 @@ export class ShareLinkBundleListComponent
this.loading.set(true) this.loading.set(true)
this.shareLinkBundleService.delete(bundle).subscribe({ this.shareLinkBundleService.delete(bundle).subscribe({
next: () => { next: () => {
if (this.bundles().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1)
}
this.toastService.showInfo($localize`Share link bundle deleted.`) this.toastService.showInfo($localize`Share link bundle deleted.`)
this.triggerRefresh(false) this.triggerRefresh(false)
}, },
@@ -214,8 +153,8 @@ export class ShareLinkBundleListComponent
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
} }
isExpired(expiration?: string): boolean { close(): void {
return !!expiration && Date.parse(expiration) <= Date.now() this.activeModal.close()
} }
private replaceBundle(updated: ShareLinkBundleSummary): void { private replaceBundle(updated: ShareLinkBundleSummary): void {
@@ -1473,6 +1473,35 @@ describe('DocumentDetailComponent', () => {
}) })
}) })
it('should not automatically get suggestions if auto-suggest is disabled', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
suggestionsSpy.mockReturnValue(of({ tags: [42] }))
initNormally()
expect(suggestionsSpy).not.toHaveBeenCalled()
// still available on demand
component.getSuggestions()
expect(suggestionsSpy).toHaveBeenCalled()
})
it('should not automatically get AI suggestions if auto-suggest is disabled', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
const getSetting = settingsService.get.bind(settingsService)
jest
.spyOn(settingsService, 'get')
.mockImplementation((key) =>
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
)
const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
aiSuggestionsSpy.mockReturnValue(of({ tags: [42] }))
initNormally()
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
component.getSuggestions()
expect(aiSuggestionsSpy).toHaveBeenCalled()
})
it('should reset the suggestions loading state if the document changes mid-request', () => { it('should reset the suggestions loading state if the document changes mid-request', () => {
const getSetting = settingsService.get.bind(settingsService) const getSetting = settingsService.get.bind(settingsService)
jest jest
@@ -237,6 +237,9 @@ export class DocumentDetailComponent
this.settings.getSignal<boolean>( this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
) )
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
)
private readonly hiddenFieldsSetting = this.settings.getSignal< private readonly hiddenFieldsSetting = this.settings.getSignal<
DocumentDetailFieldID[] DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS) >(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@@ -357,6 +360,10 @@ export class DocumentDetailComponent
return this.aiEnabledSetting() return this.aiEnabledSetting()
} }
get autoSuggest(): boolean {
return this.autoSuggestSetting()
}
get archiveContentRenderType(): ContentRenderType { get archiveContentRenderType(): ContentRenderType {
const hasArchiveVersion = const hasArchiveVersion =
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
@@ -904,6 +911,7 @@ export class DocumentDetailComponent
this.updateFormForCustomFields() this.updateFormForCustomFields()
this.loadMetadataForSelectedVersion() this.loadMetadataForSelectedVersion()
if ( if (
this.autoSuggest &&
this.permissionsService.currentUserHasObjectPermissions( this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change, PermissionAction.Change,
doc doc
@@ -7,7 +7,6 @@ import {
import { EventEmitter, signal } from '@angular/core' import { EventEmitter, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
@@ -47,6 +46,7 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component' import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component' import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
import { BulkEditorComponent } from './bulk-editor.component' import { BulkEditorComponent } from './bulk-editor.component'
const selectionData: SelectionData = { const selectionData: SelectionData = {
@@ -82,7 +82,6 @@ describe('BulkEditorComponent', () => {
let customFieldsService: CustomFieldsService let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
let shareLinkBundleService: ShareLinkBundleService let shareLinkBundleService: ShareLinkBundleService
let router: Router
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -168,14 +167,11 @@ describe('BulkEditorComponent', () => {
provide: ShareLinkBundleService, provide: ShareLinkBundleService,
useValue: { useValue: {
createBundle: jest.fn(), createBundle: jest.fn(),
listAllBundles: jest.fn(),
rebuildBundle: jest.fn(), rebuildBundle: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
}, },
}, },
{
provide: Router,
useValue: { navigate: jest.fn().mockResolvedValue(true) },
},
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
], ],
@@ -193,7 +189,6 @@ describe('BulkEditorComponent', () => {
customFieldsService = TestBed.inject(CustomFieldsService) customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
shareLinkBundleService = TestBed.inject(ShareLinkBundleService) shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
router = TestBed.inject(Router)
fixture = TestBed.createComponent(BulkEditorComponent) fixture = TestBed.createComponent(BulkEditorComponent)
component = fixture.componentInstance component = fixture.componentInstance
@@ -1829,9 +1824,9 @@ describe('BulkEditorComponent', () => {
}, },
} }
const openSpy = jest const openSpy = jest.spyOn(modalService, 'open')
.spyOn(modalService, 'open') openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
.mockReturnValueOnce(modalRef as NgbModalRef) openSpy.mockReturnValueOnce({} as NgbModalRef)
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce( ;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
of({ id: 42 }) of({ id: 42 })
) )
@@ -1865,9 +1860,11 @@ describe('BulkEditorComponent', () => {
dialogInstance.onOpenManage() dialogInstance.onOpenManage()
expect(modalRef.close).toHaveBeenCalled() expect(modalRef.close).toHaveBeenCalled()
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], { expect(openSpy).toHaveBeenNthCalledWith(
queryParams: { type: 'bundles' }, 2,
}) ShareLinkBundleManageDialogComponent,
expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
openSpy.mockRestore() openSpy.mockRestore()
}) })
@@ -1920,10 +1917,13 @@ describe('BulkEditorComponent', () => {
openSpy.mockRestore() openSpy.mockRestore()
}) })
it('should navigate to share link bundle management', () => { it('should open share link bundle management dialog', () => {
const openSpy = jest.spyOn(modalService, 'open')
component.manageShareLinkBundles() component.manageShareLinkBundles()
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], { expect(openSpy).toHaveBeenCalledWith(
queryParams: { type: 'bundles' }, ShareLinkBundleManageDialogComponent,
}) expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
openSpy.mockRestore()
}) })
}) })
@@ -12,7 +12,6 @@ import {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { Router } from '@angular/router'
import { import {
NgbDropdownModule, NgbDropdownModule,
NgbModal, NgbModal,
@@ -70,6 +69,7 @@ import {
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component' import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component' import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
@@ -104,7 +104,6 @@ export class BulkEditorComponent
public readonly permissionService = inject(PermissionsService) public readonly permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private readonly shareLinkBundleService = inject(ShareLinkBundleService) private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly router = inject(Router)
tagSelectionModel = new FilterableDropdownSelectionModel(true) tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
@@ -1136,8 +1135,9 @@ export class BulkEditorComponent
} }
manageShareLinkBundles() { manageShareLinkBundles() {
void this.router.navigate(['/share-links'], { this.modalService.open(ShareLinkBundleManageDialogComponent, {
queryParams: { type: 'bundles' }, backdrop: 'static',
size: 'lg',
}) })
} }
@@ -10,7 +10,7 @@
} }
</div> </div>
</div> </div>
@if (textFilterTarget === 'asn') { @if (textFilterTarget === 'asn' || textFilterTarget === 'duplicates') {
<select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()"> <select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
@for (m of textFilterModifiers; track m) { @for (m of textFilterModifiers; track m) {
<option ngbDropdownItem [value]="m.id">{{m.label}}</option> <option ngbDropdownItem [value]="m.id">{{m.label}}</option>
@@ -23,7 +23,7 @@
</button> </button>
} }
<input #textFilterInput class="form-control form-control-sm" type="text" <input #textFilterInput class="form-control form-control-sm" type="text"
[disabled]="textFilterModifierIsNull" [disabled]="textFilterInputDisabled"
[(ngModel)]="textFilter" [(ngModel)]="textFilter"
(keydown)="textFilterKeydown($event)" (keydown)="textFilterKeydown($event)"
[ngbTypeahead]="searchAutoComplete" [ngbTypeahead]="searchAutoComplete"
@@ -53,6 +53,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_DUPLICATES,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_HAS_TAGS_ANY, FILTER_HAS_TAGS_ANY,
@@ -427,6 +428,38 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE
}) })
it('should ingest filter rules for documents with duplicates', () => {
component.filterRules = [
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'true',
},
]
fixture.detectChanges()
expect(component.textFilterTarget).toEqual('duplicates')
expect(component.textFilterModifier).toEqual('has-duplicates')
expect(component.textFilterInputDisabled).toBeTruthy()
})
it('should ingest filter rules for documents without duplicates', () => {
component.filterRules = [
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'false',
},
]
expect(component.textFilterTarget).toEqual('duplicates')
expect(component.textFilterModifier).toEqual('does-not-have-duplicates')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'false',
},
])
})
it('should ingest text filter rules for fulltext query', () => { it('should ingest text filter rules for fulltext query', () => {
expect(component.textFilter).toEqual(null) expect(component.textFilter).toEqual(null)
component.filterRules = [ component.filterRules = [
@@ -1390,6 +1423,33 @@ describe('FilterEditorComponent', () => {
]) ])
}) })
it('should convert duplicate target input to the correct filter rule', () => {
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[5]
textFieldTargetDropdown.triggerEventHandler('click')
fixture.detectChanges()
expect(component.textFilterTarget).toEqual('duplicates')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'true',
},
])
const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
textFieldModifierSelect.nativeElement.value = 'does-not-have-duplicates'
textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'false',
},
])
})
it('should convert user input to correct filter rules on full text query', () => { it('should convert user input to correct filter rules on full text query', () => {
component.textFilterInput.nativeElement.value = 'foo' component.textFilterInput.nativeElement.value = 'foo'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input')) component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
@@ -2178,6 +2238,22 @@ describe('FilterEditorComponent', () => {
] ]
expect(component.generateFilterName()).toEqual('Without any tag') expect(component.generateFilterName()).toEqual('Without any tag')
component.filterRules = [
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'true',
},
]
expect(component.generateFilterName()).toEqual('With duplicates')
component.filterRules = [
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'false',
},
]
expect(component.generateFilterName()).toEqual('Without duplicates')
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_CUSTOM_FIELDS_QUERY, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
@@ -65,6 +65,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_DUPLICATES,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_HAS_TAGS_ANY, FILTER_HAS_TAGS_ANY,
@@ -129,12 +130,15 @@ const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike' const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields' const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type' const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type'
const TEXT_FILTER_TARGET_DUPLICATES = 'duplicates'
const TEXT_FILTER_MODIFIER_EQUALS = 'equals' const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
const TEXT_FILTER_MODIFIER_NULL = 'is null' const TEXT_FILTER_MODIFIER_NULL = 'is null'
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null' const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
const TEXT_FILTER_MODIFIER_GT = 'greater' const TEXT_FILTER_MODIFIER_GT = 'greater'
const TEXT_FILTER_MODIFIER_LT = 'less' const TEXT_FILTER_MODIFIER_LT = 'less'
const TEXT_FILTER_MODIFIER_HAS_DUPLICATES = 'has-duplicates'
const TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES = 'does-not-have-duplicates'
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g
@@ -205,6 +209,7 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY, id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
name: $localize`Advanced search`, name: $localize`Advanced search`,
}, },
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
] ]
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = { const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
@@ -241,6 +246,17 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
}, },
] ]
const DUPLICATES_FILTER_MODIFIER_OPTIONS = [
{
id: TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
label: $localize`exist`,
},
{
id: TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
label: $localize`do not exist`,
},
]
@Component({ @Component({
selector: 'pngx-filter-editor', selector: 'pngx-filter-editor',
templateUrl: './filter-editor.component.html', templateUrl: './filter-editor.component.html',
@@ -320,6 +336,12 @@ export class FilterEditorComponent
if (rule.value == 'false') { if (rule.value == 'false') {
return $localize`Without any tag` return $localize`Without any tag`
} }
break
case FILTER_HAS_DUPLICATES:
return rule.value == 'false'
? $localize`Without duplicates`
: $localize`With duplicates`
case FILTER_CUSTOM_FIELDS_QUERY: case FILTER_CUSTOM_FIELDS_QUERY:
return $localize`Custom fields query` return $localize`Custom fields query`
@@ -390,7 +412,9 @@ export class FilterEditorComponent
public textFilterModifier: string public textFilterModifier: string
get textFilterModifiers() { get textFilterModifiers() {
return DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS return this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
? DUPLICATES_FILTER_MODIFIER_OPTIONS
: DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
} }
get textFilterModifierIsNull(): boolean { get textFilterModifierIsNull(): boolean {
@@ -399,6 +423,13 @@ export class FilterEditorComponent
) )
} }
get textFilterInputDisabled(): boolean {
return (
this.textFilterModifierIsNull ||
this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
)
}
tagSelectionModel = new FilterableDropdownSelectionModel(true) tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
@@ -444,6 +475,7 @@ export class FilterEditorComponent
this.customFieldQueriesModel.clear(false) this.customFieldQueriesModel.clear(false)
this._textFilter = null this._textFilter = null
this._moreLikeId = null this._moreLikeId = null
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
this.dateAddedTo = null this.dateAddedTo = null
this.dateAddedFrom = null this.dateAddedFrom = null
this.dateCreatedTo = null this.dateCreatedTo = null
@@ -477,6 +509,13 @@ export class FilterEditorComponent
this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE
this._textFilter = rule.value this._textFilter = rule.value
break break
case FILTER_HAS_DUPLICATES:
this.textFilterTarget = TEXT_FILTER_TARGET_DUPLICATES
this.textFilterModifier =
rule.value == 'false' || rule.value == '0'
? TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES
: TEXT_FILTER_MODIFIER_HAS_DUPLICATES
break
case FILTER_FULLTEXT_QUERY: case FILTER_FULLTEXT_QUERY:
let allQueryArgs = rule.value.split(',') let allQueryArgs = rule.value.split(',')
let textQueryArgs = [] let textQueryArgs = []
@@ -800,6 +839,14 @@ export class FilterEditorComponent
value: this._textFilter.trim(), value: this._textFilter.trim(),
}) })
} }
if (this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES) {
filterRules.push({
rule_type: FILTER_HAS_DUPLICATES,
value: (
this.textFilterModifier == TEXT_FILTER_MODIFIER_HAS_DUPLICATES
).toString(),
})
}
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) { if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
filterRules.push({ filterRules.push({
rule_type: FILTER_SIMPLE_TITLE, rule_type: FILTER_SIMPLE_TITLE,
@@ -1163,7 +1210,7 @@ export class FilterEditorComponent
} }
get textFilter() { get textFilter() {
return this.textFilterModifierIsNull ? '' : this._textFilter return this.textFilterInputDisabled ? '' : this._textFilter
} }
set textFilter(value) { set textFilter(value) {
@@ -1363,12 +1410,24 @@ export class FilterEditorComponent
this._textFilter = '' this._textFilter = ''
} }
this.textFilterTarget = target this.textFilterTarget = target
if (target == TEXT_FILTER_TARGET_DUPLICATES) {
this._textFilter = ''
this.textFilterModifier = TEXT_FILTER_MODIFIER_HAS_DUPLICATES
} else if (
[
TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
].includes(this.textFilterModifier)
) {
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
}
this.textFilterInput.nativeElement.focus() this.textFilterInput.nativeElement.focus()
this.updateRules() this.updateRules()
} }
textFilterModifierChange() { textFilterModifierChange() {
if ( if (
this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES ||
this.textFilterModifierIsNull || this.textFilterModifierIsNull ||
([ ([
TEXT_FILTER_MODIFIER_EQUALS, TEXT_FILTER_MODIFIER_EQUALS,
@@ -1,110 +0,0 @@
<div class="border border-top-0 rounded-bottom p-3">
@if (!loading() && error()) {
<div class="alert alert-danger mb-0" role="alert">{{ error() }}</div>
}
@if (!loading() && !error() && links().length === 0) {
<p class="mb-0 text-muted fst-italic" i18n>
No document share links currently exist.
</p>
}
@if (!loading() && !error() && links().length > 0) {
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 bg-body">
<thead>
<tr>
<th scope="col" class="fw-normal" pngxSortable="document__title" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Document</th>
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
<th scope="col" i18n>File version</th>
<th scope="col" class="text-end" i18n>Actions</th>
</tr>
</thead>
<tbody>
@for (link of links(); track link.id) {
<tr>
<td>
<a routerLink="/documents/{{ link.document }}">{{ link.document_title | documentTitle }}</a>
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyDocumentID(link.document)">
@if (copiedDocumentID() === link.document) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{link.document}}
}
</span>
</td>
<td>{{ link.created | date: 'short' }}</td>
<td>
@if (link.expiration) {
{{ link.expiration | date: 'short' }}
@if (isExpired(link.expiration)) {
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
}
} @else {
<span i18n>Never</span>
}
</td>
<td>{{ fileVersionLabel(link.file_version) }}</td>
<td class="text-end">
<div class="d-inline-block position-relative">
<span
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
[class.show]="copiedID() === link.id"
i18n
>Copied!</span>
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
(click)="copy(link)"
title="Copy share link"
i18n-title
>
@if (copiedID() === link.id) {
<i-bs name="clipboard-check"></i-bs>
} @else {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
<pngx-confirm-button
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ShareLink }"
buttonClasses="btn btn-sm btn-outline-danger"
(confirm)="delete(link)"
iconName="trash"
>
<span class="visually-hidden" i18n>Delete share link</span>
</pngx-confirm-button>
</div>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
<div class="d-flex align-items-center">
<label class="small text-muted me-2" for="shareLinkPageSize" i18n>Show:</label>
<select id="shareLinkPageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
</div>
@if (total() > pageSize) {
<ngb-pagination
class="mb-0"
[pageSize]="pageSize"
[collectionSize]="total()"
[page]="page()"
[maxSize]="5"
(pageChange)="setPage($event)"
size="sm"
aria-label="Share links pagination"
i18n-aria-label
></ngb-pagination>
}
</div>
}
</div>
@@ -1,155 +0,0 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ShareLinkListComponent } from './share-link-list.component'
describe('ShareLinkListComponent', () => {
let component: ShareLinkListComponent
let fixture: ComponentFixture<ShareLinkListComponent>
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
let clipboard: Clipboard
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
const link = {
id: 1,
document: 42,
document_title: 'Test document',
slug: 'share-slug',
created: new Date().toISOString(),
expiration: null,
file_version: FileVersion.Archive,
} as ShareLink
beforeEach(() => {
service = {
list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
delete: jest.fn().mockReturnValue(of(true)),
}
toastService = {
showInfo: jest.fn(),
showError: jest.fn(),
}
TestBed.configureTestingModule({
imports: [
ShareLinkListComponent,
NgxBootstrapIconsModule.pick(allIcons),
RouterTestingModule,
],
providers: [
{ provide: ShareLinkService, useValue: service },
{ provide: ToastService, useValue: toastService },
],
})
fixture = TestBed.createComponent(ShareLinkListComponent)
component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard)
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
it('loads and renders document share links', () => {
fixture.detectChanges()
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
expect(component.links()).toEqual([link])
expect(fixture.nativeElement.textContent).toContain('Test document')
expect(fixture.nativeElement.textContent).toContain('ID: 42')
})
it('loads another page', () => {
fixture.detectChanges()
component.setPage(2)
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
})
it('sorts links and returns to the first page', () => {
fixture.detectChanges()
component.page.set(2)
component.onSort({ column: 'expiration', reverse: false })
expect(component.page()).toBe(1)
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'expiration', false)
})
it('marks expired share links', () => {
service.list.mockReturnValue(
of({
count: 1,
results: [
{
...link,
expiration: '2000-01-01T00:00:00.000Z',
},
],
})
)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Expired')
})
it('stores a changed page size and reloads from the first page', () => {
const settingsService = TestBed.inject(SettingsService)
jest.spyOn(settingsService, 'get').mockReturnValueOnce({ share_links: 25 })
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
const reloadSpy = jest.spyOn(component, 'reload')
component.page.set(2)
component.pageSize = 50
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
share_links: 50,
})
expect(component.page()).toBe(1)
expect(reloadSpy).toHaveBeenCalled()
})
it('shows local copy feedback without a toast', () => {
jest.useFakeTimers()
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
fixture.detectChanges()
component.copy(link)
fixture.detectChanges()
expect(component.copiedID()).toBe(link.id)
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
expect(toastService.showInfo).not.toHaveBeenCalled()
jest.advanceTimersByTime(3000)
expect(component.copiedID()).toBeNull()
})
it('deletes a link and reloads the list', () => {
fixture.detectChanges()
component.delete(link)
expect(service.delete).toHaveBeenCalledWith(link)
expect(service.list).toHaveBeenCalledTimes(2)
expect(toastService.showInfo).toHaveBeenCalled()
})
it('shows an error when loading fails', () => {
service.list.mockReturnValue(throwError(() => new Error('load failed')))
fixture.detectChanges()
expect(component.error()).toContain('Failed to load share links.')
expect(toastService.showError).toHaveBeenCalled()
})
})
@@ -1,172 +0,0 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
SortEvent,
SortableDirective,
} from 'src/app/directives/sortable.directive'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import {
PermissionAction,
PermissionType,
} from 'src/app/services/permissions.service'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
@Component({
selector: 'pngx-share-link-list',
templateUrl: './share-link-list.component.html',
imports: [
CommonModule,
ConfirmButtonComponent,
DocumentTitlePipe,
FormsModule,
IfPermissionsDirective,
NgbPaginationModule,
NgxBootstrapIconsModule,
RouterModule,
SortableDirective,
],
})
export class ShareLinkListComponent
extends LoadingComponentWithPermissions
implements OnInit
{
private readonly clipboard = inject(Clipboard)
private readonly shareLinkService = inject(ShareLinkService)
private readonly settingsService = inject(SettingsService)
private readonly toastService = inject(ToastService)
readonly links = signal<ShareLink[]>([])
readonly total = signal(0)
readonly page = signal(1)
readonly sortField = signal('created')
readonly sortReverse = signal(true)
readonly copiedID = signal<number | null>(null)
readonly copiedDocumentID = signal<number | null>(null)
readonly error = signal<string | null>(null)
readonly PermissionAction = PermissionAction
readonly PermissionType = PermissionType
get pageSize(): number {
return (
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)?.share_links ||
25
)
}
set pageSize(pageSize: number) {
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
share_links: pageSize,
})
this.settingsService.storeSettings().subscribe({
next: () => {
this.page.set(1)
this.reload()
},
error: (error) => {
this.toastService.showError($localize`Error saving settings`, error)
},
})
}
ngOnInit(): void {
this.reload()
}
reload(): void {
this.loading.set(true)
this.error.set(null)
this.shareLinkService
.list(this.page(), this.pageSize, this.sortField(), this.sortReverse())
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (results) => {
this.links.set(results.results)
this.total.set(results.count)
this.loading.set(false)
},
error: (error) => {
this.loading.set(false)
this.error.set($localize`Failed to load share links.`)
this.toastService.showError(
$localize`Error retrieving share links.`,
error
)
},
})
}
setPage(page: number): void {
this.page.set(page)
this.reload()
}
onSort(event: SortEvent): void {
this.sortField.set(event.column || 'created')
this.sortReverse.set(event.column ? event.reverse : true)
this.page.set(1)
this.reload()
}
getShareUrl(link: ShareLink): string {
const apiURL = new URL(environment.apiBaseUrl)
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
link.slug
}`
}
fileVersionLabel(version: FileVersion): string {
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
}
isExpired(expiration?: string): boolean {
return !!expiration && Date.parse(expiration) <= Date.now()
}
copy(link: ShareLink): void {
if (this.clipboard.copy(this.getShareUrl(link))) {
this.copiedID.set(link.id)
setTimeout(() => this.copiedID.set(null), 3000)
}
}
delete(link: ShareLink): void {
this.shareLinkService.delete(link).subscribe({
next: () => {
if (this.links().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1)
}
this.toastService.showInfo($localize`Share link deleted.`)
this.reload()
},
error: (error) => {
this.toastService.showError(
$localize`Error deleting share link.`,
error
)
},
})
}
copyDocumentID(documentID: number): void {
if (this.clipboard.copy(documentID.toString())) {
this.copiedDocumentID.set(documentID)
setTimeout(() => this.copiedDocumentID.set(null), 3000)
}
}
}
@@ -1,34 +0,0 @@
<pngx-page-header
title="Share links"
i18n-title
info="Manage public links to individual documents and document bundles."
i18n-info
[loading]="loading()"
></pngx-page-header>
<ul
ngbNav
#nav="ngbNav"
class="nav-tabs"
[activeId]="activeNavID()"
(activeIdChange)="selectTab($event)"
>
@if (canViewDocumentLinks) {
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
<button ngbNavLink i18n>Document links</button>
<ng-template ngbNavContent>
<pngx-share-link-list></pngx-share-link-list>
</ng-template>
</li>
}
@if (canViewBundles) {
<li [ngbNavItem]="ShareLinksNavIDs.Bundles">
<button ngbNavLink i18n>Bundles</button>
<ng-template ngbNavContent>
<pngx-share-link-bundle-list></pngx-share-link-bundle-list>
</ng-template>
</li>
}
</ul>
<div class="bg-body" [ngbNavOutlet]="nav"></div>
@@ -1,102 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
describe('ShareLinksComponent', () => {
let fixture: ComponentFixture<ShareLinksComponent>
let permissionsService: PermissionsService
let router: Router
const configure = async (type: string = null) => {
await TestBed.configureTestingModule({
imports: [
ShareLinksComponent,
NgbNavModule,
NgxBootstrapIconsModule.pick(allIcons),
PageHeaderComponent,
],
providers: [
PermissionsService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { queryParamMap: convertToParamMap({ type }) },
},
},
{
provide: Router,
useValue: { navigate: jest.fn().mockResolvedValue(true) },
},
{
provide: ShareLinkBundleService,
useValue: {
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
rebuildBundle: jest.fn(),
delete: jest.fn(),
},
},
{
provide: ShareLinkService,
useValue: {
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
delete: jest.fn(),
},
},
{
provide: ToastService,
useValue: { showInfo: jest.fn(), showError: jest.fn() },
},
],
}).compileComponents()
permissionsService = TestBed.inject(PermissionsService)
router = TestBed.inject(Router)
}
afterEach(() => TestBed.resetTestingModule())
it('uses the requested bundles tab when permitted', async () => {
await configure(ShareLinksNavIDs.Bundles)
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation(
(action, type) =>
action === PermissionAction.View &&
type === PermissionType.ShareLinkBundle
)
fixture = TestBed.createComponent(ShareLinksComponent)
fixture.detectChanges()
expect(fixture.componentInstance.activeNavID()).toBe(
ShareLinksNavIDs.Bundles
)
expect(fixture.nativeElement.textContent).not.toContain('Document links')
})
it('updates the URL when a tab is selected', async () => {
await configure()
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture = TestBed.createComponent(ShareLinksComponent)
fixture.componentInstance.selectTab(ShareLinksNavIDs.Bundles)
expect(router.navigate).toHaveBeenCalledWith([], {
relativeTo: TestBed.inject(ActivatedRoute),
queryParams: { type: ShareLinksNavIDs.Bundles },
queryParamsHandling: 'merge',
})
})
})
@@ -1,78 +0,0 @@
import { Component, computed, inject, signal, viewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
export enum ShareLinksNavIDs {
DocumentLinks = 'documents',
Bundles = 'bundles',
}
@Component({
selector: 'pngx-share-links',
templateUrl: './share-links.component.html',
imports: [
NgbNavModule,
PageHeaderComponent,
ShareLinkBundleListComponent,
ShareLinkListComponent,
],
})
export class ShareLinksComponent {
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly permissionsService = inject(PermissionsService)
readonly ShareLinksNavIDs = ShareLinksNavIDs
readonly activeNavID = signal(this.getInitialNavID())
private readonly documentLinks = viewChild(ShareLinkListComponent)
private readonly bundles = viewChild(ShareLinkBundleListComponent)
readonly loading = computed(() => {
const activeList =
this.activeNavID() === ShareLinksNavIDs.DocumentLinks
? this.documentLinks()
: this.bundles()
return activeList?.loading() ?? true
})
get canViewDocumentLinks(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLink
)
}
get canViewBundles(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLinkBundle
)
}
selectTab(tab: ShareLinksNavIDs): void {
this.activeNavID.set(tab)
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { type: tab },
queryParamsHandling: 'merge',
})
}
private getInitialNavID(): ShareLinksNavIDs {
const requestedTab = this.route.snapshot.queryParamMap.get('type')
if (requestedTab === ShareLinksNavIDs.Bundles && this.canViewBundles) {
return ShareLinksNavIDs.Bundles
}
if (this.canViewDocumentLinks) {
return ShareLinksNavIDs.DocumentLinks
}
return ShareLinksNavIDs.Bundles
}
}
+8
View File
@@ -49,6 +49,7 @@ export const FILTER_MODIFIED_AFTER = 16
export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views
export const FILTER_SIMPLE_TITLE = 48 export const FILTER_SIMPLE_TITLE = 48
export const FILTER_SIMPLE_TEXT = 49 export const FILTER_SIMPLE_TEXT = 49
export const FILTER_HAS_DUPLICATES = 50
export const FILTER_FULLTEXT_QUERY = 20 export const FILTER_FULLTEXT_QUERY = 20
export const FILTER_FULLTEXT_MORELIKE = 21 export const FILTER_FULLTEXT_MORELIKE = 21
@@ -382,6 +383,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'string', datatype: 'string',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_DUPLICATES,
filtervar: 'has_duplicates',
datatype: 'boolean',
multi: false,
default: true,
},
] ]
export interface FilterRuleType { export interface FilterRuleType {
+1
View File
@@ -422,6 +422,7 @@ export const PaperlessConfigOptions: ConfigOption[] = [
] ]
export interface PaperlessConfig extends ObjectWithId { export interface PaperlessConfig extends ObjectWithId {
externally_configured_variables: string[]
output_type: OutputTypeConfig output_type: OutputTypeConfig
pages: number pages: number
language: string language: string
-2
View File
@@ -26,7 +26,5 @@ export interface ShareLink extends ObjectWithPermissions {
document: number // Document document: number // Document
document_title?: string
file_version: string file_version: string
} }
+23 -2
View File
@@ -24,6 +24,16 @@ export enum CollapsibleSection {
ATTRIBUTES = 'attributes', ATTRIBUTES = 'attributes',
} }
export enum HideableSidebarItemID {
Dashboard = 'dashboard',
SavedViews = 'saved_views',
Workflows = 'workflows',
Mail = 'mail',
Documentation = 'documentation',
}
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
export const PAPERLESS_GREEN_HEX = '#17541f' export const PAPERLESS_GREEN_HEX = '#17541f'
export const SETTINGS_KEYS = { export const SETTINGS_KEYS = {
@@ -56,6 +66,7 @@ export const SETTINGS_KEYS = {
NOTES_ENABLED: 'general-settings:notes-enabled', NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled', AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar', SLIM_SIDEBAR: 'general-settings:slim-sidebar',
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
ATTRIBUTES_SECTIONS_COLLAPSED: ATTRIBUTES_SECTIONS_COLLAPSED:
'general-settings:attributes-sections-collapsed', 'general-settings:attributes-sections-collapsed',
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled', UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
@@ -84,6 +95,8 @@ export const SETTINGS_KEYS = {
'general-settings:document-editing:remove-inbox-tags', 'general-settings:document-editing:remove-inbox-tags',
DOCUMENT_EDITING_OVERLAY_THUMBNAIL: DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
'general-settings:document-editing:overlay-thumbnail', 'general-settings:document-editing:overlay-thumbnail',
DOCUMENT_EDITING_AUTO_SUGGEST:
'general-settings:document-editing:auto-suggest',
DOCUMENT_DETAILS_HIDDEN_FIELDS: DOCUMENT_DETAILS_HIDDEN_FIELDS:
'general-settings:document-details:hidden-fields', 'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only', SEARCH_DB_ONLY: 'general-settings:search:db-only',
@@ -125,6 +138,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: false, default: false,
}, },
{
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
type: 'array',
default: [],
},
{ {
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
type: 'array', type: 'array',
@@ -228,8 +246,6 @@ export const SETTINGS: UiSetting[] = [
document_types: 25, document_types: 25,
tags: 25, tags: 25,
storage_paths: 25, storage_paths: 25,
share_links: 25,
share_link_bundles: 25,
}, },
}, },
{ {
@@ -302,6 +318,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: true, default: true,
}, },
{
key: SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
type: 'boolean',
default: true,
},
{ {
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
type: 'array', type: 'array',
@@ -48,4 +48,13 @@ describe('ShareLinkBundleService', () => {
expect(req.request.body).toEqual({}) expect(req.request.body).toEqual({})
req.flush({}) req.flush({})
}) })
it('lists bundles with expected parameters', () => {
subscription = service.listAllBundles().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
)
expect(req.request.method).toBe('GET')
req.flush({ results: [] })
})
}) })
@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { import {
ShareLinkBundleCreatePayload, ShareLinkBundleCreatePayload,
ShareLinkBundleSummary, ShareLinkBundleSummary,
@@ -31,4 +32,10 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
{} {}
) )
} }
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
return this.list(1, 1000, 'created', true).pipe(
map((response) => response.results)
)
}
} }
@@ -14,7 +14,11 @@ import { CustomFieldDataType } from '../data/custom-field'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { RemoteOCRModeConfig } from '../data/paperless-config' import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings' import {
HideableSidebarItemID,
SETTINGS_KEYS,
UiSettings,
} from '../data/ui-settings'
import { PermissionsService } from './permissions.service' import { PermissionsService } from './permissions.service'
import { CustomFieldsService } from './rest/custom-fields.service' import { CustomFieldsService } from './rest/custom-fields.service'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
@@ -230,6 +234,35 @@ describe('SettingsService', () => {
expect(notesEnabled()).toBeFalsy() expect(notesEnabled()).toBeFalsy()
}) })
it('updates sidebar item visibility', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(false)
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
false
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(true)
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
true
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(false)
})
it('updates setting signals when settings are reinitialized', () => { it('updates setting signals when settings are reinitialized', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/` `${environment.apiBaseUrl}ui_settings/`
@@ -24,6 +24,7 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { RemoteOCRModeConfig } from '../data/paperless-config' import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { import {
HideableSidebarItemID,
PAPERLESS_GREEN_HEX, PAPERLESS_GREEN_HEX,
SETTINGS, SETTINGS,
SETTINGS_KEYS, SETTINGS_KEYS,
@@ -313,6 +314,18 @@ export class SettingsService {
readonly globalDropzoneEnabled = signal(true) readonly globalDropzoneEnabled = signal(true)
readonly globalDropzoneActive = signal(false) readonly globalDropzoneActive = signal(false)
readonly organizingSidebarSavedViews = signal(false) readonly organizingSidebarSavedViews = signal(false)
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
null
)
readonly organizingSidebarItems = computed(
() => this.sidebarHiddenItemsEditing() !== null
)
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
HideableSidebarItemID[]
>()
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
)
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>( readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
DEFAULT_DISPLAY_FIELDS DEFAULT_DISPLAY_FIELDS
@@ -749,6 +762,29 @@ export class SettingsService {
return this.storeSettings() return this.storeSettings()
} }
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
return (
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
).includes(item)
}
updateSidebarItemVisibility(
item: HideableSidebarItemID,
visible: boolean
): void {
const hiddenItems = new Set(
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
)
if (visible) {
hiddenItems.delete(item)
} else {
hiddenItems.add(item)
}
const updatedHiddenItems = [...hiddenItems]
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
}
updateSavedViewsVisibility( updateSavedViewsVisibility(
dashboardVisibleViewIds: number[], dashboardVisibleViewIds: number[],
sidebarVisibleViewIds: number[] sidebarVisibleViewIds: number[]
+23
View File
@@ -7,6 +7,7 @@ import {
FILTER_HAS_ANY_TAG, FILTER_HAS_ANY_TAG,
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_DUPLICATES,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_SIMPLE_TEXT, FILTER_SIMPLE_TEXT,
FILTER_SIMPLE_TITLE, FILTER_SIMPLE_TITLE,
@@ -132,6 +133,16 @@ describe('QueryParams Utils', () => {
is_tagged: 0, is_tagged: 0,
}) })
params = queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'false',
},
])
expect(params).toEqual({
has_duplicates: 0,
})
params = queryParamsFromFilterRules([ params = queryParamsFromFilterRules([
{ {
rule_type: FILTER_TITLE_CONTENT, rule_type: FILTER_TITLE_CONTENT,
@@ -247,6 +258,18 @@ describe('QueryParams Utils', () => {
}, },
]) ])
rules = filterRulesFromQueryParams(
convertToParamMap({
has_duplicates: 'true',
})
)
expect(rules).toEqual([
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'true',
},
])
rules = filterRulesFromQueryParams( rules = filterRulesFromQueryParams(
convertToParamMap({ convertToParamMap({
correspondent__isnull: '1', correspondent__isnull: '1',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.4 238.9" style="enable-background:new 0 0 198.4 238.9" xml:space="preserve">
<path d="M194.7 0C164.211 70.943 17.64 79.733 64.55 194.06c.59 1.468-10.848 17-18.47 29.897-1.758-6.453-3.816-13.486-3.516-14.075 38.109-45.141-27.26-70.643-30.776-107.583-16.423 29.318-22.286 80.623 27.25 110.23.29 0 2.637 11.138 3.816 16.712-1.169 2.348-2.348 4.695-2.927 6.454-1.168 2.926 7.622 2.637 7.622 3.226.879-.29 21.697-36.94 22.276-37.23C187.667 174.711 208.485 68.596 194.699 0zm-60.096 74.749c-55.11 49.246-64.49 85.897-62.732 103.777-18.47-43.682 35.772-91.76 62.732-103.777zM28.2 145.102c10.548 9.67 28.14 39.278 13.196 56.58 3.506-7.912 4.684-25.793-13.196-56.58z"/>
</svg>

Before

Width:  |  Height:  |  Size: 727 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
<path style="fill:#17541f;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#17541F"/>
</svg>

Before

Width:  |  Height:  |  Size: 855 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
<path style="fill:#fff;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 849 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

-1
View File
@@ -8,7 +8,6 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#17541f" /> <meta name="theme-color" content="#17541f" />
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="apple-touch-icon" href="apple-touch-icon.png"> <link rel="apple-touch-icon" href="apple-touch-icon.png">
</head> </head>
<body> <body>
+12 -4
View File
@@ -4,12 +4,20 @@
"display": "standalone", "display": "standalone",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "icon-192.png",
"sizes": "256x256" "sizes": "192x192",
"type": "image/png"
}, },
{ {
"src": "assets/logo-notext.svg", "src": "icon-512.png",
"sizes": "any" "sizes": "512x512",
"type": "image/png"
},
{
"src": "icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
], ],
"name": "Paperless-ngx", "name": "Paperless-ngx",
-4
View File
@@ -626,10 +626,6 @@ ul.pagination {
table.table { table.table {
--bs-table-color: var(--bs-body-color); --bs-table-color: var(--bs-body-color);
--bs-table-bg: var(--bs-light-rgb); --bs-table-bg: var(--bs-light-rgb);
&.bg-body {
--bs-table-bg: var(--bs-body-bg);
}
} }
.close { .close {
+42 -36
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
import tempfile import tempfile
import uuid
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Literal from typing import Literal
@@ -298,53 +299,55 @@ def modify_custom_fields(
) -> Literal["OK"]: ) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).only("pk") qs = Document.objects.filter(id__in=doc_ids).only("pk")
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
# Ensure add_custom_fields is a list of tuples, supports old API # Ensure add_custom_fields is a list of (int, value) tuples, supports old API
add_custom_fields = ( add_custom_fields = (
add_custom_fields.items() [(int(field), value) for field, value in add_custom_fields.items()]
if isinstance(add_custom_fields, dict) if isinstance(add_custom_fields, dict)
else [(field, None) for field in add_custom_fields] else [(int(field), None) for field in add_custom_fields]
) )
custom_fields = CustomField.objects.filter( # Resolved once, instead of re-querying the same field for every document
id__in=[int(field) for field, _ in add_custom_fields], custom_fields_by_id: dict[int, CustomField] = CustomField.objects.in_bulk(
).distinct() [field_id for field_id, _ in add_custom_fields],
)
# Passed to update_or_create() below rather than a bare id, so the FK is
# cached on the created instance and auditlog's post_save receiver does
# not reload it per row. Only needed for additions. content is deferred:
# the one field here that is both large and unused.
docs_by_id: dict[int, Document] = (
Document.objects.defer("content").in_bulk(affected_docs)
if add_custom_fields
else {}
)
for field_id, value in add_custom_fields: for field_id, value in add_custom_fields:
custom_field = custom_fields_by_id[field_id]
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
custom_field.data_type
]
is_doclink = custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
for doc_id in affected_docs: for doc_id in affected_docs:
defaults = {} if is_doclink and value and doc_id in value:
custom_field = custom_fields.get(id=field_id) # Prevent self-linking
if custom_field: continue
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
custom_field.data_type
]
defaults[value_field] = value
if (
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
and value
and doc_id in value
):
# Prevent self-linking
continue
CustomFieldInstance.objects.update_or_create( CustomFieldInstance.objects.update_or_create(
document_id=doc_id, document=docs_by_id[doc_id],
field_id=field_id, field=custom_field,
defaults=defaults, defaults={value_field: value},
) )
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: if is_doclink:
doc = Document.objects.get(id=doc_id) reflect_doclinks(docs_by_id[doc_id], custom_field, value)
reflect_doclinks(doc, custom_field, value)
# For doc link fields that are being removed, remove symmetrical links # For doc link fields that are being removed, remove symmetrical links.
# select_related avoids a per-instance reload of the document and field.
for doclink_being_removed_instance in CustomFieldInstance.objects.filter( for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
document_id__in=affected_docs, document_id__in=affected_docs,
field__id__in=remove_custom_fields, field__id__in=remove_custom_fields,
field__data_type=CustomField.FieldDataType.DOCUMENTLINK, field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
value_document_ids__isnull=False, value_document_ids__isnull=False,
): ).select_related("field", "document"):
for target_doc_id in doclink_being_removed_instance.value: for target_doc_id in doclink_being_removed_instance.value:
remove_doclink( remove_doclink(
document=Document.objects.get( document=doclink_being_removed_instance.document,
id=doclink_being_removed_instance.document.id,
),
field=doclink_being_removed_instance.field, field=doclink_being_removed_instance.field,
target_doc_id=target_doc_id, target_doc_id=target_doc_id,
) )
@@ -379,7 +382,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
) )
delete_ids = list({*doc_ids, *version_ids}) delete_ids = list({*doc_ids, *version_ids})
Document.objects.filter(id__in=delete_ids).delete() Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
from documents.search import get_backend from documents.search import get_backend
@@ -1177,10 +1180,13 @@ def remove_doclink(
""" """
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
""" """
target_doc_field_instance = CustomFieldInstance.objects.filter( # select_related: a signal receiver (auditlog) touches .document/.field on
document_id=target_doc_id, # the save() below, without this that is a per-call reload query
field=field, target_doc_field_instance = (
).first() CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field)
.select_related("document", "field")
.first()
)
if ( if (
target_doc_field_instance is not None target_doc_field_instance is not None
and document.id in target_doc_field_instance.value and document.id in target_doc_field_instance.value
+66 -28
View File
@@ -34,6 +34,27 @@ from paperless.signed_pickle import signed_pickle_loads
logger = logging.getLogger("paperless.classifier") logger = logging.getLogger("paperless.classifier")
def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
"""
Return the predicted class id, or None if:
- the prediction is -1 (no match), or
- the winning class probability is below the configured threshold.
Using predict_proba() instead of predict() lets us apply a minimum-confidence
cutoff so that uncertain predictions are discarded rather than assigned.
"""
probas = classifier.predict_proba(X)[0]
best_idx = int(probas.argmax())
best_class = int(classifier.classes_[best_idx])
if best_class == -1:
return None
if threshold > 0.0 and probas[best_idx] < threshold:
return None
return best_class
ADVANCED_TEXT_PROCESSING_ENABLED = ( ADVANCED_TEXT_PROCESSING_ENABLED = (
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
) )
@@ -102,7 +123,8 @@ class DocumentClassifier:
# v8 - Added storage path classifier # v8 - Added storage path classifier
# v9 - Changed from hashing to time/ids for re-train check # v9 - Changed from hashing to time/ids for re-train check
# v10 - HMAC-signed model file # v10 - HMAC-signed model file
FORMAT_VERSION = 10 # v11 - Use sample_weight for balanced training; predict_proba with threshold
FORMAT_VERSION = 11
HMAC_SIZE = 32 # SHA-256 digest length HMAC_SIZE = 32 # SHA-256 digest length
@@ -324,6 +346,13 @@ class DocumentClassifier:
from sklearn.preprocessing import LabelBinarizer from sklearn.preprocessing import LabelBinarizer
from sklearn.preprocessing import MultiLabelBinarizer from sklearn.preprocessing import MultiLabelBinarizer
# MLPClassifier does not support class_weight directly
# (https://github.com/scikit-learn/scikit-learn/issues/9113), so we use
# compute_sample_weight to balance classes during training and prevent
# over-represented correspondents from dominating predictions.
# https://scikit-learn.org/stable/modules/generated/sklearn.utils.class_weight.compute_sample_weight.html
from sklearn.utils.class_weight import compute_sample_weight
# Step 2: vectorize data # Step 2: vectorize data
logger.debug("Vectorizing data...") logger.debug("Vectorizing data...")
notify("Vectorizing document content...") notify("Vectorizing document content...")
@@ -369,7 +398,7 @@ class DocumentClassifier:
self.tags_binarizer = MultiLabelBinarizer() self.tags_binarizer = MultiLabelBinarizer()
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags) labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
self.tags_classifier = MLPClassifier(tol=0.01) self.tags_classifier = MLPClassifier(tol=0.01, random_state=0)
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized) self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
else: else:
self.tags_classifier = None self.tags_classifier = None
@@ -380,8 +409,12 @@ class DocumentClassifier:
notify( notify(
f"Training correspondent classifier ({num_correspondents} correspondent(s))...", f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
) )
self.correspondent_classifier = MLPClassifier(tol=0.01) self.correspondent_classifier = MLPClassifier(tol=0.01, random_state=0)
self.correspondent_classifier.fit(data_vectorized, labels_correspondent) self.correspondent_classifier.fit(
data_vectorized,
labels_correspondent,
sample_weight=compute_sample_weight("balanced", labels_correspondent),
)
else: else:
self.correspondent_classifier = None self.correspondent_classifier = None
logger.debug( logger.debug(
@@ -393,8 +426,12 @@ class DocumentClassifier:
notify( notify(
f"Training document type classifier ({num_document_types} type(s))...", f"Training document type classifier ({num_document_types} type(s))...",
) )
self.document_type_classifier = MLPClassifier(tol=0.01) self.document_type_classifier = MLPClassifier(tol=0.01, random_state=0)
self.document_type_classifier.fit(data_vectorized, labels_document_type) self.document_type_classifier.fit(
data_vectorized,
labels_document_type,
sample_weight=compute_sample_weight("balanced", labels_document_type),
)
else: else:
self.document_type_classifier = None self.document_type_classifier = None
logger.debug( logger.debug(
@@ -406,10 +443,11 @@ class DocumentClassifier:
"Training storage paths classifier...", "Training storage paths classifier...",
) )
notify(f"Training storage path classifier ({num_storage_paths} path(s))...") notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
self.storage_path_classifier = MLPClassifier(tol=0.01) self.storage_path_classifier = MLPClassifier(tol=0.01, random_state=0)
self.storage_path_classifier.fit( self.storage_path_classifier.fit(
data_vectorized, data_vectorized,
labels_storage_path, labels_storage_path,
sample_weight=compute_sample_weight("balanced", labels_storage_path),
) )
else: else:
self.storage_path_classifier = None self.storage_path_classifier = None
@@ -546,24 +584,24 @@ class DocumentClassifier:
def predict_correspondent(self, content: str) -> int | None: def predict_correspondent(self, content: str) -> int | None:
if self.correspondent_classifier: if self.correspondent_classifier:
X = self._vectorize(content) X = self._vectorize(content)
correspondent_id = self.correspondent_classifier.predict(X) predicted_id = _predict_with_threshold(
if correspondent_id != -1: self.correspondent_classifier,
return correspondent_id X,
else: settings.CLASSIFIER_MATCH_THRESHOLD,
return None )
else: return predicted_id
return None return None
def predict_document_type(self, content: str) -> int | None: def predict_document_type(self, content: str) -> int | None:
if self.document_type_classifier: if self.document_type_classifier:
X = self._vectorize(content) X = self._vectorize(content)
document_type_id = self.document_type_classifier.predict(X) predicted_id = _predict_with_threshold(
if document_type_id != -1: self.document_type_classifier,
return document_type_id X,
else: settings.CLASSIFIER_MATCH_THRESHOLD,
return None )
else: return predicted_id
return None return None
def predict_tags(self, content: str) -> list[int]: def predict_tags(self, content: str) -> list[int]:
from sklearn.utils.multiclass import type_of_target from sklearn.utils.multiclass import type_of_target
@@ -589,10 +627,10 @@ class DocumentClassifier:
def predict_storage_path(self, content: str) -> int | None: def predict_storage_path(self, content: str) -> int | None:
if self.storage_path_classifier: if self.storage_path_classifier:
X = self._vectorize(content) X = self._vectorize(content)
storage_path_id = self.storage_path_classifier.predict(X) predicted_id = _predict_with_threshold(
if storage_path_id != -1: self.storage_path_classifier,
return storage_path_id X,
else: settings.CLASSIFIER_MATCH_THRESHOLD,
return None )
else: return predicted_id
return None return None
+47 -17
View File
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING
from typing import Any from typing import Any
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db.models import Case from django.db.models import Case
from django.db.models import CharField from django.db.models import CharField
from django.db.models import Count from django.db.models import Count
@@ -25,6 +24,7 @@ from django.db.models import Sum
from django.db.models import Value from django.db.models import Value
from django.db.models import When from django.db.models import When
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.db.models.functions import NullIf
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_filters import DateFilter from django_filters import DateFilter
from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import BooleanFilter
@@ -50,7 +50,9 @@ from documents.models import ShareLink
from documents.models import ShareLinkBundle from documents.models import ShareLinkBundle
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.versioning import annotate_effective_content
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -180,14 +182,9 @@ class TitleContentFilter(Filter):
logger.warning( logger.warning(
"Deprecated document filter parameter 'title_content' used; use `text` instead.", "Deprecated document filter parameter 'title_content' used; use `text` instead.",
) )
try: return annotate_effective_content(qs).filter(
return qs.filter( Q(title__icontains=value) | Q(effective_content__icontains=value),
Q(title__icontains=value) | Q(effective_content__icontains=value), )
)
except FieldError:
return qs.filter(
Q(title__icontains=value) | Q(content__icontains=value),
)
else: else:
return qs return qs
@@ -198,14 +195,9 @@ class EffectiveContentFilter(Filter):
value = value.strip() if isinstance(value, str) else value value = value.strip() if isinstance(value, str) else value
if not value: if not value:
return qs return qs
try: return annotate_effective_content(qs).filter(
return qs.filter( **{f"effective_content__{self.lookup_expr}": value},
**{f"effective_content__{self.lookup_expr}": value}, )
)
except FieldError:
return qs.filter(
**{f"content__{self.lookup_expr}": value},
)
@extend_schema_field(serializers.BooleanField) @extend_schema_field(serializers.BooleanField)
@@ -793,6 +785,12 @@ class CustomFieldQueryFilter(Filter):
class DocumentFilterSet(FilterSet): class DocumentFilterSet(FilterSet):
has_duplicates = BooleanFilter(method="filter_has_duplicates")
def __init__(self, *args: Any, user: Any = None, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._user = user
is_tagged = BooleanFilter( is_tagged = BooleanFilter(
label="Is tagged", label="Is tagged",
field_name="tags", field_name="tags",
@@ -852,6 +850,38 @@ class DocumentFilterSet(FilterSet):
mime_type = MimeTypeFilter() mime_type = MimeTypeFilter()
def filter_has_duplicates(self, queryset, name, value):
if value is None:
return queryset
user = (
self._user
if self._user is not None
else getattr(self.request, "user", None)
)
queryset = queryset.alias(
nonempty_archive_checksum=NullIf("archive_checksum", Value("")),
)
visible_root_documents = Document.global_objects.filter(
root_document__isnull=True,
pk__in=permitted_document_ids(
user,
include_deleted=True,
),
).exclude(pk=OuterRef("pk"))
# see serialisers._get_viewable_duplicates().
matching_duplicates = visible_root_documents.filter(
Q(checksum=OuterRef("checksum"))
| Q(checksum=OuterRef("nonempty_archive_checksum"))
| Q(archive_checksum=OuterRef("checksum"))
| Q(archive_checksum=OuterRef("nonempty_archive_checksum")),
)
return queryset.alias(
has_visible_duplicates=Exists(matching_duplicates),
).filter(has_visible_duplicates=value)
# Backwards compatibility # Backwards compatibility
created__date__gt = DateFilter(field_name="created", lookup_expr="gt") created__date__gt = DateFilter(field_name="created", lookup_expr="gt")
created__date__gte = DateFilter(field_name="created", lookup_expr="gte") created__date__gte = DateFilter(field_name="created", lookup_expr="gte")
@@ -156,6 +156,15 @@ class FileStabilityTracker:
logger.debug(f"File disappeared during stability check: {path}") logger.debug(f"File disappeared during stability check: {path}")
continue continue
# Stable, but empty: some scanners create a zero byte placeholder
# and only write the page some time later. Consuming it now can
# only fail so drop it and let the writer's next event
# (or the periodic rescan) bring it back once it has content
if not tracked.last_size:
to_remove.append(path)
logger.debug("Ignoring stable but empty file: %s", path)
continue
# File is stable, we can return it # File is stable, we can return it
to_yield.append(path) to_yield.append(path)
logger.info(f"File is stable: {path}") logger.info(f"File is stable: {path}")
@@ -0,0 +1,86 @@
# Generated by Django 5.2.16 on 2026-09-05 16:29
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0025_workflowaction_apply_ai_suggestions"),
]
operations = [
migrations.AlterField(
model_name="document",
name="archive_checksum",
field=models.CharField(
blank=True,
db_index=True,
editable=False,
help_text="The checksum of the archived document.",
max_length=64,
null=True,
verbose_name="archive checksum",
),
),
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveSmallIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
(32, "owner is"),
(33, "has owner in"),
(34, "does not have owner"),
(35, "does not have owner in"),
(36, "has custom field value"),
(37, "is shared by me"),
(38, "has custom fields"),
(39, "has custom field in"),
(40, "does not have custom field in"),
(41, "does not have custom field"),
(42, "custom fields query"),
(43, "created to"),
(44, "created from"),
(45, "added to"),
(46, "added from"),
(47, "mime type is"),
(48, "simple title search"),
(49, "simple text search"),
(50, "has duplicates"),
],
verbose_name="rule type",
),
),
]
+12 -2
View File
@@ -1,4 +1,5 @@
import datetime import datetime
import uuid
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
@@ -227,6 +228,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
editable=False, editable=False,
blank=True, blank=True,
null=True, null=True,
db_index=True,
help_text=_("The checksum of the archived document."), help_text=_("The checksum of the archived document."),
) )
@@ -513,13 +515,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def delete( def delete(
self, self,
*args, *args,
transaction_id=None,
**kwargs, **kwargs,
): ):
# If deleting a root document, move all its versions to trash as well. # Versions must share the root's transaction ID so they are restored
# together by django-softdelete.
if transaction_id is None:
transaction_id = uuid.uuid4()
if self.root_document_id is None: if self.root_document_id is None:
Document.objects.filter(root_document=self).delete() Document.objects.filter(root_document=self).delete(
transaction_id=transaction_id,
)
return super().delete( return super().delete(
*args, *args,
transaction_id=transaction_id,
**kwargs, **kwargs,
) )
@@ -706,6 +715,7 @@ class SavedViewFilterRule(models.Model):
(47, _("mime type is")), (47, _("mime type is")),
(48, _("simple title search")), (48, _("simple title search")),
(49, _("simple text search")), (49, _("simple text search")),
(50, _("has duplicates")),
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(
+40
View File
@@ -284,6 +284,46 @@ class WriteBatch:
tantivy.Query.term_query(self._backend._schema, "id", doc_id), tantivy.Query.term_query(self._backend._schema, "id", doc_id),
) )
def add_or_update_ids(self, ids: Sequence[int]) -> None:
"""
Add or update multiple documents in the batch by primary key.
Unlike calling ``add_or_update()`` once per document, this resolves
viewer permissions and effective (versioned) content in bulk against
the ids as a whole, instead of once per document -- see
``_DocumentViewerStream`` and ``annotate_effective_content``. Use
this whenever more than one document is being written in the same
batch.
An id with no matching document (e.g. deleted between the caller
collecting ids and the batch running) is silently skipped, matching
``add_or_update()``'s existing single-document deferred-task behavior
rather than erroring or leaving a stale index entry.
Args:
ids: Primary keys of Document instances to index
"""
from documents.models import Document
from documents.versioning import annotate_effective_content
ids = list(ids)
if not ids:
return
queryset = annotate_effective_content(
Document.objects.filter(pk__in=ids)
.select_related("correspondent", "document_type", "storage_path", "owner")
.prefetch_related("tags", "notes__user", "custom_fields__field"),
)
for document, grant in _DocumentViewerStream(queryset, chunk_size=1000):
self.remove(document.pk)
doc = self._backend._build_tantivy_doc(
document,
viewer_ids=grant.viewer_ids,
viewer_group_ids=grant.viewer_group_ids,
)
self._writer.add_document(doc)
class TantivyBackend: class TantivyBackend:
""" """
+3 -6
View File
@@ -674,6 +674,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
ordering = ordering or (Lower("name"),) ordering = ordering or (Lower("name"),)
children = children.order_by(*ordering) children = children.order_by(*ordering)
if not children:
return []
serializer = TagSerializer( serializer = TagSerializer(
children, children,
many=True, many=True,
@@ -2812,11 +2815,6 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
class ShareLinkSerializer(OwnedObjectSerializer): class ShareLinkSerializer(OwnedObjectSerializer):
document_title = serializers.CharField(
source="document.title",
read_only=True,
)
class Meta: class Meta:
model = ShareLink model = ShareLink
fields = ( fields = (
@@ -2825,7 +2823,6 @@ class ShareLinkSerializer(OwnedObjectSerializer):
"expiration", "expiration",
"slug", "slug",
"document", "document",
"document_title",
"file_version", "file_version",
) )
+5 -3
View File
@@ -312,7 +312,10 @@ def bulk_update_documents(document_ids) -> None:
from documents.search import get_backend from documents.search import get_backend
document_ids = list(document_ids) document_ids = list(document_ids)
# Annotated so indexing below doesn't query the versions of each document # Annotated so the signal handlers below (e.g. matching) don't query the
# versions of each document. Indexing re-queries and re-annotates its own
# copy via add_or_update_ids() below, after these signals (and any
# workflow they trigger) have had a chance to mutate the documents.
documents = annotate_effective_content( documents = annotate_effective_content(
Document.objects.filter(id__in=document_ids), Document.objects.filter(id__in=document_ids),
) )
@@ -328,8 +331,7 @@ def bulk_update_documents(document_ids) -> None:
post_save.send(Document, instance=doc, created=False) post_save.send(Document, instance=doc, created=False)
with get_backend().batch_update() as batch: with get_backend().batch_update() as batch:
for doc in documents: batch.add_or_update_ids(document_ids)
batch.add_or_update(doc)
ai_config = AIConfig() ai_config = AIConfig()
if ai_config.llm_index_enabled: if ai_config.llm_index_enabled:
+3 -3
View File
@@ -10,7 +10,7 @@ import pytest
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from guardian.shortcuts import clear_ct_cache from guardian.shortcuts import clear_ct_cache
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from rest_framework.test import APIClient from rest_framework.test import APIClient
from documents.tests.factories import DocumentFactory from documents.tests.factories import DocumentFactory
@@ -100,7 +100,7 @@ def sample_doc(
@pytest.fixture() @pytest.fixture()
def _search_index( def _search_index(
tmp_path: Path, tmp_path: Path,
settings: SettingsWrapper, settings: Settings,
) -> Generator[None, None, None]: ) -> Generator[None, None, None]:
"""Create a temp index directory and point INDEX_DIR at it. """Create a temp index directory and point INDEX_DIR at it.
@@ -118,7 +118,7 @@ def _search_index(
@pytest.fixture() @pytest.fixture()
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo: def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
return zoneinfo.ZoneInfo(settings.TIME_ZONE) return zoneinfo.ZoneInfo(settings.TIME_ZONE)
+1 -1
View File
@@ -70,7 +70,7 @@ def clear_lru_cache() -> Generator[None, None, None]:
@pytest.fixture @pytest.fixture
def mock_date_parser_settings(settings: pytest_django.fixtures.SettingsWrapper) -> Any: def mock_date_parser_settings(settings: pytest_django.fixtures.Settings) -> Any:
""" """
Override Django settings for the duration of date parser tests. Override Django settings for the duration of date parser tests.
""" """
+3 -3
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import pytest import pytest
import pytest_mock import pytest_mock
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from documents.export.sinks import DirectoryExportSink from documents.export.sinks import DirectoryExportSink
from documents.export.sinks import ExportSink from documents.export.sinks import ExportSink
@@ -242,7 +242,7 @@ class TestZipExportSink:
self, self,
tmp_path: Path, tmp_path: Path,
source_file: Path, source_file: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
scratch_dir = tmp_path / "scratch" scratch_dir = tmp_path / "scratch"
settings.SCRATCH_DIR = scratch_dir settings.SCRATCH_DIR = scratch_dir
@@ -261,7 +261,7 @@ class TestZipExportSink:
def test_abort_after_manifest_written_cleans_up_pending_tmp( def test_abort_after_manifest_written_cleans_up_pending_tmp(
self, self,
tmp_path: Path, tmp_path: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
scratch_dir = tmp_path / "scratch" scratch_dir = tmp_path / "scratch"
settings.SCRATCH_DIR = scratch_dir settings.SCRATCH_DIR = scratch_dir
+2 -2
View File
@@ -15,11 +15,11 @@ if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
@pytest.fixture @pytest.fixture
def index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path: def index_dir(tmp_path: Path, settings: Settings) -> Path:
path = tmp_path / "index" path = tmp_path / "index"
path.mkdir() path.mkdir()
settings.INDEX_DIR = path settings.INDEX_DIR = path
+187
View File
@@ -4,6 +4,8 @@ from pathlib import Path
import pytest import pytest
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import connection
from django.test.utils import CaptureQueriesContext
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@@ -102,6 +104,191 @@ class TestWriteBatch:
assert len(backend.search_ids("indexable", user=None)) == 1 assert len(backend.search_ids("indexable", user=None)) == 1
class TestAddOrUpdateIds:
"""Test WriteBatch.add_or_update_ids(), the bulk id-based upsert path.
Unlike add_or_update() called once per document, this resolves viewer
permissions and effective (versioned) content in bulk against the ids as
a whole, so it must produce identical indexed output to the per-document
path while issuing a constant number of queries regardless of batch size.
"""
def test_missing_id_is_skipped_not_errored(
self,
backend: TantivyBackend,
) -> None:
doc = Document.objects.create(
title="doc",
content="present",
checksum="EXIST1",
pk=1,
)
missing_pk = 999
with backend.batch_update() as batch:
batch.add_or_update_ids([doc.pk, missing_pk])
assert backend.search_ids("present", user=None) == [doc.pk]
def test_query_count_does_not_scale_with_batch_size(
self,
backend: TantivyBackend,
) -> None:
"""Each query count must stay far below N, not merely match between
two runs -- an exact-equality assertion between two measurements is
at the mercy of incidental process-level caches (e.g. Django's
ContentType.objects.get_for_model) warming on whichever run happens
first, which makes counts differ by a query for reasons unrelated to
batch size. A generous fixed bound sidesteps that: the old
per-document path issued roughly 8 queries per document, so 50
documents under a bound this low proves the fix regardless of cache
state.
"""
max_queries_for_any_batch_size = 15
small_docs = [
Document.objects.create(
title="doc",
content=f"unique{i}",
checksum=f"SMALL{i}",
pk=i,
)
for i in range(1, 3)
]
with CaptureQueriesContext(connection) as ctx_small:
with backend.batch_update() as batch:
batch.add_or_update_ids([d.pk for d in small_docs])
assert len(ctx_small.captured_queries) <= max_queries_for_any_batch_size
large_docs = [
Document.objects.create(
title="doc",
content=f"unique{i}",
checksum=f"LARGE{i}",
pk=i,
)
for i in range(100, 150)
]
with CaptureQueriesContext(connection) as ctx_large:
with backend.batch_update() as batch:
batch.add_or_update_ids([d.pk for d in large_docs])
assert len(ctx_large.captured_queries) <= max_queries_for_any_batch_size
for doc in large_docs:
assert backend.search_ids(f"unique{doc.pk}", user=None) == [doc.pk]
def test_resolves_direct_user_grant_in_bulk(
self,
backend: TantivyBackend,
) -> None:
owner = UserFactory()
user = UserFactory()
doc = Document.objects.create(
title="doc",
checksum="PERM1",
pk=1,
owner=owner,
)
assign_perm("view_document", user, doc)
with backend.batch_update() as batch:
batch.add_or_update_ids([doc.pk])
assert backend.search_ids("doc", user=user) == [doc.pk]
other = UserFactory()
assert backend.search_ids("doc", user=other) == []
def test_resolves_group_grant_in_bulk(self, backend: TantivyBackend) -> None:
owner = UserFactory()
group = Group.objects.create(name="reviewers")
user = UserFactory()
user.groups.add(group)
doc = Document.objects.create(
title="doc",
checksum="GPERM1",
pk=1,
owner=owner,
)
assign_perm("view_document", group, doc)
with backend.batch_update() as batch:
batch.add_or_update_ids([doc.pk])
assert backend.search_ids("doc", user=user) == [doc.pk]
other = UserFactory()
assert backend.search_ids("doc", user=other) == []
def test_indexes_notes_and_custom_fields(self, backend: TantivyBackend) -> None:
note_author = UserFactory(username="noter")
field = CustomField.objects.create(
name="Invoice Number",
data_type=CustomField.FieldDataType.STRING,
)
doc = Document.objects.create(title="doc", checksum="RICH1", pk=1)
Note.objects.create(document=doc, note="Reviewed", user=note_author)
CustomFieldInstance.objects.create(
document=doc,
field=field,
value_text="INV-42",
)
with backend.batch_update() as batch:
batch.add_or_update_ids([doc.pk])
assert backend.search_ids("notes.user:noter", user=None) == [doc.pk]
assert backend.search_ids("custom_fields.value:INV-42", user=None) == [
doc.pk,
]
def test_uses_effective_content_for_versioned_documents(
self,
backend: TantivyBackend,
) -> None:
root = Document.objects.create(
title="Statement",
content="stale text",
checksum="ROOT1",
pk=1,
)
Document.objects.create(
title="Statement",
content="latest version text",
checksum="VER1",
pk=2,
root_document=root,
version_index=1,
)
with backend.batch_update() as batch:
batch.add_or_update_ids([root.pk])
assert backend.search_ids("latest", user=None) == [root.pk]
assert backend.search_ids("stale", user=None) == []
def test_reindexes_documents_already_in_the_index(
self,
backend: TantivyBackend,
) -> None:
"""add_or_update_ids must upsert, matching add_or_update's behaviour."""
doc = Document.objects.create(
title="doc",
content="original",
checksum="UP1",
pk=1,
)
backend.add_or_update(doc)
assert backend.search_ids("original", user=None) == [doc.pk]
doc.content = "updated"
doc.save()
with backend.batch_update() as batch:
batch.add_or_update_ids([doc.pk])
assert backend.search_ids("original", user=None) == []
assert backend.search_ids("updated", user=None) == [doc.pk]
class TestSearch: class TestSearch:
"""Test search query parsing and matching via search_ids.""" """Test search query parsing and matching via search_ids."""
+7 -6
View File
@@ -11,7 +11,8 @@ from documents.search._schema import needs_rebuild
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
pytestmark = pytest.mark.search pytestmark = pytest.mark.search
@@ -25,7 +26,7 @@ class TestNeedsRebuild:
def test_returns_false_when_version_and_language_match( def test_returns_false_when_version_and_language_match(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "en" settings.SEARCH_LANGUAGE = "en"
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -36,7 +37,7 @@ class TestNeedsRebuild:
def test_returns_true_on_schema_version_mismatch( def test_returns_true_on_schema_version_mismatch(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -47,7 +48,7 @@ class TestNeedsRebuild:
def test_returns_true_when_version_is_not_an_integer( def test_returns_true_when_version_is_not_an_integer(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -58,7 +59,7 @@ class TestNeedsRebuild:
def test_returns_true_when_language_key_missing( def test_returns_true_when_language_key_missing(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "en" settings.SEARCH_LANGUAGE = "en"
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -69,7 +70,7 @@ class TestNeedsRebuild:
def test_returns_true_when_language_differs( def test_returns_true_when_language_differs(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "de" settings.SEARCH_LANGUAGE = "de"
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
+49 -2
View File
@@ -35,7 +35,8 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
THEN: THEN:
- Existing config - Existing config
""" """
response = self.client.get(self.ENDPOINT, format="json") with patch.dict("os.environ", {}, clear=True):
response = self.client.get(self.ENDPOINT, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -45,6 +46,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
response.data[0], response.data[0],
{ {
"id": 1, "id": 1,
"externally_configured_variables": [],
"output_type": None, "output_type": None,
"pages": None, "pages": None,
"language": None, "language": None,
@@ -76,7 +78,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"remote_ocr_api_key": None, "remote_ocr_api_key": None,
"remote_ocr_endpoint": None, "remote_ocr_endpoint": None,
"remote_ocr_mode": None, "remote_ocr_mode": None,
"ai_enabled": False, "ai_enabled": None,
"llm_embedding_backend": None, "llm_embedding_backend": None,
"llm_embedding_model": None, "llm_embedding_model": None,
"llm_embedding_endpoint": None, "llm_embedding_endpoint": None,
@@ -91,6 +93,31 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
}, },
) )
def test_api_get_config_reports_external_configuration_without_values(self) -> None:
with patch.dict(
"os.environ",
{
"PAPERLESS_OCR_LANGUAGE": "eng",
"PAPERLESS_REMOTE_OCR_API_KEY": "secret-value",
"PAPERLESS_FUTURE_SETTING": "future-value",
"UNRELATED_SETTING": "unrelated-value",
},
clear=True,
):
response = self.client.get(self.ENDPOINT, format="json")
self.assertCountEqual(
response.data[0]["externally_configured_variables"],
[
"PAPERLESS_FUTURE_SETTING",
"PAPERLESS_OCR_LANGUAGE",
"PAPERLESS_REMOTE_OCR_API_KEY",
],
)
self.assertNotContains(response, "secret-value")
self.assertNotContains(response, "future-value")
self.assertNotContains(response, "UNRELATED_SETTING")
def test_api_get_ui_settings_with_config(self) -> None: def test_api_get_ui_settings_with_config(self) -> None:
""" """
GIVEN: GIVEN:
@@ -949,6 +976,26 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
) )
mock_update.assert_called_once() mock_update.assert_called_once()
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND=None)
def test_external_ai_setting_triggers_index_update(self) -> None:
config = ApplicationConfiguration.objects.first()
assert config is not None
config.ai_enabled = None
config.llm_embedding_backend = None
config.save()
with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless.views.llm_index_exists", return_value=False),
):
self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps({"llm_embedding_backend": "openai-like"}),
content_type="application/json",
)
mock_update.assert_called_once()
def test_update_llm_embedding_chunk_size_triggers_rebuild(self) -> None: def test_update_llm_embedding_chunk_size_triggers_rebuild(self) -> None:
config = ApplicationConfiguration.objects.first() config = ApplicationConfiguration.objects.first()
assert config is not None assert config is not None
+38
View File
@@ -717,6 +717,44 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc2.id]) self.assertEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id) self.assertEqual(kwargs["storage_path"], self.sp1.id)
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
self.setup_mock(m, "set_storage_path")
user = User.objects.create_user(username="duplicate-owner")
user.user_permissions.add(
Permission.objects.get(codename="change_document"),
)
first_duplicate = Document.objects.create(
checksum="owned-duplicate",
title="First duplicate",
owner=user,
)
second_duplicate = Document.objects.create(
checksum="owned-duplicate",
title="Second duplicate",
owner=user,
)
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"all": True,
"filters": {"has_duplicates": True},
"method": "set_storage_path",
"parameters": {"storage_path": self.sp1.id},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
self.assertCountEqual(args[0], [first_duplicate.id, second_duplicate.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@mock.patch("documents.search.get_backend") @mock.patch("documents.search.get_backend")
@mock.patch("documents.serialisers.bulk_edit.set_storage_path") @mock.patch("documents.serialisers.bulk_edit.set_storage_path")
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters( def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
@@ -2,14 +2,12 @@ from __future__ import annotations
import datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest import TestCase
from unittest import mock from unittest import mock
from auditlog.models import LogEntry # type: ignore[import-untyped] from auditlog.models import LogEntry # type: ignore[import-untyped]
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase as DjangoTestCase from django.test import TestCase as DjangoTestCase
from django.utils import timezone from django.utils import timezone
@@ -22,6 +20,7 @@ from documents.filters import TitleContentFilter
from documents.models import Document from documents.models import Document
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response from documents.tests.utils import read_streaming_response
from documents.versioning import annotate_effective_content
from documents.views import DocumentSelectionMixin from documents.views import DocumentSelectionMixin
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -598,6 +597,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(input_doc.root_document_id, root.id) self.assertEqual(input_doc.root_document_id, root.id)
self.assertEqual(input_doc.source, DocumentSource.ApiUpload) self.assertEqual(input_doc.source, DocumentSource.ApiUpload)
self.assertEqual(overrides.version_label, "New Version") self.assertEqual(overrides.version_label, "New Version")
self.assertEqual(overrides.owner_id, self.user.id)
self.assertEqual(overrides.actor_id, self.user.id) self.assertEqual(overrides.actor_id, self.user.id)
def test_update_version_with_version_pk_normalizes_to_root(self) -> None: def test_update_version_with_version_pk_normalizes_to_root(self) -> None:
@@ -891,32 +891,104 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
) )
class TestVersionAwareFilters(TestCase): class TestVersionAwareFilters(DjangoTestCase):
def test_title_content_filter_falls_back_to_content(self) -> None: """
queryset = mock.Mock() The filters annotate effective_content themselves rather than relying on
fallback_queryset = mock.Mock() the caller's queryset carrying it, so they stay version-aware on a plain
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset] Document queryset (e.g. the bulk-edit "select all matching" path).
"""
result = TitleContentFilter().filter(queryset, " latest ") def setUp(self) -> None:
super().setUp()
self.root = Document.objects.create(
title="root",
checksum="root",
mime_type="application/pdf",
content="superseded-content",
)
Document.objects.create(
title="version",
checksum="version",
mime_type="application/pdf",
root_document=self.root,
version_index=1,
content="latest-content",
)
self.unversioned = Document.objects.create(
title="unversioned",
checksum="unversioned",
mime_type="application/pdf",
content="latest-content",
)
self.assertIs(result, fallback_queryset) def test_title_content_filter_matches_latest_version_content(self) -> None:
self.assertEqual(queryset.filter.call_count, 2) result = TitleContentFilter().filter(
Document.objects.filter(root_document__isnull=True),
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
queryset = mock.Mock()
fallback_queryset = mock.Mock()
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
result = EffectiveContentFilter(lookup_expr="icontains").filter(
queryset,
" latest ", " latest ",
) )
self.assertIs(result, fallback_queryset) self.assertCountEqual(
first_kwargs = queryset.filter.call_args_list[0].kwargs [doc.id for doc in result],
second_kwargs = queryset.filter.call_args_list[1].kwargs [self.root.id, self.unversioned.id],
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"}) )
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
def test_effective_content_filter_matches_latest_version_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
" latest ",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_effective_content_filter_ignores_superseded_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
"superseded",
)
self.assertEqual(list(result), [])
def test_filters_reuse_an_existing_annotation(self) -> None:
"""
Annotating twice under the same alias is an error, so an already
annotated queryset (the search path) has to be left alone.
"""
annotated = annotate_effective_content(
Document.objects.filter(root_document__isnull=True),
)
self.assertIs(annotate_effective_content(annotated), annotated)
result = EffectiveContentFilter(lookup_expr="icontains").filter(
annotated,
"latest",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
"""
Bulk edit's "select all matching" builds its own queryset, so before
the filters annotated for themselves it matched the root document's
superseded content -- selecting documents the list view, filtered by
the same term, does not show.
"""
user = User.objects.create_superuser(username="bulk_selection")
selected = DocumentSelectionMixin()._resolve_document_ids(
user=user,
validated_data={
"all": True,
"filters": {"content__icontains": "superseded"},
},
)
self.assertEqual(selected, [])
def test_effective_content_filter_returns_input_for_empty_values(self) -> None: def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
queryset = mock.Mock() queryset = mock.Mock()
+122 -32
View File
@@ -981,6 +981,128 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(len(results), 1) self.assertEqual(len(results), 1)
self.assertEqual(results[0]["id"], doc.id) self.assertEqual(results[0]["id"], doc.id)
def test_has_duplicates_filter(self) -> None:
original_match = Document.objects.create(
title="original match",
checksum="same-original",
)
second_original_match = Document.objects.create(
title="second original match",
checksum="same-original",
)
archive_match = Document.objects.create(
title="archive match",
checksum="archive-source",
archive_checksum="same-archive",
)
original_to_archive_match = Document.objects.create(
title="original to archive match",
checksum="same-archive",
)
first_archive_match = Document.objects.create(
title="first archive match",
checksum="first-archive-source",
archive_checksum="same-archive-only",
)
second_archive_match = Document.objects.create(
title="second archive match",
checksum="second-archive-source",
archive_checksum="same-archive-only",
)
first_empty_archive = Document.objects.create(
title="first empty archive",
checksum="first-empty-archive",
archive_checksum="",
)
second_empty_archive = Document.objects.create(
title="second empty archive",
checksum="second-empty-archive",
archive_checksum="",
)
unique = Document.objects.create(title="unique", checksum="unique")
version_root = Document.objects.create(
title="version root",
checksum="version-root",
)
Document.objects.create(
title="version",
checksum=unique.checksum,
root_document=version_root,
version_index=1,
)
trash_match = Document.objects.create(
title="trash match",
checksum="trash-match",
)
trashed_duplicate = Document.objects.create(
title="trashed duplicate",
checksum="trash-match",
)
trashed_duplicate.delete()
response = self.client.get("/api/documents/?has_duplicates=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertCountEqual(
[document["id"] for document in response.data["results"]],
[
original_match.id,
second_original_match.id,
archive_match.id,
original_to_archive_match.id,
first_archive_match.id,
second_archive_match.id,
trash_match.id,
],
)
response = self.client.get("/api/documents/?has_duplicates=false")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertCountEqual(
[document["id"] for document in response.data["results"]],
[
unique.id,
version_root.id,
first_empty_archive.id,
second_empty_archive.id,
],
)
response = self.client.get(f"/api/documents/{first_empty_archive.id}/")
self.assertEqual(response.data["duplicate_documents"], [])
def test_has_duplicates_filter_respects_document_permissions(self) -> None:
owner = User.objects.create_user(username="duplicate-owner")
requester = User.objects.create_user(username="duplicate-requester")
requester.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
visible_document = Document.objects.create(
title="visible document",
checksum="permission-match",
owner=requester,
)
hidden_duplicate = Document.objects.create(
title="hidden duplicate",
checksum="permission-match",
owner=owner,
)
self.client.force_authenticate(user=requester)
response = self.client.get("/api/documents/?has_duplicates=true")
self.assertNotIn(
visible_document.id,
[document["id"] for document in response.data["results"]],
)
assign_perm("view_document", requester, hidden_duplicate)
response = self.client.get("/api/documents/?has_duplicates=true")
self.assertIn(
visible_document.id,
[document["id"] for document in response.data["results"]],
)
def test_custom_fields_icontains_filter_no_duplicates(self) -> None: def test_custom_fields_icontains_filter_no_duplicates(self) -> None:
""" """
GIVEN: GIVEN:
@@ -3735,7 +3857,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
}, },
) )
self.assertEqual(resp.status_code, status.HTTP_201_CREATED) self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(resp.data["document_title"], doc.title)
resp = self.client.post( resp = self.client.post(
"/api/share_links/", "/api/share_links/",
@@ -3746,17 +3867,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
}, },
) )
self.assertEqual(resp.status_code, status.HTTP_201_CREATED) self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(resp.data["document_title"], doc.title)
response = self.client.get("/api/share_links/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 2)
self.assertTrue(
all(
link["document_title"] == doc.title for link in response.data["results"]
),
)
response = self.client.get( response = self.client.get(
f"/api/documents/{doc.pk}/share_links/", f"/api/documents/{doc.pk}/share_links/",
@@ -3768,9 +3878,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
resp_data = response.json() resp_data = response.json()
self.assertEqual(len(resp_data), 2) self.assertEqual(len(resp_data), 2)
self.assertTrue(
all(link["document_title"] == doc.title for link in resp_data),
)
self.assertGreater(len(resp_data[1]["slug"]), 0) self.assertGreater(len(resp_data[1]["slug"]), 0)
self.assertIsNone(resp_data[1]["expiration"]) self.assertIsNone(resp_data[1]["expiration"])
@@ -3796,23 +3903,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_order_share_links_by_document_title(self) -> None:
document_zulu = Document.objects.create(title="Zulu")
document_alpha = Document.objects.create(title="Alpha")
ShareLink.objects.create(document=document_zulu, slug="zulu-link")
ShareLink.objects.create(document=document_alpha, slug="alpha-link")
response = self.client.get(
"/api/share_links/?ordering=document__title",
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[link["document_title"] for link in response.data["results"]],
["Alpha", "Zulu"],
)
def test_share_links_permissions_aware(self) -> None: def test_share_links_permissions_aware(self) -> None:
""" """
GIVEN: GIVEN:
+23
View File
@@ -1947,6 +1947,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data["documents"]), 1) self.assertEqual(len(response.data["documents"]), 1)
self.assertEqual(response.data["documents"][0]["id"], title_match.id) self.assertEqual(response.data["documents"][0]["id"], title_match.id)
def test_global_search_returns_latest_version_content(self) -> None:
root = Document.objects.create(
title="bank statement",
content="superseded content",
checksum="GSV1",
pk=23,
)
Document.objects.create(
title="bank statement v2",
content="latest content",
checksum="GSV2",
pk=24,
root_document=root,
version_index=1,
)
self.client.force_authenticate(self.user)
response = self.client.get("/api/search/?query=bank&db_only=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
self.assertEqual(returned.get(root.id), "latest content")
def test_global_search_filters_owned_mail_objects(self) -> None: def test_global_search_filters_owned_mail_objects(self) -> None:
user1 = User.objects.create_user("mail-search-user") user1 = User.objects.create_user("mail-search-user")
user2 = User.objects.create_user("other-mail-search-user") user2 = User.objects.create_user("other-mail-search-user")
+62
View File
@@ -207,3 +207,65 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
) )
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("have not yet been deleted", resp.data["documents"][0]) self.assertIn("have not yet been deleted", resp.data["documents"][0])
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
root = Document.objects.create(
title="root",
content="root-content",
checksum="root",
mime_type="application/pdf",
)
versions = [
Document.objects.create(
title=f"v{index}",
content=f"v{index}-content",
checksum=f"v{index}",
mime_type="application/pdf",
root_document=root,
version_index=index,
)
for index in range(1, 3)
]
return root, versions
def test_api_trash_restore_document_restores_its_versions(self) -> None:
"""
GIVEN:
- Existing document with two versions
WHEN:
- API request to delete the document
- API request to restore it from the trash
THEN:
- Only the document itself is listed in the trash
- A version cannot be restored without its root
- The document is restored together with all of its versions
"""
root, versions = self._make_versioned_document()
self.client.force_login(user=self.user)
self.client.delete(f"/api/documents/{root.pk}/")
self.assertEqual(Document.deleted_objects.count(), 3)
resp = self.client.get("/api/trash/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 1)
self.assertEqual(resp.data["results"][0]["id"], root.pk)
# A version cannot be restored while its root remains in the trash.
resp = self.client.post(
"/api/trash/",
{"action": "restore", "documents": [versions[0].pk]},
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("Restore the root document", resp.data["documents"][0])
resp = self.client.post(
"/api/trash/",
{"action": "restore", "documents": [root.pk]},
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(Document.deleted_objects.count(), 0)
self.assertCountEqual(
Document.objects.filter(root_document=root).values_list("id", flat=True),
[version.pk for version in versions],
)
+5
View File
@@ -392,6 +392,11 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists()) self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
self.assertFalse(Document.objects.filter(id=version.id).exists()) self.assertFalse(Document.objects.filter(id=version.id).exists())
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
self.assertTrue(Document.objects.filter(id=version.id).exists())
def test_delete_version_document_keeps_root(self) -> None: def test_delete_version_document_keeps_root(self) -> None:
version = Document.objects.create( version = Document.objects.create(
checksum="A-v1", checksum="A-v1",
+2 -2
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from django.core.checks import Error from django.core.checks import Error
from django.core.checks import Warning from django.core.checks import Warning
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from documents.checks import filename_format_check from documents.checks import filename_format_check
@@ -47,7 +47,7 @@ class TestFilenameFormatCheck:
) )
def test_warns_on_old_style_format( def test_warns_on_old_style_format(
self, self,
settings: SettingsWrapper, settings: Settings,
filename_format: str, filename_format: str,
expected_hint: str, expected_hint: str,
) -> None: ) -> None:
+145
View File
@@ -3,6 +3,7 @@ import warnings
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import numpy as np
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
@@ -11,6 +12,7 @@ from django.test import override_settings
from documents.classifier import ClassifierModelCorruptError from documents.classifier import ClassifierModelCorruptError
from documents.classifier import DocumentClassifier from documents.classifier import DocumentClassifier
from documents.classifier import IncompatibleClassifierVersionError from documents.classifier import IncompatibleClassifierVersionError
from documents.classifier import _predict_with_threshold
from documents.classifier import load_classifier from documents.classifier import load_classifier
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import Document from documents.models import Document
@@ -625,6 +627,103 @@ class TestClassifier(DirectoriesMixin, TestCase):
self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk) self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk)
self.assertIsNone(self.classifier.predict_storage_path(doc2.content)) self.assertIsNone(self.classifier.predict_storage_path(doc2.content))
def test_predict_rejects_prediction_below_match_threshold(self) -> None:
"""
GIVEN:
- Classifiers trained against test data with confident predictions
WHEN:
- CLASSIFIER_MATCH_THRESHOLD exceeds the model's confidence
THEN:
- Every predict_* method discards the match in favor of no match
"""
c1 = Correspondent.objects.create(
name="c1",
matching_algorithm=Correspondent.MATCH_AUTO,
)
dt1 = DocumentType.objects.create(
name="dt1",
matching_algorithm=DocumentType.MATCH_AUTO,
)
sp1 = StoragePath.objects.create(
name="sp1",
matching_algorithm=StoragePath.MATCH_AUTO,
)
doc1 = Document.objects.create(
title="doc1",
content="this is a document from c1",
correspondent=c1,
document_type=dt1,
storage_path=sp1,
checksum="A",
)
Document.objects.create(
title="doc2",
content="this is a document from no one",
checksum="B",
)
self.classifier.train()
predictors = {
"correspondent": self.classifier.predict_correspondent,
"document_type": self.classifier.predict_document_type,
"storage_path": self.classifier.predict_storage_path,
}
# No real prediction can reach a confidence this high, so this
# isolates the threshold check from the model's actual output.
with override_settings(CLASSIFIER_MATCH_THRESHOLD=0.999999):
for name, predict in predictors.items():
with self.subTest(field=name):
self.assertIsNone(predict(doc1.content))
def test_train_uses_balanced_sample_weight(self) -> None:
"""
GIVEN:
- A training set with correspondents, document types and storage paths
WHEN:
- The classifier is trained
THEN:
- Each MLP classifier is fit with balanced sample weights, so that
over-represented classes don't dominate predictions
"""
c1 = Correspondent.objects.create(
name="c1",
matching_algorithm=Correspondent.MATCH_AUTO,
)
dt1 = DocumentType.objects.create(
name="dt1",
matching_algorithm=DocumentType.MATCH_AUTO,
)
sp1 = StoragePath.objects.create(
name="sp1",
matching_algorithm=StoragePath.MATCH_AUTO,
)
Document.objects.create(
title="doc1",
content="this is a document from c1",
correspondent=c1,
document_type=dt1,
storage_path=sp1,
checksum="A",
)
Document.objects.create(
title="doc2",
content="this is a document from no one",
checksum="B",
)
with mock.patch(
"sklearn.utils.class_weight.compute_sample_weight",
return_value=None,
) as mocked_compute_sample_weight:
self.classifier.train()
self.assertEqual(mocked_compute_sample_weight.call_count, 3)
for call in mocked_compute_sample_weight.call_args_list:
self.assertEqual(call.args[0], "balanced")
def test_one_tag_predict(self) -> None: def test_one_tag_predict(self) -> None:
t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12)
@@ -810,6 +909,52 @@ class TestClassifier(DirectoriesMixin, TestCase):
load_classifier(raise_exception=True) load_classifier(raise_exception=True)
class _StubProbaClassifier:
"""
A fake scikit-learn classifier exposing just enough of the API for
`_predict_with_threshold`: `classes_` and `predict_proba`.
"""
def __init__(self, classes: list[int], probabilities: list[float]) -> None:
self.classes_ = np.array(classes)
self._probabilities = np.array([probabilities])
def predict_proba(self, X) -> np.ndarray:
return self._probabilities
@pytest.mark.parametrize(
("classes", "probabilities", "threshold", "expected"),
[
# confident prediction above the threshold is returned
([-1, 3], [0.1, 0.9], 0.6, 3),
# prediction below the threshold is discarded
([-1, 3], [0.45, 0.55], 0.6, None),
# boundary: exactly at the threshold is accepted, not discarded
([-1, 3], [0.4, 0.6], 0.6, 3),
# the winning class is the "no match" pseudo-class, regardless of its
# own confidence
([-1, 3], [0.99, 0.01], 0.0, None),
# threshold of 0.0 disables the confidence check entirely
([-1, 3], [0.45, 0.55], 0.0, 3),
],
)
def test_predict_with_threshold(classes, probabilities, threshold, expected) -> None:
classifier = _StubProbaClassifier(classes, probabilities)
result = _predict_with_threshold(classifier, X=None, threshold=threshold)
assert result == expected
def test_classifier_match_threshold_default() -> None:
"""
GIVEN:
- No PAPERLESS_CLASSIFIER_MATCH_THRESHOLD environment variable is set
THEN:
- The classifier match threshold defaults to 0.6
"""
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.6
def test_preprocess_content() -> None: def test_preprocess_content() -> None:
""" """
GIVEN: GIVEN:
+5 -1
View File
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
checksum="checksum", checksum="checksum",
mime_type="application/pdf", mime_type="application/pdf",
) )
Document.objects.create( version = Document.objects.create(
root_document=root, root_document=root,
correspondent=root.correspondent, correspondent=root.correspondent,
title="Version", title="Version",
@@ -124,6 +124,10 @@ class TestDocument(TestCase):
self.assertEqual(Document.objects.count(), 0) self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 2) self.assertEqual(Document.deleted_objects.count(), 2)
root.restore(strict=False)
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
def test_file_name(self) -> None: def test_file_name(self) -> None:
doc = Document( doc = Document(
mime_type="application/pdf", mime_type="application/pdf",
+118 -6
View File
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import MagicMock from unittest.mock import MagicMock
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@@ -136,6 +136,23 @@ def wait_for_mock_call(
return False return False
def sleep_past_stability(
owner: FileStabilityTracker | ConsumerThread,
*,
windows: float = 1.5,
) -> None:
"""
Block until a tracked file's stability window has certainly elapsed.
Args:
owner: The tracker, or the consumer thread running one, whose
configured stability delay sets the wait.
windows: How many stability windows to wait, giving slop for a slow
or loaded test runner.
"""
sleep(owner.stability_delay * windows)
class TestTrackedFile: class TestTrackedFile:
"""Tests for the TrackedFile dataclass.""" """Tests for the TrackedFile dataclass."""
@@ -261,6 +278,56 @@ class TestFileStabilityTracker:
assert len(stable) == 0 assert len(stable) == 0
assert stability_tracker.pending_count == 1 assert stability_tracker.pending_count == 1
def test_get_stable_files_skips_empty_file(
self,
stability_tracker: FileStabilityTracker,
tmp_path: Path,
) -> None:
"""
GIVEN:
- A zero byte file, tracked and past its stability delay
WHEN:
- Stable files are collected
THEN:
- The file is not yielded for consumption
- The file is dropped from tracking rather than held, so an
abandoned placeholder does not keep the watch loop awake
"""
empty = tmp_path / "scan.pdf"
empty.write_bytes(b"")
stability_tracker.track(empty, Change.added)
sleep_past_stability(stability_tracker)
stable = list(stability_tracker.get_stable_files())
assert stable == []
assert stability_tracker.pending_count == 0
def test_empty_file_is_yielded_once_content_arrives(
self,
stability_tracker: FileStabilityTracker,
tmp_path: Path,
) -> None:
"""
GIVEN:
- A zero byte file which was dropped from tracking while empty
WHEN:
- The writer fills the file and a new event re-tracks it
THEN:
- The file is yielded for consumption once it is stable
"""
target = tmp_path / "scan.pdf"
target.write_bytes(b"")
stability_tracker.track(target, Change.added)
sleep_past_stability(stability_tracker)
assert list(stability_tracker.get_stable_files()) == []
target.write_bytes(b"%PDF-1.4 content")
stability_tracker.track(target, Change.modified)
sleep_past_stability(stability_tracker)
assert list(stability_tracker.get_stable_files()) == [target]
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None: def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
"""Test deleted file is not returned during stability check.""" """Test deleted file is not returned during stability check."""
tracker = FileStabilityTracker(stability_delay=0.1) tracker = FileStabilityTracker(stability_delay=0.1)
@@ -605,7 +672,7 @@ class TestCommandValidation:
def test_raises_for_missing_consumption_dir( def test_raises_for_missing_consumption_dir(
self, self,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test command raises error when directory is not provided.""" """Test command raises error when directory is not provided."""
settings.CONSUMPTION_DIR = None settings.CONSUMPTION_DIR = None
@@ -639,7 +706,7 @@ class TestCommandOneshot:
scratch_dir: Path, scratch_dir: Path,
sample_pdf: Path, sample_pdf: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test oneshot mode processes existing files.""" """Test oneshot mode processes existing files."""
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
@@ -659,7 +726,7 @@ class TestCommandOneshot:
scratch_dir: Path, scratch_dir: Path,
sample_pdf: Path, sample_pdf: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test oneshot mode processes files recursively.""" """Test oneshot mode processes files recursively."""
subdir = consumption_dir / "subdir" subdir = consumption_dir / "subdir"
@@ -681,7 +748,7 @@ class TestCommandOneshot:
consumption_dir: Path, consumption_dir: Path,
scratch_dir: Path, scratch_dir: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test oneshot mode ignores unsupported file extensions.""" """Test oneshot mode ignores unsupported file extensions."""
target = consumption_dir / "document.xyz" target = consumption_dir / "document.xyz"
@@ -879,6 +946,51 @@ class TestCommandWatch:
mock_consume_file_delay.apply_async.assert_called() mock_consume_file_delay.apply_async.assert_called()
def test_scanner_placeholder_is_not_consumed_while_empty(
self,
consumption_dir: Path,
sample_pdf: Path,
mock_consume_file_delay: MagicMock,
start_consumer: Callable[..., ConsumerThread],
) -> None:
"""
GIVEN:
- A scanner which creates a zero byte placeholder and only writes
the page some time later (GH discussion #13969)
WHEN:
- The placeholder sits untouched well past the stability delay
- The scanner then writes the real content
THEN:
- The empty placeholder is never queued, as it could only fail
with "Unsupported mime type inode/x-empty"
- The file is queued exactly once, when the content lands
"""
thread = start_consumer(stability_delay=0.2)
target = consumption_dir / "scan.pdf"
target.write_bytes(b"") # the scanner's placeholder
# Well past the stability delay: the old behaviour queued it here.
sleep_past_stability(thread, windows=5)
if thread.exception:
raise thread.exception
assert mock_consume_file_delay.apply_async.call_count == 0
shutil.copy(sample_pdf, target) # the scanner finishes the page
assert wait_for_mock_call(
mock_consume_file_delay.apply_async,
timeout_s=5.0,
)
if thread.exception:
raise thread.exception
assert mock_consume_file_delay.apply_async.call_count == 1
queued_doc = mock_consume_file_delay.apply_async.call_args.kwargs["kwargs"][
"input_doc"
]
assert queued_doc.original_file.name == "scan.pdf"
def test_ignores_macos_files( def test_ignores_macos_files(
self, self,
consumption_dir: Path, consumption_dir: Path,
@@ -1256,7 +1368,7 @@ class TestProcessExistingFilesQueued:
consumption_dir: Path, consumption_dir: Path,
sample_pdf: Path, sample_pdf: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""The set returned seeds the rescan's queued set, avoiding re-queue.""" """The set returned seeds the rescan's queued set, avoiding re-queue."""
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
+2 -2
View File
@@ -1,7 +1,7 @@
from collections.abc import Generator from collections.abc import Generator
import pytest import pytest
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from documents.parsers import get_default_file_extension from documents.parsers import get_default_file_extension
from documents.parsers import get_supported_file_extensions from documents.parsers import get_supported_file_extensions
@@ -14,7 +14,7 @@ from paperless.parsers.tika import TikaDocumentParser
@pytest.fixture() @pytest.fixture()
def _tika_registry(settings: SettingsWrapper) -> Generator[None, None, None]: def _tika_registry(settings: Settings) -> Generator[None, None, None]:
""" """
Rebuild the parser registry with Tika enabled for the duration of the Rebuild the parser registry with Tika enabled for the duration of the
test, then reset on exit so other tests see the default (Tika-disabled) test, then reset on exit so other tests see the default (Tika-disabled)
+33
View File
@@ -32,6 +32,7 @@ from documents.signals.handlers import update_llm_suggestions_cache
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response from documents.tests.utils import read_streaming_response
from paperless.models import ApplicationConfiguration from paperless.models import ApplicationConfiguration
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
@@ -737,6 +738,38 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"), get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
) )
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
)
def test_ai_suggestions_with_llm_provider_error(
self,
mock_get_ai_classification,
) -> None:
mock_get_ai_classification.side_effect = LLMProviderError(
"confidential provider response",
)
self.client.force_login(user=self.user)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
self.assertEqual(
response.json(),
{
"ai": [
"AI backend rejected the request. Check logs for details.",
],
},
)
self.assertNotIn("confidential provider response", response.content.decode())
self.assertIsNone(
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("documents.views.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,

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