mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-26 03:10:31 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a4026d4f | ||
|
|
339f9a4d30 | ||
|
|
856140c068 | ||
|
|
62cf7c4429 | ||
|
|
9a2c2b9331 | ||
|
|
506afb4200 | ||
|
|
22a8a4f4ad | ||
|
|
d5425a893d | ||
|
|
cc25144dbf |
@@ -1,5 +1,185 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.2.0
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
- Enhancement (QoL): support deselecting single items from "select all" [@shamoon](https://github.com/shamoon) ([#14117](https://github.com/paperless-ngx/paperless-ngx/pull/14117))
|
||||
- Enhancement: Match fuzzy terms in place inside the parsed query [@stumpylog](https://github.com/stumpylog) ([#14157](https://github.com/paperless-ngx/paperless-ngx/pull/14157))
|
||||
- Enhancement: Match CJK terms through their bigram fields in place [@stumpylog](https://github.com/stumpylog) ([#14156](https://github.com/paperless-ngx/paperless-ngx/pull/14156))
|
||||
- Enhancement: centralized management of share links + bundles [@shamoon](https://github.com/shamoon) ([#14115](https://github.com/paperless-ngx/paperless-ngx/pull/14115))
|
||||
- Enhancement: parse advanced search with whoosh-compat and delete the handwritten translation [@stumpylog](https://github.com/stumpylog) ([#14072](https://github.com/paperless-ngx/paperless-ngx/pull/14072))
|
||||
- Enhancement: allow regex timeout configuration [@shamoon](https://github.com/shamoon) ([#14085](https://github.com/paperless-ngx/paperless-ngx/pull/14085))
|
||||
- Enhancement: hide-able sidebar items [@shamoon](https://github.com/shamoon) ([#14052](https://github.com/paperless-ngx/paperless-ngx/pull/14052))
|
||||
- Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold [@dewey](https://github.com/dewey) ([#12164](https://github.com/paperless-ngx/paperless-ngx/pull/12164))
|
||||
- Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates [@stumpylog](https://github.com/stumpylog) ([#13820](https://github.com/paperless-ngx/paperless-ngx/pull/13820))
|
||||
- Enhancement (QoL): surface externally-set options in Config UI [@shamoon](https://github.com/shamoon) ([#13989](https://github.com/paperless-ngx/paperless-ngx/pull/13989))
|
||||
- Enhancement: allow disabling auto-suggestions for inbox documents [@shamoon](https://github.com/shamoon) ([#13946](https://github.com/paperless-ngx/paperless-ngx/pull/13946))
|
||||
- Change: skip documents with empty content in apply AI suggestions WF [@shamoon](https://github.com/shamoon) ([#13985](https://github.com/paperless-ngx/paperless-ngx/pull/13985))
|
||||
- Enhancement: duplicates filter [@shamoon](https://github.com/shamoon) ([#13994](https://github.com/paperless-ngx/paperless-ngx/pull/13994))
|
||||
- Tweak: note that apply AI suggestions runs async in WF editor [@shamoon](https://github.com/shamoon) ([#14004](https://github.com/paperless-ngx/paperless-ngx/pull/14004))
|
||||
- Enhancement (QoL): attempt to localize firstDayOfWeek for date picker [@shamoon](https://github.com/shamoon) ([#13999](https://github.com/paperless-ngx/paperless-ngx/pull/13999))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: don't redirect to signup on first install when regular login is disabled [@cyberb](https://github.com/cyberb) ([#14165](https://github.com/paperless-ngx/paperless-ngx/pull/14165))
|
||||
- Fix: better catch email workflow placeholder parsing errors [@shamoon](https://github.com/shamoon) ([#14129](https://github.com/paperless-ngx/paperless-ngx/pull/14129))
|
||||
- Fix: validate legacy bulk edit owner, rotate and split parameters [@stumpylog](https://github.com/stumpylog) ([#14120](https://github.com/paperless-ngx/paperless-ngx/pull/14120))
|
||||
- Fix: validate set\_permissions with a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14119](https://github.com/paperless-ngx/paperless-ngx/pull/14119))
|
||||
- Fix: Use prefetching to reduce query counts during classifier training [@stumpylog](https://github.com/stumpylog) ([#14122](https://github.com/paperless-ngx/paperless-ngx/pull/14122))
|
||||
- Fix: reject non-dict user\_args/barcode\_tag\_mapping in config API [@stumpylog](https://github.com/stumpylog) ([#14118](https://github.com/paperless-ngx/paperless-ngx/pull/14118))
|
||||
- Fix: type edit\_pdf operations via a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14116](https://github.com/paperless-ngx/paperless-ngx/pull/14116))
|
||||
- Fix: ensure django setup is run for management comments under 3.14 [@shamoon](https://github.com/shamoon) ([#14100](https://github.com/paperless-ngx/paperless-ngx/pull/14100))
|
||||
- Fix: validate PDF output doc indexes in bulk edit [@shamoon](https://github.com/shamoon) ([#14083](https://github.com/paperless-ngx/paperless-ngx/pull/14083))
|
||||
- Fix: avoid IntegrityError when a retried task republishes with the same ID [@stumpylog](https://github.com/stumpylog) ([#14096](https://github.com/paperless-ngx/paperless-ngx/pull/14096))
|
||||
- Fix: update some api global perms inconsistencies [@shamoon](https://github.com/shamoon) ([#14086](https://github.com/paperless-ngx/paperless-ngx/pull/14086))
|
||||
- Fix: ignore nested action IDs on WF create [@shamoon](https://github.com/shamoon) ([#14084](https://github.com/paperless-ngx/paperless-ngx/pull/14084))
|
||||
- Fix: correct text/stream compression workaround [@shamoon](https://github.com/shamoon) ([#14064](https://github.com/paperless-ngx/paperless-ngx/pull/14064))
|
||||
- Fix: ui version content switching inconsistencies [@shamoon](https://github.com/shamoon) ([#14066](https://github.com/paperless-ngx/paperless-ngx/pull/14066))
|
||||
- Fix: prevent saving changes to stale cached document object [@shamoon](https://github.com/shamoon) ([#14065](https://github.com/paperless-ngx/paperless-ngx/pull/14065))
|
||||
- Fix: ensure remove inbox tag children on remove\_inbox\_tags [@shamoon](https://github.com/shamoon) ([#14050](https://github.com/paperless-ngx/paperless-ngx/pull/14050))
|
||||
- Fix: connect add\_to\_index handler after document\_added [@shamoon](https://github.com/shamoon) ([#14058](https://github.com/paperless-ngx/paperless-ngx/pull/14058))
|
||||
- Fix: Drop empty files from tracking after the stability window has passed [@stumpylog](https://github.com/stumpylog) ([#14047](https://github.com/paperless-ngx/paperless-ngx/pull/14047))
|
||||
- Fixhancement: better LLM errors [@shamoon](https://github.com/shamoon) ([#14031](https://github.com/paperless-ngx/paperless-ngx/pull/14031))
|
||||
- Fix: prevent orphaned versions from bulk delete [@shamoon](https://github.com/shamoon) ([#14030](https://github.com/paperless-ngx/paperless-ngx/pull/14030))
|
||||
- Fixhancement: prevent overlapping mail-account processing runs [@stumpylog](https://github.com/stumpylog) ([#14046](https://github.com/paperless-ngx/paperless-ngx/pull/14046))
|
||||
- Fix: Use PAPERLESS\_REDIS\_PREFIX for Celery result backend keys [@bdd](https://github.com/bdd) ([#14015](https://github.com/paperless-ngx/paperless-ngx/pull/14015))
|
||||
- Fix: correct setting ai\_enabled to false via UI [@shamoon](https://github.com/shamoon) ([#13987](https://github.com/paperless-ngx/paperless-ngx/pull/13987))
|
||||
- Fix: catch some frontend failed object retrievals [@shamoon](https://github.com/shamoon) ([#14023](https://github.com/paperless-ngx/paperless-ngx/pull/14023))
|
||||
- Fix: more v3 icons cleanup [@shamoon](https://github.com/shamoon) ([#14017](https://github.com/paperless-ngx/paperless-ngx/pull/14017))
|
||||
- Fix: correct add version actor parity [@shamoon](https://github.com/shamoon) ([#14016](https://github.com/paperless-ngx/paperless-ngx/pull/14016))
|
||||
- Fix: fix v3 favicon file [@shamoon](https://github.com/shamoon) ([#14014](https://github.com/paperless-ngx/paperless-ngx/pull/14014))
|
||||
- Fix: change share link bundle dialog button to close after create, don't toast on copied [@shamoon](https://github.com/shamoon) ([#14002](https://github.com/paperless-ngx/paperless-ngx/pull/14002))
|
||||
- Fix: truncate mail subjects to field max length [@shamoon](https://github.com/shamoon) ([#13991](https://github.com/paperless-ngx/paperless-ngx/pull/13991))
|
||||
- Fix: enforce the overflow hidden rule on pdf editor thumbnails [@shamoon](https://github.com/shamoon) ([#13976](https://github.com/paperless-ngx/paperless-ngx/pull/13976))
|
||||
- Fix: also correct unbroken long names on small cards [@shamoon](https://github.com/shamoon) ([#13974](https://github.com/paperless-ngx/paperless-ngx/pull/13974))
|
||||
- Fix: ensure parent + child tags change together in bulk editor [@shamoon](https://github.com/shamoon) ([#13972](https://github.com/paperless-ngx/paperless-ngx/pull/13972))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>29 changes</summary>
|
||||
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14167](https://github.com/paperless-ngx/paperless-ngx/pull/14167))
|
||||
- docker(deps): Bump astral-sh/uv from 0.12.9-python3.14-trixie-slim to 0.12.16-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#14134](https://github.com/paperless-ngx/paperless-ngx/pull/14134))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14149](https://github.com/paperless-ngx/paperless-ngx/pull/14149))
|
||||
- Chore(deps): Bump the actions group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14162](https://github.com/paperless-ngx/paperless-ngx/pull/14162))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 19 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14141](https://github.com/paperless-ngx/paperless-ngx/pull/14141))
|
||||
- docker-compose(deps): bump gotenberg/gotenberg from 8.36 to 8.37 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14137](https://github.com/paperless-ngx/paperless-ngx/pull/14137))
|
||||
- docker-compose(deps): Bump nginx from 1.31.5-alpine to 1.31.6-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14138](https://github.com/paperless-ngx/paperless-ngx/pull/14138))
|
||||
- Chore(deps): Bump pdfjs-dist from 6.2.108 to 6.3.289 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14144](https://github.com/paperless-ngx/paperless-ngx/pull/14144))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14143](https://github.com/paperless-ngx/paperless-ngx/pull/14143))
|
||||
- Chore(deps-dev): Bump @types/node from 26.4.0 to 26.5.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14146](https://github.com/paperless-ngx/paperless-ngx/pull/14146))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14142](https://github.com/paperless-ngx/paperless-ngx/pull/14142))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13988](https://github.com/paperless-ngx/paperless-ngx/pull/13988))
|
||||
- Chore(deps): Bump sentence-transformers from 5.6.1 to 6.0.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13983](https://github.com/paperless-ngx/paperless-ngx/pull/13983))
|
||||
- Chore(deps-dev): Bump types-markdown from 3.10.2.20260518 to 3.10.2.20260712 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13982](https://github.com/paperless-ngx/paperless-ngx/pull/13982))
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13980](https://github.com/paperless-ngx/paperless-ngx/pull/13980))
|
||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.7.0 to >=2.7,\<2.9 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13984](https://github.com/paperless-ngx/paperless-ngx/pull/13984))
|
||||
- Chore: Updates our direct Redis pin [@stumpylog](https://github.com/stumpylog) ([#13986](https://github.com/paperless-ngx/paperless-ngx/pull/13986))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13918](https://github.com/paperless-ngx/paperless-ngx/pull/13918))
|
||||
- Chore(deps): Bump uuid from 14.0.1 to 14.0.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13921](https://github.com/paperless-ngx/paperless-ngx/pull/13921))
|
||||
- Chore(deps-dev): Bump @types/node from 26.2.0 to 26.4.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13919](https://github.com/paperless-ngx/paperless-ngx/pull/13919))
|
||||
- Chore: update ng-select to v24, handle breaking changes [@shamoon](https://github.com/shamoon) ([#13951](https://github.com/paperless-ngx/paperless-ngx/pull/13951))
|
||||
- Chore(deps): Bump djangorestframework from 3.17.2 to 3.18.0 in the django-ecosystem group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13912](https://github.com/paperless-ngx/paperless-ngx/pull/13912))
|
||||
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13922](https://github.com/paperless-ngx/paperless-ngx/pull/13922))
|
||||
- Chore(deps): Bump the document-processing group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13916](https://github.com/paperless-ngx/paperless-ngx/pull/13916))
|
||||
- Chore(deps): Bump flower from 2.0.1 to 2.1.0 in the async-tasks group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13913](https://github.com/paperless-ngx/paperless-ngx/pull/13913))
|
||||
- Chore(deps): Bump the actions group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13920](https://github.com/paperless-ngx/paperless-ngx/pull/13920))
|
||||
- docker-compose(deps): Bump gotenberg/gotenberg from 8.34 to 8.36 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13910](https://github.com/paperless-ngx/paperless-ngx/pull/13910))
|
||||
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13911](https://github.com/paperless-ngx/paperless-ngx/pull/13911))
|
||||
- docker(deps): Bump astral-sh/uv from 0.12.5-python3.14-trixie-slim to 0.12.9-python3.14-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#13914](https://github.com/paperless-ngx/paperless-ngx/pull/13914))
|
||||
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>80 changes</summary>
|
||||
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 15 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14167](https://github.com/paperless-ngx/paperless-ngx/pull/14167))
|
||||
- Fix: don't redirect to signup on first install when regular login is disabled [@cyberb](https://github.com/cyberb) ([#14165](https://github.com/paperless-ngx/paperless-ngx/pull/14165))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 10 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14149](https://github.com/paperless-ngx/paperless-ngx/pull/14149))
|
||||
- Enhancement (QoL): support deselecting single items from "select all" [@shamoon](https://github.com/shamoon) ([#14117](https://github.com/paperless-ngx/paperless-ngx/pull/14117))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 19 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14141](https://github.com/paperless-ngx/paperless-ngx/pull/14141))
|
||||
- Enhancement: Match fuzzy terms in place inside the parsed query [@stumpylog](https://github.com/stumpylog) ([#14157](https://github.com/paperless-ngx/paperless-ngx/pull/14157))
|
||||
- Enhancement: Match CJK terms through their bigram fields in place [@stumpylog](https://github.com/stumpylog) ([#14156](https://github.com/paperless-ngx/paperless-ngx/pull/14156))
|
||||
- Chore(deps): Bump pdfjs-dist from 6.2.108 to 6.3.289 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14144](https://github.com/paperless-ngx/paperless-ngx/pull/14144))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14143](https://github.com/paperless-ngx/paperless-ngx/pull/14143))
|
||||
- Chore(deps-dev): Bump @types/node from 26.4.0 to 26.5.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14146](https://github.com/paperless-ngx/paperless-ngx/pull/14146))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14142](https://github.com/paperless-ngx/paperless-ngx/pull/14142))
|
||||
- Enhancement: centralized management of share links + bundles [@shamoon](https://github.com/shamoon) ([#14115](https://github.com/paperless-ngx/paperless-ngx/pull/14115))
|
||||
- Performance: Preprocess classifier text with Tantivy instead of NLTK [@stumpylog](https://github.com/stumpylog) ([#14127](https://github.com/paperless-ngx/paperless-ngx/pull/14127))
|
||||
- Performance: Drops fields from the classifier before pickling [@stumpylog](https://github.com/stumpylog) ([#14114](https://github.com/paperless-ngx/paperless-ngx/pull/14114))
|
||||
- Fix: better catch email workflow placeholder parsing errors [@shamoon](https://github.com/shamoon) ([#14129](https://github.com/paperless-ngx/paperless-ngx/pull/14129))
|
||||
- Performance: Improves the memory efficiency of classifier training [@stumpylog](https://github.com/stumpylog) ([#14124](https://github.com/paperless-ngx/paperless-ngx/pull/14124))
|
||||
- Performance: Streams the classifier pickle file during save as well [@stumpylog](https://github.com/stumpylog) ([#14121](https://github.com/paperless-ngx/paperless-ngx/pull/14121))
|
||||
- Fix: validate legacy bulk edit owner, rotate and split parameters [@stumpylog](https://github.com/stumpylog) ([#14120](https://github.com/paperless-ngx/paperless-ngx/pull/14120))
|
||||
- Fix: validate set\_permissions with a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14119](https://github.com/paperless-ngx/paperless-ngx/pull/14119))
|
||||
- Fix: Use prefetching to reduce query counts during classifier training [@stumpylog](https://github.com/stumpylog) ([#14122](https://github.com/paperless-ngx/paperless-ngx/pull/14122))
|
||||
- Fix: reject non-dict user\_args/barcode\_tag\_mapping in config API [@stumpylog](https://github.com/stumpylog) ([#14118](https://github.com/paperless-ngx/paperless-ngx/pull/14118))
|
||||
- Fix: type edit\_pdf operations via a nested serializer [@stumpylog](https://github.com/stumpylog) ([#14116](https://github.com/paperless-ngx/paperless-ngx/pull/14116))
|
||||
- Performance: Loads the classifier through a memory view to reduce memory usage [@stumpylog](https://github.com/stumpylog) ([#14113](https://github.com/paperless-ngx/paperless-ngx/pull/14113))
|
||||
- Feature: parse advanced search with whoosh-compat and delete the handwritten translation [@stumpylog](https://github.com/stumpylog) ([#14072](https://github.com/paperless-ngx/paperless-ngx/pull/14072))
|
||||
- Fix: ensure django setup is run for management comments under 3.14 [@shamoon](https://github.com/shamoon) ([#14100](https://github.com/paperless-ngx/paperless-ngx/pull/14100))
|
||||
- Fix: validate PDF output doc indexes in bulk edit [@shamoon](https://github.com/shamoon) ([#14083](https://github.com/paperless-ngx/paperless-ngx/pull/14083))
|
||||
- Enhancement: allow regex timeout configuration [@shamoon](https://github.com/shamoon) ([#14085](https://github.com/paperless-ngx/paperless-ngx/pull/14085))
|
||||
- Fix: avoid IntegrityError when a retried task republishes with the same ID [@stumpylog](https://github.com/stumpylog) ([#14096](https://github.com/paperless-ngx/paperless-ngx/pull/14096))
|
||||
- Chore: include Apply AI Suggestions in the tasks UI filter dropdown [@shamoon](https://github.com/shamoon) ([#14093](https://github.com/paperless-ngx/paperless-ngx/pull/14093))
|
||||
- Fix: update some api global perms inconsistencies [@shamoon](https://github.com/shamoon) ([#14086](https://github.com/paperless-ngx/paperless-ngx/pull/14086))
|
||||
- Fix: ignore nested action IDs on WF create [@shamoon](https://github.com/shamoon) ([#14084](https://github.com/paperless-ngx/paperless-ngx/pull/14084))
|
||||
- Fix: correct text/stream compression workaround [@shamoon](https://github.com/shamoon) ([#14064](https://github.com/paperless-ngx/paperless-ngx/pull/14064))
|
||||
- Fix: ui version content switching inconsistencies [@shamoon](https://github.com/shamoon) ([#14066](https://github.com/paperless-ngx/paperless-ngx/pull/14066))
|
||||
- Fix: prevent saving changes to stale cached document object [@shamoon](https://github.com/shamoon) ([#14065](https://github.com/paperless-ngx/paperless-ngx/pull/14065))
|
||||
- Performance: batch permission assignment in bulk `set_permissions` [@stumpylog](https://github.com/stumpylog) ([#13806](https://github.com/paperless-ngx/paperless-ngx/pull/13806))
|
||||
- Performance: skip effective\_content annotation on document list unless required [@stumpylog](https://github.com/stumpylog) ([#13789](https://github.com/paperless-ngx/paperless-ngx/pull/13789))
|
||||
- Fix: ensure remove inbox tag children on remove\_inbox\_tags [@shamoon](https://github.com/shamoon) ([#14050](https://github.com/paperless-ngx/paperless-ngx/pull/14050))
|
||||
- Fix: connect add\_to\_index handler after document\_added [@shamoon](https://github.com/shamoon) ([#14058](https://github.com/paperless-ngx/paperless-ngx/pull/14058))
|
||||
- Enhancement: hide-able sidebar items [@shamoon](https://github.com/shamoon) ([#14052](https://github.com/paperless-ngx/paperless-ngx/pull/14052))
|
||||
- Performance: cut redundant per-document lookups in bulk `modify_custom_fields` [@stumpylog](https://github.com/stumpylog) ([#13807](https://github.com/paperless-ngx/paperless-ngx/pull/13807))
|
||||
- Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold [@dewey](https://github.com/dewey) ([#12164](https://github.com/paperless-ngx/paperless-ngx/pull/12164))
|
||||
- Fix: Drop empty files from tracking after the stability window has passed [@stumpylog](https://github.com/stumpylog) ([#14047](https://github.com/paperless-ngx/paperless-ngx/pull/14047))
|
||||
- Fixhancement: better LLM errors [@shamoon](https://github.com/shamoon) ([#14031](https://github.com/paperless-ngx/paperless-ngx/pull/14031))
|
||||
- Fix: prevent orphaned versions from bulk delete [@shamoon](https://github.com/shamoon) ([#14030](https://github.com/paperless-ngx/paperless-ngx/pull/14030))
|
||||
- Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates [@stumpylog](https://github.com/stumpylog) ([#13820](https://github.com/paperless-ngx/paperless-ngx/pull/13820))
|
||||
- Fixhancement: prevent overlapping mail-account processing runs [@stumpylog](https://github.com/stumpylog) ([#14046](https://github.com/paperless-ngx/paperless-ngx/pull/14046))
|
||||
- Performance: ensure version-aware content filters on querysets [@shamoon](https://github.com/shamoon) ([#13792](https://github.com/paperless-ngx/paperless-ngx/pull/13792))
|
||||
- Performance: skip nested TagSerializer construction when a tag has no children [@stumpylog](https://github.com/stumpylog) ([#14039](https://github.com/paperless-ngx/paperless-ngx/pull/14039))
|
||||
- Enhancement (QoL): surface externally-set options in Config UI [@shamoon](https://github.com/shamoon) ([#13989](https://github.com/paperless-ngx/paperless-ngx/pull/13989))
|
||||
- Enhancement: allow disabling auto-suggestions for inbox documents [@shamoon](https://github.com/shamoon) ([#13946](https://github.com/paperless-ngx/paperless-ngx/pull/13946))
|
||||
- Change: skip documents with empty content in apply AI suggestions WF [@shamoon](https://github.com/shamoon) ([#13985](https://github.com/paperless-ngx/paperless-ngx/pull/13985))
|
||||
- Performance: resolve index-write permissions and effective content in bulk [@stumpylog](https://github.com/stumpylog) ([#13869](https://github.com/paperless-ngx/paperless-ngx/pull/13869))
|
||||
- Fix: Use PAPERLESS\_REDIS\_PREFIX for Celery result backend keys [@bdd](https://github.com/bdd) ([#14015](https://github.com/paperless-ngx/paperless-ngx/pull/14015))
|
||||
- Enhancement: duplicates filter [@shamoon](https://github.com/shamoon) ([#13994](https://github.com/paperless-ngx/paperless-ngx/pull/13994))
|
||||
- Fix: correct setting ai\_enabled to false via UI [@shamoon](https://github.com/shamoon) ([#13987](https://github.com/paperless-ngx/paperless-ngx/pull/13987))
|
||||
- Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13988](https://github.com/paperless-ngx/paperless-ngx/pull/13988))
|
||||
- Fix: catch some frontend failed object retrievals [@shamoon](https://github.com/shamoon) ([#14023](https://github.com/paperless-ngx/paperless-ngx/pull/14023))
|
||||
- Fix: more v3 icons cleanup [@shamoon](https://github.com/shamoon) ([#14017](https://github.com/paperless-ngx/paperless-ngx/pull/14017))
|
||||
- Fix: correct add version actor parity [@shamoon](https://github.com/shamoon) ([#14016](https://github.com/paperless-ngx/paperless-ngx/pull/14016))
|
||||
- Fix: fix v3 favicon file [@shamoon](https://github.com/shamoon) ([#14014](https://github.com/paperless-ngx/paperless-ngx/pull/14014))
|
||||
- Tweak: note that apply AI suggestions runs async in WF editor [@shamoon](https://github.com/shamoon) ([#14004](https://github.com/paperless-ngx/paperless-ngx/pull/14004))
|
||||
- Fix: change share link bundle dialog button to close after create, don't toast on copied [@shamoon](https://github.com/shamoon) ([#14002](https://github.com/paperless-ngx/paperless-ngx/pull/14002))
|
||||
- Enhancement (QoL): attempt to localize firstDayOfWeek for date picker [@shamoon](https://github.com/shamoon) ([#13999](https://github.com/paperless-ngx/paperless-ngx/pull/13999))
|
||||
- Fix: truncate mail subjects to field max length [@shamoon](https://github.com/shamoon) ([#13991](https://github.com/paperless-ngx/paperless-ngx/pull/13991))
|
||||
- Chore(deps): Bump sentence-transformers from 5.6.1 to 6.0.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13983](https://github.com/paperless-ngx/paperless-ngx/pull/13983))
|
||||
- Chore(deps-dev): Bump types-markdown from 3.10.2.20260518 to 3.10.2.20260712 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13982](https://github.com/paperless-ngx/paperless-ngx/pull/13982))
|
||||
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13980](https://github.com/paperless-ngx/paperless-ngx/pull/13980))
|
||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.7.0 to >=2.7,\<2.9 @[dependabot[bot]](https://github.com/apps/dependabot) ([#13984](https://github.com/paperless-ngx/paperless-ngx/pull/13984))
|
||||
- Chore: Updates our direct Redis pin [@stumpylog](https://github.com/stumpylog) ([#13986](https://github.com/paperless-ngx/paperless-ngx/pull/13986))
|
||||
- Fix: enforce the overflow hidden rule on pdf editor thumbnails [@shamoon](https://github.com/shamoon) ([#13976](https://github.com/paperless-ngx/paperless-ngx/pull/13976))
|
||||
- Fix: also correct unbroken long names on small cards [@shamoon](https://github.com/shamoon) ([#13974](https://github.com/paperless-ngx/paperless-ngx/pull/13974))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13918](https://github.com/paperless-ngx/paperless-ngx/pull/13918))
|
||||
- Chore(deps): Bump uuid from 14.0.1 to 14.0.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13921](https://github.com/paperless-ngx/paperless-ngx/pull/13921))
|
||||
- Chore(deps-dev): Bump @types/node from 26.2.0 to 26.4.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13919](https://github.com/paperless-ngx/paperless-ngx/pull/13919))
|
||||
- Chore: update ng-select to v24, handle breaking changes [@shamoon](https://github.com/shamoon) ([#13951](https://github.com/paperless-ngx/paperless-ngx/pull/13951))
|
||||
- Chore(deps): Bump djangorestframework from 3.17.2 to 3.18.0 in the django-ecosystem group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13912](https://github.com/paperless-ngx/paperless-ngx/pull/13912))
|
||||
- Chore(deps): Bump the document-processing group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13916](https://github.com/paperless-ngx/paperless-ngx/pull/13916))
|
||||
- Chore(deps): Bump flower from 2.0.1 to 2.1.0 in the async-tasks group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13913](https://github.com/paperless-ngx/paperless-ngx/pull/13913))
|
||||
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13911](https://github.com/paperless-ngx/paperless-ngx/pull/13911))
|
||||
- Fix: ensure parent + child tags change together in bulk editor [@shamoon](https://github.com/shamoon) ([#13972](https://github.com/paperless-ngx/paperless-ngx/pull/13972))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.1.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ dependencies = [
|
||||
"llama-index-embeddings-openai-like>=0.2.2",
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"ocrmypdf>=17.7,<17.12",
|
||||
"ocrmypdf[heic]>=17.12,<17.13",
|
||||
"openai>=2.48",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="advanced-search-dialog-title" i18n>
|
||||
Advanced search
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
i18n-aria-label
|
||||
(click)="cancel()"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (unreadable) {
|
||||
<div class="alert alert-warning d-flex flex-wrap gap-2 align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span i18n
|
||||
>The current query uses syntax this editor can't show. It still works
|
||||
as typed.</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
(click)="startOver()"
|
||||
i18n
|
||||
>
|
||||
Start a new query
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
groupTemplate;
|
||||
context: { group: root, parent: null, depth: 0 }
|
||||
"
|
||||
></ng-container>
|
||||
|
||||
<div class="mt-4">
|
||||
<label
|
||||
class="form-label small text-muted"
|
||||
for="advanced-search-preview"
|
||||
i18n
|
||||
>Query</label
|
||||
>
|
||||
<pre
|
||||
id="advanced-search-preview"
|
||||
class="query-preview mb-0 p-2 rounded border"
|
||||
>@if (generatedQuery) {{{ generatedQuery }}} @else {<span class="text-muted fst-italic" i18n>Nothing to search for yet</span>}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
(click)="cancel()"
|
||||
i18n
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="apply()"
|
||||
[disabled]="!generatedQuery"
|
||||
i18n
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template
|
||||
#groupTemplate
|
||||
let-group="group"
|
||||
let-parent="parent"
|
||||
let-depth="depth"
|
||||
>
|
||||
<div class="d-flex w-100 gap-2">
|
||||
<div class="d-flex flex-grow-1 flex-column">
|
||||
<div class="d-flex align-items-center flex-wrap">
|
||||
<div
|
||||
class="btn-group btn-group-xs"
|
||||
role="group"
|
||||
aria-label="Match"
|
||||
i18n-aria-label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.Or"
|
||||
id="advancedSearchAny_{{ idFor(group) }}"
|
||||
name="advancedSearchAny_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-primary"
|
||||
for="advancedSearchAny_{{ idFor(group) }}"
|
||||
i18n
|
||||
>Any</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.And"
|
||||
id="advancedSearchAll_{{ idFor(group) }}"
|
||||
name="advancedSearchAll_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-primary"
|
||||
for="advancedSearchAll_{{ idFor(group) }}"
|
||||
i18n
|
||||
>All</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.Not"
|
||||
id="advancedSearchNot_{{ idFor(group) }}"
|
||||
name="advancedSearchNot_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-secondary"
|
||||
for="advancedSearchNot_{{ idFor(group) }}"
|
||||
i18n
|
||||
>Not</label
|
||||
>
|
||||
</div>
|
||||
<span class="small text-muted ms-2">
|
||||
@switch (group.operator) {
|
||||
@case (LogicalOperator.And) {
|
||||
<ng-container i18n>match all of these</ng-container>
|
||||
}
|
||||
@case (LogicalOperator.Or) {
|
||||
<ng-container i18n>match any of these</ng-container>
|
||||
}
|
||||
@case (LogicalOperator.Not) {
|
||||
<ng-container i18n>match none of these</ng-container>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@for (element of group.children; track element) {
|
||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||
@if (element.type === ElementType.Group) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
groupTemplate;
|
||||
context: { group: element, parent: group, depth: depth + 1 }
|
||||
"
|
||||
></ng-container>
|
||||
} @else {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
atomTemplate;
|
||||
context: { atom: element, parent: group }
|
||||
"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="btn-group-vertical align-self-start ms-2 ps-2 border-start"
|
||||
role="group"
|
||||
aria-label="Group actions"
|
||||
i18n-aria-label
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-primary"
|
||||
title="Add condition"
|
||||
i18n-title
|
||||
(click)="addAtom(group)"
|
||||
[disabled]="group.children.length >= maxAtoms"
|
||||
>
|
||||
<i-bs name="node-plus"></i-bs>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-primary"
|
||||
title="Add group"
|
||||
i18n-title
|
||||
(click)="addGroup(group)"
|
||||
[disabled]="depth >= maxDepth"
|
||||
>
|
||||
<i-bs name="braces"></i-bs>
|
||||
</button>
|
||||
@if (parent) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-danger"
|
||||
aria-label="Remove group"
|
||||
i18n-aria-label
|
||||
(click)="remove(parent, group)"
|
||||
>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #atomTemplate let-atom="atom" let-parent="parent">
|
||||
<div class="d-flex align-items-center gap-1 w-100">
|
||||
<div class="input-group input-group-sm flex-wrap">
|
||||
<select
|
||||
class="form-select flex-grow-0 w-auto"
|
||||
[(ngModel)]="atom.field"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="onFieldChange(atom)"
|
||||
aria-label="Field"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (fieldGroup of fieldGroups; track fieldGroup.label) {
|
||||
<optgroup [label]="fieldGroup.label">
|
||||
@for (field of fieldGroup.fields; track field) {
|
||||
<option [ngValue]="field">{{ fieldLabels[field] }}</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
</select>
|
||||
<select
|
||||
class="form-select flex-grow-0 w-auto"
|
||||
[(ngModel)]="atom.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="onOperatorChange(atom)"
|
||||
aria-label="Condition"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (operator of operatorsFor(atom); track operator) {
|
||||
<option [ngValue]="operator">
|
||||
{{ operatorLabel(atom, operator) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@switch (atom.operator) {
|
||||
@case (Operator.DateKeyword) {
|
||||
<select
|
||||
class="form-select"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Period"
|
||||
i18n-aria-label
|
||||
>
|
||||
<option [ngValue]="''" disabled i18n>Choose a period</option>
|
||||
@for (keyword of dateKeywords; track keyword) {
|
||||
<option [ngValue]="keyword">
|
||||
{{ dateKeywordLabels[keyword] }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@case (Operator.WithinLast) {
|
||||
<input
|
||||
class="form-control amount"
|
||||
type="number"
|
||||
min="1"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Amount"
|
||||
i18n-aria-label
|
||||
/>
|
||||
<select
|
||||
class="form-select"
|
||||
[(ngModel)]="atom.unit"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Unit"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (unit of dateUnits; track unit) {
|
||||
<option [ngValue]="unit">{{ dateUnitLabels[unit] }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@case (Operator.Between) {
|
||||
<input
|
||||
class="form-control"
|
||||
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="From"
|
||||
i18n-aria-label
|
||||
/>
|
||||
<span class="input-group-text" i18n>and</span>
|
||||
<input
|
||||
class="form-control"
|
||||
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||
[(ngModel)]="atom.valueTo"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="To"
|
||||
i18n-aria-label
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
@if (kindOf(atom) === FieldKind.Date) {
|
||||
<input
|
||||
class="form-control"
|
||||
type="date"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Date"
|
||||
i18n-aria-label
|
||||
/>
|
||||
} @else if (kindOf(atom) === FieldKind.Number) {
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Number"
|
||||
i18n-aria-label
|
||||
/>
|
||||
} @else {
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[placeholder]="placeholderFor(atom)"
|
||||
aria-label="Value"
|
||||
i18n-aria-label
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-link btn-sm text-danger px-1"
|
||||
type="button"
|
||||
(click)="remove(parent, atom)"
|
||||
aria-label="Remove condition"
|
||||
i18n-aria-label
|
||||
>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
.query-preview {
|
||||
font-size: 0.8125rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background-color: var(--pngx-bg-darker);
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
.input-group .amount {
|
||||
flex: 0 1 5rem;
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from 'src/app/data/advanced-search-query'
|
||||
import { AdvancedSearchDialogComponent } from './advanced-search-dialog.component'
|
||||
|
||||
describe('AdvancedSearchDialogComponent', () => {
|
||||
let component: AdvancedSearchDialogComponent
|
||||
let fixture: ComponentFixture<AdvancedSearchDialogComponent>
|
||||
let activeModal: NgbActiveModal
|
||||
|
||||
const firstAtom = (): AdvancedSearchQueryAtom =>
|
||||
component.root.children[0] as AdvancedSearchQueryAtom
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AdvancedSearchDialogComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [NgbActiveModal],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(AdvancedSearchDialogComponent)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should start with one empty condition and nothing to search for', () => {
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
expect(component.generatedQuery).toBe('')
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show an existing query as conditions', () => {
|
||||
component.query = 'title:invoice AND NOT tag:paid'
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
expect(component.root.children).toHaveLength(2)
|
||||
expect(component.generatedQuery).toBe('title:invoice AND NOT tag:paid')
|
||||
})
|
||||
|
||||
it('should flag a query it cannot show and start empty', () => {
|
||||
component.query = 'title:invoice^2'
|
||||
expect(component.unreadable).toBeTruthy()
|
||||
expect(component.generatedQuery).toBe('')
|
||||
})
|
||||
|
||||
it('should clear the warning when starting a new query', () => {
|
||||
component.query = 'title:invoice^2'
|
||||
component.startOver()
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should treat an empty query as a fresh start', () => {
|
||||
component.query = ' '
|
||||
expect(component.unreadable).toBeFalsy()
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should write the query as conditions are filled in', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Title
|
||||
atom.value = 'unpaid invoice'
|
||||
expect(component.generatedQuery).toBe('title:unpaid AND title:invoice')
|
||||
})
|
||||
|
||||
it('should offer the conditions of the chosen field', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Added
|
||||
component.onFieldChange(atom)
|
||||
expect(component.operatorsFor(atom)).toContain(
|
||||
AdvancedSearchOperator.WithinLast
|
||||
)
|
||||
expect(component.operatorsFor(atom)).not.toContain(
|
||||
AdvancedSearchOperator.Phrase
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep a condition the new field still offers', () => {
|
||||
const atom = firstAtom()
|
||||
atom.operator = AdvancedSearchOperator.Phrase
|
||||
atom.field = AdvancedSearchField.Correspondent
|
||||
component.onFieldChange(atom)
|
||||
expect(atom.operator).toBe(AdvancedSearchOperator.Phrase)
|
||||
})
|
||||
|
||||
it('should replace a condition the new field does not offer, and clear the value', () => {
|
||||
const atom = firstAtom()
|
||||
atom.operator = AdvancedSearchOperator.Phrase
|
||||
atom.value = 'invoice'
|
||||
atom.field = AdvancedSearchField.ASN
|
||||
component.onFieldChange(atom)
|
||||
expect(atom.operator).toBe(AdvancedSearchOperator.Equals)
|
||||
expect(atom.value).toBe('')
|
||||
})
|
||||
|
||||
it('should give a within-the-last condition a unit to start from', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Added
|
||||
atom.operator = AdvancedSearchOperator.WithinLast
|
||||
component.onOperatorChange(atom)
|
||||
expect(atom.unit).toBe(AdvancedSearchDateUnit.Day)
|
||||
atom.value = '3'
|
||||
expect(component.generatedQuery).toBe('added:[-3 days to now]')
|
||||
})
|
||||
|
||||
it('should label date comparisons as dates', () => {
|
||||
const atom = firstAtom()
|
||||
atom.field = AdvancedSearchField.Created
|
||||
expect(
|
||||
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
|
||||
).toEqual('is on or after')
|
||||
atom.field = AdvancedSearchField.ASN
|
||||
expect(
|
||||
component.operatorLabel(atom, AdvancedSearchOperator.AtLeast)
|
||||
).toEqual('is at least')
|
||||
})
|
||||
|
||||
it('should add and remove conditions', () => {
|
||||
component.addAtom(component.root)
|
||||
expect(component.root.children).toHaveLength(2)
|
||||
component.remove(component.root, component.root.children[1])
|
||||
expect(component.root.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should add a group, which starts as Any', () => {
|
||||
component.addGroup(component.root)
|
||||
const group = component.root.children[1] as AdvancedSearchQueryGroup
|
||||
expect(group.type).toBe(AdvancedSearchQueryElementType.Group)
|
||||
expect(group.operator).toBe(AdvancedSearchLogicalOperator.Or)
|
||||
expect(group.children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should give every group its own id, once', () => {
|
||||
component.addGroup(component.root)
|
||||
const group = component.root.children[1] as AdvancedSearchQueryGroup
|
||||
expect(component.idFor(component.root)).not.toEqual(component.idFor(group))
|
||||
expect(component.idFor(group)).toEqual(component.idFor(group))
|
||||
})
|
||||
|
||||
it('should apply the query and close', () => {
|
||||
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
const atom = firstAtom()
|
||||
atom.value = 'invoice'
|
||||
component.apply()
|
||||
expect(emitSpy).toHaveBeenCalledWith('content:invoice')
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close without applying on cancel', () => {
|
||||
const emitSpy = jest.spyOn(component.queryApplied, 'emit')
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.cancel()
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show the query it will apply', () => {
|
||||
component.query = 'content:invoice OR content:receipt'
|
||||
fixture.detectChanges()
|
||||
const preview = fixture.nativeElement.querySelector(
|
||||
'#advanced-search-preview'
|
||||
)
|
||||
expect(preview.textContent).toContain('content:invoice OR content:receipt')
|
||||
})
|
||||
|
||||
it('should not offer to apply an empty query', () => {
|
||||
fixture.detectChanges()
|
||||
const apply = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.modal-footer button')
|
||||
).pop() as HTMLButtonElement
|
||||
expect(apply.disabled).toBeTruthy()
|
||||
})
|
||||
})
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, EventEmitter, inject, Input, Output } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
ADVANCED_SEARCH_DATE_KEYWORD_LABELS,
|
||||
ADVANCED_SEARCH_DATE_KEYWORDS,
|
||||
ADVANCED_SEARCH_DATE_OPERATOR_LABELS,
|
||||
ADVANCED_SEARCH_DATE_UNIT_LABELS,
|
||||
ADVANCED_SEARCH_FIELD_GROUPS,
|
||||
ADVANCED_SEARCH_FIELD_KINDS,
|
||||
ADVANCED_SEARCH_FIELD_LABELS,
|
||||
ADVANCED_SEARCH_MAX_ATOMS,
|
||||
ADVANCED_SEARCH_MAX_DEPTH,
|
||||
ADVANCED_SEARCH_OPERATOR_LABELS,
|
||||
ADVANCED_SEARCH_OPERATORS_BY_KIND,
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchFieldKind,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElement,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from 'src/app/data/advanced-search-query'
|
||||
import {
|
||||
parseAdvancedSearchQuery,
|
||||
serializeAdvancedSearchQuery,
|
||||
} from 'src/app/utils/advanced-search-query'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-advanced-search-dialog',
|
||||
templateUrl: './advanced-search-dialog.component.html',
|
||||
styleUrl: './advanced-search-dialog.component.scss',
|
||||
imports: [FormsModule, NgTemplateOutlet, NgxBootstrapIconsModule],
|
||||
})
|
||||
export class AdvancedSearchDialogComponent extends LoadingComponentWithPermissions {
|
||||
private activeModal = inject(NgbActiveModal)
|
||||
|
||||
public readonly ElementType = AdvancedSearchQueryElementType
|
||||
public readonly LogicalOperator = AdvancedSearchLogicalOperator
|
||||
public readonly Operator = AdvancedSearchOperator
|
||||
public readonly FieldKind = AdvancedSearchFieldKind
|
||||
public readonly fieldGroups = ADVANCED_SEARCH_FIELD_GROUPS
|
||||
public readonly fieldLabels = ADVANCED_SEARCH_FIELD_LABELS
|
||||
public readonly dateKeywords = ADVANCED_SEARCH_DATE_KEYWORDS
|
||||
public readonly dateKeywordLabels = ADVANCED_SEARCH_DATE_KEYWORD_LABELS
|
||||
public readonly dateUnits = Object.values(AdvancedSearchDateUnit)
|
||||
public readonly dateUnitLabels = ADVANCED_SEARCH_DATE_UNIT_LABELS
|
||||
public readonly maxDepth = ADVANCED_SEARCH_MAX_DEPTH
|
||||
public readonly maxAtoms = ADVANCED_SEARCH_MAX_ATOMS
|
||||
|
||||
@Output()
|
||||
public queryApplied = new EventEmitter<string>()
|
||||
|
||||
public root: AdvancedSearchQueryGroup = this.emptyRoot()
|
||||
|
||||
// True when the query in the search box uses syntax the editor cannot show
|
||||
public unreadable: boolean = false
|
||||
|
||||
private _query: string = ''
|
||||
|
||||
@Input()
|
||||
set query(query: string) {
|
||||
this._query = query ?? ''
|
||||
const parsed = parseAdvancedSearchQuery(this._query)
|
||||
this.unreadable = !!this._query.trim() && !parsed
|
||||
this.root = parsed ?? this.emptyRoot()
|
||||
}
|
||||
|
||||
get query(): string {
|
||||
return this._query
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.loading.set(false)
|
||||
}
|
||||
|
||||
// Stable ids for the radio groups, without putting them in the query model
|
||||
private ids = new WeakMap<object, number>()
|
||||
private nextId = 0
|
||||
|
||||
public idFor(element: AdvancedSearchQueryElement): number {
|
||||
if (!this.ids.has(element)) {
|
||||
this.ids.set(element, this.nextId++)
|
||||
}
|
||||
return this.ids.get(element)
|
||||
}
|
||||
|
||||
private emptyRoot(): AdvancedSearchQueryGroup {
|
||||
return {
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator: AdvancedSearchLogicalOperator.And,
|
||||
children: [this.newAtom()],
|
||||
}
|
||||
}
|
||||
|
||||
private newAtom(): AdvancedSearchQueryAtom {
|
||||
return {
|
||||
type: AdvancedSearchQueryElementType.Atom,
|
||||
field: AdvancedSearchField.Content,
|
||||
operator: AdvancedSearchOperator.AllWords,
|
||||
value: '',
|
||||
}
|
||||
}
|
||||
|
||||
public get generatedQuery(): string {
|
||||
return serializeAdvancedSearchQuery(this.root)
|
||||
}
|
||||
|
||||
public kindOf(atom: AdvancedSearchQueryAtom): AdvancedSearchFieldKind {
|
||||
return ADVANCED_SEARCH_FIELD_KINDS[atom.field]
|
||||
}
|
||||
|
||||
public operatorsFor(atom: AdvancedSearchQueryAtom): AdvancedSearchOperator[] {
|
||||
return ADVANCED_SEARCH_OPERATORS_BY_KIND[this.kindOf(atom)]
|
||||
}
|
||||
|
||||
public operatorLabel(
|
||||
atom: AdvancedSearchQueryAtom,
|
||||
operator: AdvancedSearchOperator
|
||||
): string {
|
||||
return this.kindOf(atom) === AdvancedSearchFieldKind.Date
|
||||
? (ADVANCED_SEARCH_DATE_OPERATOR_LABELS[operator] ??
|
||||
ADVANCED_SEARCH_OPERATOR_LABELS[operator])
|
||||
: ADVANCED_SEARCH_OPERATOR_LABELS[operator]
|
||||
}
|
||||
|
||||
public placeholderFor(atom: AdvancedSearchQueryAtom): string {
|
||||
switch (atom.operator) {
|
||||
case AdvancedSearchOperator.Phrase:
|
||||
return $localize`phrase`
|
||||
case AdvancedSearchOperator.StartsWith:
|
||||
return $localize`beginning of a word`
|
||||
default:
|
||||
return $localize`words`
|
||||
}
|
||||
}
|
||||
|
||||
public onFieldChange(atom: AdvancedSearchQueryAtom) {
|
||||
// Keep the condition only if the new field still offers it
|
||||
if (!this.operatorsFor(atom).includes(atom.operator)) {
|
||||
atom.operator = this.operatorsFor(atom)[0]
|
||||
}
|
||||
this.clearValues(atom)
|
||||
}
|
||||
|
||||
public onOperatorChange(atom: AdvancedSearchQueryAtom) {
|
||||
this.clearValues(atom)
|
||||
}
|
||||
|
||||
private clearValues(atom: AdvancedSearchQueryAtom) {
|
||||
atom.value = ''
|
||||
atom.valueTo = undefined
|
||||
atom.unit =
|
||||
atom.operator === AdvancedSearchOperator.WithinLast
|
||||
? AdvancedSearchDateUnit.Day
|
||||
: undefined
|
||||
}
|
||||
|
||||
public addAtom(group: AdvancedSearchQueryGroup) {
|
||||
group.children.push(this.newAtom())
|
||||
}
|
||||
|
||||
public addGroup(group: AdvancedSearchQueryGroup) {
|
||||
group.children.push({
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator: AdvancedSearchLogicalOperator.Or,
|
||||
children: [this.newAtom()],
|
||||
})
|
||||
}
|
||||
|
||||
public remove(
|
||||
parent: AdvancedSearchQueryGroup,
|
||||
element: AdvancedSearchQueryElement
|
||||
) {
|
||||
parent.children = parent.children.filter((child) => child !== element)
|
||||
}
|
||||
|
||||
public startOver() {
|
||||
this.unreadable = false
|
||||
this.root = this.emptyRoot()
|
||||
}
|
||||
|
||||
public apply() {
|
||||
this.queryApplied.emit(this.generatedQuery)
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@if (advancedSearchEditorAvailable) {
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" (click)="openAdvancedSearchEditor()"
|
||||
title="Edit query" i18n-title [disabled]="disabled">
|
||||
<i-bs name="sliders"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@if (_textFilter) {
|
||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
NgbDatepickerModule,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||
@@ -2515,4 +2517,45 @@ describe('FilterEditorComponent', () => {
|
||||
|
||||
expect(component.textFilter).toEqual('help ')
|
||||
})
|
||||
|
||||
it('should open the advanced search editor with the current query and apply the result', () => {
|
||||
const modalService: NgbModal = TestBed.inject(NgbModal)
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe(
|
||||
(instances) => (modal = instances[0])
|
||||
)
|
||||
component.textFilterTarget = 'fulltext-query'
|
||||
component.updateTextFilter('title:invoice')
|
||||
fixture.detectChanges()
|
||||
|
||||
const editorButton = fixture.debugElement.query(
|
||||
By.css('button[title="Edit query"]')
|
||||
)
|
||||
expect(editorButton).not.toBeNull()
|
||||
editorButton.triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(modal.componentInstance.query).toEqual('title:invoice')
|
||||
|
||||
const rulesSpy = jest.spyOn(component.filterRulesChange, 'next')
|
||||
modal.componentInstance.queryApplied.emit('title:invoice AND NOT tag:paid')
|
||||
expect(component.textFilter).toEqual('title:invoice AND NOT tag:paid')
|
||||
expect(documentService.searchQuery).toEqual(
|
||||
'title:invoice AND NOT tag:paid'
|
||||
)
|
||||
expect(rulesSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'title:invoice AND NOT tag:paid',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should not offer the advanced search editor for other targets', () => {
|
||||
component.textFilterTarget = 'title-content'
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('button[title="Edit query"]'))
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbTypeahead,
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Observable, Subject, from } from 'rxjs'
|
||||
import { first, Observable, Subject, from } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
@@ -121,6 +122,7 @@ import {
|
||||
PermissionsFilterDropdownComponent,
|
||||
PermissionsSelectionModel,
|
||||
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
||||
import { AdvancedSearchDialogComponent } from '../../common/advanced-search-dialog/advanced-search-dialog.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
const TEXT_FILTER_TARGET_TITLE = 'title'
|
||||
@@ -286,6 +288,7 @@ export class FilterEditorComponent
|
||||
permissionsService = inject(PermissionsService)
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
private searchService = inject(SearchService)
|
||||
private modalService = inject(NgbModal)
|
||||
|
||||
generateFilterName() {
|
||||
if (this.filterRules.length == 1) {
|
||||
@@ -1372,6 +1375,23 @@ export class FilterEditorComponent
|
||||
}
|
||||
}
|
||||
|
||||
get advancedSearchEditorAvailable(): boolean {
|
||||
return this.textFilterTarget === TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||
}
|
||||
|
||||
openAdvancedSearchEditor() {
|
||||
const modal = this.modalService.open(AdvancedSearchDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
modal.componentInstance.query = this._textFilter ?? ''
|
||||
modal.componentInstance.queryApplied
|
||||
.pipe(first())
|
||||
.subscribe((query: string) => {
|
||||
this.updateTextFilter(query)
|
||||
})
|
||||
}
|
||||
|
||||
textFilterKeydown(event: KeyboardEvent) {
|
||||
if (event.key == 'Enter') {
|
||||
if (event.defaultPrevented) {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Fields and forms documented in docs/usage.md > "Document searches"
|
||||
|
||||
export enum AdvancedSearchField {
|
||||
Any = '',
|
||||
Title = 'title',
|
||||
Content = 'content',
|
||||
OriginalFilename = 'original_filename',
|
||||
NoteText = 'notes.note',
|
||||
NoteAuthor = 'notes.user',
|
||||
CustomFieldName = 'custom_fields.name',
|
||||
CustomFieldValue = 'custom_fields.value',
|
||||
Correspondent = 'correspondent',
|
||||
DocumentType = 'document_type',
|
||||
StoragePath = 'storage_path',
|
||||
Tag = 'tag',
|
||||
ASN = 'asn',
|
||||
PageCount = 'page_count',
|
||||
NumNotes = 'num_notes',
|
||||
Created = 'created',
|
||||
Added = 'added',
|
||||
Modified = 'modified',
|
||||
Checksum = 'checksum',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchFieldKind {
|
||||
Text = 'text',
|
||||
Number = 'number',
|
||||
Date = 'date',
|
||||
Checksum = 'checksum',
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_FIELD_KINDS: Record<
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchFieldKind
|
||||
> = {
|
||||
[AdvancedSearchField.Any]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Title]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Content]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.OriginalFilename]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.NoteText]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.NoteAuthor]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.CustomFieldName]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.CustomFieldValue]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Correspondent]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.DocumentType]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.StoragePath]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.Tag]: AdvancedSearchFieldKind.Text,
|
||||
[AdvancedSearchField.ASN]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.PageCount]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.NumNotes]: AdvancedSearchFieldKind.Number,
|
||||
[AdvancedSearchField.Created]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Added]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Modified]: AdvancedSearchFieldKind.Date,
|
||||
[AdvancedSearchField.Checksum]: AdvancedSearchFieldKind.Checksum,
|
||||
}
|
||||
|
||||
export enum AdvancedSearchOperator {
|
||||
AllWords = 'all',
|
||||
AnyWord = 'any',
|
||||
Phrase = 'phrase',
|
||||
StartsWith = 'prefix',
|
||||
Equals = 'eq',
|
||||
AtLeast = 'gte',
|
||||
AtMost = 'lte',
|
||||
Between = 'between',
|
||||
DateKeyword = 'keyword',
|
||||
WithinLast = 'within',
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_OPERATORS_BY_KIND: Record<
|
||||
AdvancedSearchFieldKind,
|
||||
AdvancedSearchOperator[]
|
||||
> = {
|
||||
[AdvancedSearchFieldKind.Text]: [
|
||||
AdvancedSearchOperator.AllWords,
|
||||
AdvancedSearchOperator.AnyWord,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
AdvancedSearchOperator.StartsWith,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Number]: [
|
||||
AdvancedSearchOperator.Equals,
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
AdvancedSearchOperator.AtMost,
|
||||
AdvancedSearchOperator.Between,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Date]: [
|
||||
AdvancedSearchOperator.DateKeyword,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
AdvancedSearchOperator.AtMost,
|
||||
AdvancedSearchOperator.Between,
|
||||
],
|
||||
[AdvancedSearchFieldKind.Checksum]: [AdvancedSearchOperator.StartsWith],
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_KEYWORDS = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'tomorrow',
|
||||
'previous week',
|
||||
'this month',
|
||||
'previous month',
|
||||
'previous quarter',
|
||||
'this year',
|
||||
'previous year',
|
||||
] as const
|
||||
|
||||
export type AdvancedSearchDateKeyword =
|
||||
(typeof ADVANCED_SEARCH_DATE_KEYWORDS)[number]
|
||||
|
||||
export enum AdvancedSearchDateUnit {
|
||||
Day = 'day',
|
||||
Week = 'week',
|
||||
Month = 'month',
|
||||
Year = 'year',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchLogicalOperator {
|
||||
And = 'AND',
|
||||
Or = 'OR',
|
||||
Not = 'NOT',
|
||||
}
|
||||
|
||||
export enum AdvancedSearchQueryElementType {
|
||||
Atom = 'atom',
|
||||
Group = 'group',
|
||||
}
|
||||
|
||||
export interface AdvancedSearchQueryAtom {
|
||||
type: AdvancedSearchQueryElementType.Atom
|
||||
field: AdvancedSearchField
|
||||
operator: AdvancedSearchOperator
|
||||
value?: string
|
||||
valueTo?: string
|
||||
unit?: AdvancedSearchDateUnit // for WithinLast
|
||||
}
|
||||
|
||||
export interface AdvancedSearchQueryGroup {
|
||||
type: AdvancedSearchQueryElementType.Group
|
||||
operator: AdvancedSearchLogicalOperator
|
||||
children: AdvancedSearchQueryElement[]
|
||||
}
|
||||
|
||||
export type AdvancedSearchQueryElement =
|
||||
AdvancedSearchQueryAtom | AdvancedSearchQueryGroup
|
||||
|
||||
export const ADVANCED_SEARCH_MAX_DEPTH = 2
|
||||
export const ADVANCED_SEARCH_MAX_ATOMS = 10
|
||||
|
||||
export const ADVANCED_SEARCH_FIELD_LABELS: Record<AdvancedSearchField, string> =
|
||||
{
|
||||
[AdvancedSearchField.Any]: $localize`Any field`,
|
||||
[AdvancedSearchField.Title]: $localize`Title`,
|
||||
[AdvancedSearchField.Content]: $localize`Content`,
|
||||
[AdvancedSearchField.OriginalFilename]: $localize`File name`,
|
||||
[AdvancedSearchField.NoteText]: $localize`Note text`,
|
||||
[AdvancedSearchField.NoteAuthor]: $localize`Note author`,
|
||||
[AdvancedSearchField.CustomFieldName]: $localize`Custom field name`,
|
||||
[AdvancedSearchField.CustomFieldValue]: $localize`Custom field value`,
|
||||
[AdvancedSearchField.Correspondent]: $localize`Correspondent name`,
|
||||
[AdvancedSearchField.DocumentType]: $localize`Document type name`,
|
||||
[AdvancedSearchField.StoragePath]: $localize`Storage path name`,
|
||||
[AdvancedSearchField.Tag]: $localize`Tag name`,
|
||||
[AdvancedSearchField.ASN]: $localize`ASN`,
|
||||
[AdvancedSearchField.PageCount]: $localize`Pages`,
|
||||
[AdvancedSearchField.NumNotes]: $localize`Number of notes`,
|
||||
[AdvancedSearchField.Created]: $localize`Created`,
|
||||
[AdvancedSearchField.Added]: $localize`Added`,
|
||||
[AdvancedSearchField.Modified]: $localize`Modified`,
|
||||
[AdvancedSearchField.Checksum]: $localize`Checksum`,
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_FIELD_GROUPS: {
|
||||
label: string
|
||||
fields: AdvancedSearchField[]
|
||||
}[] = [
|
||||
{
|
||||
label: $localize`Text`,
|
||||
fields: [
|
||||
AdvancedSearchField.Any,
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchField.Content,
|
||||
AdvancedSearchField.OriginalFilename,
|
||||
AdvancedSearchField.NoteText,
|
||||
AdvancedSearchField.NoteAuthor,
|
||||
AdvancedSearchField.CustomFieldName,
|
||||
AdvancedSearchField.CustomFieldValue,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: $localize`Names`,
|
||||
fields: [
|
||||
AdvancedSearchField.Correspondent,
|
||||
AdvancedSearchField.DocumentType,
|
||||
AdvancedSearchField.StoragePath,
|
||||
AdvancedSearchField.Tag,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: $localize`Numbers`,
|
||||
fields: [
|
||||
AdvancedSearchField.ASN,
|
||||
AdvancedSearchField.PageCount,
|
||||
AdvancedSearchField.NumNotes,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: $localize`Dates`,
|
||||
fields: [
|
||||
AdvancedSearchField.Created,
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchField.Modified,
|
||||
],
|
||||
},
|
||||
{ label: $localize`Other`, fields: [AdvancedSearchField.Checksum] },
|
||||
]
|
||||
|
||||
export const ADVANCED_SEARCH_OPERATOR_LABELS: Record<
|
||||
AdvancedSearchOperator,
|
||||
string
|
||||
> = {
|
||||
[AdvancedSearchOperator.AllWords]: $localize`contains all words`,
|
||||
[AdvancedSearchOperator.AnyWord]: $localize`contains any word`,
|
||||
[AdvancedSearchOperator.Phrase]: $localize`contains the phrase`,
|
||||
[AdvancedSearchOperator.StartsWith]: $localize`starts with`,
|
||||
[AdvancedSearchOperator.Equals]: $localize`is`,
|
||||
[AdvancedSearchOperator.AtLeast]: $localize`is at least`,
|
||||
[AdvancedSearchOperator.AtMost]: $localize`is at most`,
|
||||
[AdvancedSearchOperator.Between]: $localize`is between`,
|
||||
[AdvancedSearchOperator.DateKeyword]: $localize`is`,
|
||||
[AdvancedSearchOperator.WithinLast]: $localize`is within the last`,
|
||||
}
|
||||
|
||||
// Comparing dates reads differently than comparing counts
|
||||
export const ADVANCED_SEARCH_DATE_OPERATOR_LABELS: Partial<
|
||||
Record<AdvancedSearchOperator, string>
|
||||
> = {
|
||||
[AdvancedSearchOperator.AtLeast]: $localize`is on or after`,
|
||||
[AdvancedSearchOperator.AtMost]: $localize`is on or before`,
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_KEYWORD_LABELS: Record<string, string> = {
|
||||
today: $localize`today`,
|
||||
yesterday: $localize`yesterday`,
|
||||
tomorrow: $localize`tomorrow`,
|
||||
'previous week': $localize`previous week`,
|
||||
'this month': $localize`this month`,
|
||||
'previous month': $localize`previous month`,
|
||||
'previous quarter': $localize`previous quarter`,
|
||||
'this year': $localize`this year`,
|
||||
'previous year': $localize`previous year`,
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_UNIT_LABELS: Record<
|
||||
AdvancedSearchDateUnit,
|
||||
string
|
||||
> = {
|
||||
[AdvancedSearchDateUnit.Day]: $localize`days`,
|
||||
[AdvancedSearchDateUnit.Week]: $localize`weeks`,
|
||||
[AdvancedSearchDateUnit.Month]: $localize`months`,
|
||||
[AdvancedSearchDateUnit.Year]: $localize`years`,
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElement,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from '../data/advanced-search-query'
|
||||
import {
|
||||
parseAdvancedSearchQuery,
|
||||
serializeAdvancedSearchQuery,
|
||||
} from './advanced-search-query'
|
||||
|
||||
const atom = (
|
||||
field: AdvancedSearchField,
|
||||
operator: AdvancedSearchOperator,
|
||||
value?: string,
|
||||
extra: Partial<AdvancedSearchQueryAtom> = {}
|
||||
): AdvancedSearchQueryAtom => ({
|
||||
type: AdvancedSearchQueryElementType.Atom,
|
||||
field,
|
||||
operator,
|
||||
value,
|
||||
...extra,
|
||||
})
|
||||
|
||||
const group = (
|
||||
operator: AdvancedSearchLogicalOperator,
|
||||
...children: AdvancedSearchQueryElement[]
|
||||
): AdvancedSearchQueryGroup => ({
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator,
|
||||
children,
|
||||
})
|
||||
|
||||
const { And, Or, Not } = AdvancedSearchLogicalOperator
|
||||
|
||||
describe('serializeAdvancedSearchQuery', () => {
|
||||
describe('text fields', () => {
|
||||
it.each([
|
||||
[AdvancedSearchOperator.AllWords, 'invoice', 'title:invoice'],
|
||||
[
|
||||
AdvancedSearchOperator.AllWords,
|
||||
' invoice unpaid ',
|
||||
'title:invoice AND title:unpaid',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.AnyWord,
|
||||
'invoice unpaid',
|
||||
'title:invoice OR title:unpaid',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'quick brown fox',
|
||||
'title:"quick brown fox"',
|
||||
],
|
||||
[AdvancedSearchOperator.Phrase, 'say "hi"', 'title:"say hi"'],
|
||||
[AdvancedSearchOperator.StartsWith, 'invoi', 'title:invoi*'],
|
||||
[AdvancedSearchOperator.StartsWith, 'in*v?oi', 'title:invoi*'],
|
||||
])('%s %j writes %s', (operator, value, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Title, operator, value)
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it('writes bare words for the Any field', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Any, AdvancedSearchOperator.AllWords, 'a b')
|
||||
)
|
||||
).toBe('a AND b')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['A-1312/99', 'custom_fields.value:A-1312/99'],
|
||||
["O'Brien", "custom_fields.value:O'Brien"],
|
||||
["'quoted'", `custom_fields.value:"'quoted'"`],
|
||||
['foo:bar', 'custom_fields.value:"foo:bar"'],
|
||||
['(x)', 'custom_fields.value:"(x)"'],
|
||||
['2024*', 'custom_fields.value:"2024*"'],
|
||||
['a,b', 'custom_fields.value:"a,b"'],
|
||||
['OR', 'custom_fields.value:"OR"'],
|
||||
['or', 'custom_fields.value:or'],
|
||||
])(
|
||||
'quotes %j only when the grammar would read it as syntax',
|
||||
(value, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.CustomFieldValue,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
value
|
||||
)
|
||||
)
|
||||
).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the dotted names for custom fields', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
group(
|
||||
And,
|
||||
atom(
|
||||
AdvancedSearchField.CustomFieldName,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'status'
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.CustomFieldValue,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'paid'
|
||||
)
|
||||
)
|
||||
)
|
||||
).toBe('custom_fields.name:"status" AND custom_fields.value:paid')
|
||||
})
|
||||
|
||||
it('uses the dotted names for notes', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
group(
|
||||
And,
|
||||
atom(
|
||||
AdvancedSearchField.NoteText,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'call'
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.NoteAuthor,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'alice'
|
||||
)
|
||||
)
|
||||
)
|
||||
).toBe('notes.note:call AND notes.user:alice')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[AdvancedSearchOperator.AllWords, ''],
|
||||
[AdvancedSearchOperator.AllWords, ' '],
|
||||
[AdvancedSearchOperator.AllWords, '!! --'],
|
||||
[AdvancedSearchOperator.Phrase, '""'],
|
||||
[AdvancedSearchOperator.StartsWith, 'two words'],
|
||||
[AdvancedSearchOperator.StartsWith, '***'],
|
||||
[AdvancedSearchOperator.StartsWith, undefined],
|
||||
])('leaves out %s %j', (operator, value) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Title, operator, value)
|
||||
)
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checksum', () => {
|
||||
it('lowercases the prefix', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.Checksum,
|
||||
AdvancedSearchOperator.StartsWith,
|
||||
'9F86D081'
|
||||
)
|
||||
)
|
||||
).toBe('checksum:9f86d081*')
|
||||
})
|
||||
})
|
||||
|
||||
describe('number fields', () => {
|
||||
it.each([
|
||||
[AdvancedSearchOperator.Equals, '42', undefined, 'asn:42'],
|
||||
[AdvancedSearchOperator.AtLeast, '50', undefined, 'asn:[50 to]'],
|
||||
[AdvancedSearchOperator.AtMost, '50', undefined, 'asn:[to 50]'],
|
||||
[AdvancedSearchOperator.Between, '50', '150', 'asn:[50 to 150]'],
|
||||
[AdvancedSearchOperator.Equals, '4.2', undefined, ''],
|
||||
[AdvancedSearchOperator.Equals, '-1', undefined, ''],
|
||||
[AdvancedSearchOperator.Equals, '2024-01-01', undefined, ''],
|
||||
[AdvancedSearchOperator.Between, '50', '', ''],
|
||||
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.ASN, operator, value, { valueTo })
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('date fields', () => {
|
||||
it.each([
|
||||
['today', 'added:today'],
|
||||
['previous month', 'added:"previous month"'],
|
||||
['last tuesday', ''],
|
||||
['', ''],
|
||||
])('keyword %j writes %j', (value, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchOperator.DateKeyword,
|
||||
value
|
||||
)
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1', AdvancedSearchDateUnit.Day, 'added:[-1 day to now]'],
|
||||
['3', AdvancedSearchDateUnit.Month, 'added:[-3 months to now]'],
|
||||
['2', AdvancedSearchDateUnit.Week, 'added:[-2 weeks to now]'],
|
||||
['0', AdvancedSearchDateUnit.Year, ''],
|
||||
['1.5', AdvancedSearchDateUnit.Year, ''],
|
||||
['3', undefined, ''],
|
||||
['3', 'fortnight' as AdvancedSearchDateUnit, ''],
|
||||
])('within the last %j %j writes %j', (value, unit, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
value,
|
||||
{
|
||||
unit,
|
||||
}
|
||||
)
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
AdvancedSearchOperator.AtLeast,
|
||||
'2024-01-01',
|
||||
undefined,
|
||||
'created:[2024-01-01 to]',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.AtMost,
|
||||
'2024-01-01',
|
||||
undefined,
|
||||
'created:[to 2024-01-01]',
|
||||
],
|
||||
[
|
||||
AdvancedSearchOperator.Between,
|
||||
'2024-01-01',
|
||||
'2024-03-31',
|
||||
'created:[2024-01-01 to 2024-03-31]',
|
||||
],
|
||||
[AdvancedSearchOperator.AtLeast, '2024', undefined, ''],
|
||||
[AdvancedSearchOperator.Between, '2024-01-01', 'now', ''],
|
||||
])('%s %j %j writes %j', (operator, value, valueTo, expected) => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
atom(AdvancedSearchField.Created, operator, value, { valueTo })
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groups', () => {
|
||||
const invoice = atom(
|
||||
AdvancedSearchField.Content,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'invoice'
|
||||
)
|
||||
const letter = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'letter'
|
||||
)
|
||||
const paid = atom(
|
||||
AdvancedSearchField.Tag,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'paid'
|
||||
)
|
||||
const twoWords = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'a b'
|
||||
)
|
||||
const anyWords = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AnyWord,
|
||||
'a b'
|
||||
)
|
||||
const empty = atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
''
|
||||
)
|
||||
|
||||
it.each([
|
||||
['an empty group', group(And), ''],
|
||||
['a group of empty atoms', group(Or, empty, group(And, empty)), ''],
|
||||
[
|
||||
'a single child without parentheses',
|
||||
group(Or, invoice),
|
||||
'content:invoice',
|
||||
],
|
||||
['All', group(And, invoice, letter), 'content:invoice AND title:letter'],
|
||||
['Any', group(Or, invoice, letter), 'content:invoice OR title:letter'],
|
||||
[
|
||||
'skipped empty atoms',
|
||||
group(And, empty, invoice, empty),
|
||||
'content:invoice',
|
||||
],
|
||||
['Not with one child', group(Not, paid), 'NOT tag:paid'],
|
||||
[
|
||||
'Not as none of',
|
||||
group(Not, paid, letter),
|
||||
'NOT (tag:paid OR title:letter)',
|
||||
],
|
||||
[
|
||||
'Not with a compound child',
|
||||
group(Not, twoWords),
|
||||
'NOT (title:a AND title:b)',
|
||||
],
|
||||
[
|
||||
'Not inside All',
|
||||
group(And, invoice, group(Not, paid)),
|
||||
'content:invoice AND NOT tag:paid',
|
||||
],
|
||||
[
|
||||
'Not inside Any',
|
||||
group(Or, invoice, group(Not, paid)),
|
||||
'content:invoice OR NOT tag:paid',
|
||||
],
|
||||
[
|
||||
'Any inside All',
|
||||
group(And, invoice, group(Or, letter, paid)),
|
||||
'content:invoice AND (title:letter OR tag:paid)',
|
||||
],
|
||||
[
|
||||
'All inside Any',
|
||||
group(Or, invoice, group(And, letter, paid)),
|
||||
'content:invoice OR (title:letter AND tag:paid)',
|
||||
],
|
||||
[
|
||||
'All inside All flattened',
|
||||
group(And, invoice, group(And, letter, paid)),
|
||||
'content:invoice AND title:letter AND tag:paid',
|
||||
],
|
||||
[
|
||||
'an all-words atom inside Any',
|
||||
group(Or, invoice, twoWords),
|
||||
'content:invoice OR (title:a AND title:b)',
|
||||
],
|
||||
[
|
||||
'an any-word atom inside All',
|
||||
group(And, invoice, anyWords),
|
||||
'content:invoice AND (title:a OR title:b)',
|
||||
],
|
||||
[
|
||||
'an all-words atom inside Not with siblings',
|
||||
group(Not, paid, twoWords),
|
||||
'NOT (tag:paid OR (title:a AND title:b))',
|
||||
],
|
||||
])('writes %s', (_, tree, expected) => {
|
||||
expect(serializeAdvancedSearchQuery(tree)).toBe(expected)
|
||||
})
|
||||
|
||||
it('writes the mockup example', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
group(
|
||||
And,
|
||||
invoice,
|
||||
group(
|
||||
Or,
|
||||
atom(
|
||||
AdvancedSearchField.Correspondent,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'acme corp'
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.Content,
|
||||
AdvancedSearchOperator.Phrase,
|
||||
'acme corporation'
|
||||
)
|
||||
),
|
||||
atom(
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
'3',
|
||||
{
|
||||
unit: AdvancedSearchDateUnit.Month,
|
||||
}
|
||||
),
|
||||
group(Not, paid)
|
||||
)
|
||||
)
|
||||
).toBe(
|
||||
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAdvancedSearchQuery', () => {
|
||||
const canonical = [
|
||||
'title:invoice',
|
||||
'title:invoice AND title:unpaid',
|
||||
'content:invoice OR content:receipt',
|
||||
'a AND b',
|
||||
'title:"quick brown fox"',
|
||||
'title:invoi*',
|
||||
"custom_fields.value:O'Brien",
|
||||
'custom_fields.value:"foo:bar"',
|
||||
'custom_fields.name:"status" AND custom_fields.value:paid',
|
||||
'notes.note:call AND notes.user:alice',
|
||||
'checksum:9f86d081*',
|
||||
'asn:42',
|
||||
'asn:[50 to 150]',
|
||||
'asn:[50 to]',
|
||||
'asn:[to 50]',
|
||||
'page_count:[10 to]',
|
||||
'added:today',
|
||||
'added:"previous month"',
|
||||
'added:[-1 day to now]',
|
||||
'added:[-3 months to now]',
|
||||
'created:[2024-01-01 to 2024-03-31]',
|
||||
'created:[2024-01-01 to]',
|
||||
'created:[to 2024-01-01]',
|
||||
'NOT tag:paid',
|
||||
'NOT (tag:paid OR title:letter)',
|
||||
'NOT (title:a AND title:b)',
|
||||
'content:invoice OR NOT tag:paid',
|
||||
'content:invoice AND (title:letter OR tag:paid)',
|
||||
'content:invoice OR (title:letter AND tag:paid)',
|
||||
'content:invoice AND (correspondent:"acme corp" OR content:"acme corporation") AND added:[-3 months to now] AND NOT tag:paid',
|
||||
]
|
||||
|
||||
it.each(canonical)('reads back %s unchanged', (query) => {
|
||||
const tree = parseAdvancedSearchQuery(query)
|
||||
expect(tree).not.toBeNull()
|
||||
expect(serializeAdvancedSearchQuery(tree)).toBe(query)
|
||||
})
|
||||
|
||||
it('reads surrounding whitespace', () => {
|
||||
expect(
|
||||
serializeAdvancedSearchQuery(
|
||||
parseAdvancedSearchQuery(' title:invoice ')
|
||||
)
|
||||
).toBe('title:invoice')
|
||||
})
|
||||
|
||||
it('puts the words of one condition back together', () => {
|
||||
expect(parseAdvancedSearchQuery('title:invoice AND title:unpaid')).toEqual(
|
||||
group(
|
||||
And,
|
||||
atom(
|
||||
AdvancedSearchField.Title,
|
||||
AdvancedSearchOperator.AllWords,
|
||||
'invoice unpaid'
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps words of different fields apart', () => {
|
||||
expect(parseAdvancedSearchQuery('title:a OR content:b')).toEqual(
|
||||
group(
|
||||
Or,
|
||||
atom(AdvancedSearchField.Title, AdvancedSearchOperator.AllWords, 'a'),
|
||||
atom(AdvancedSearchField.Content, AdvancedSearchOperator.AllWords, 'b')
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a range as its condition', () => {
|
||||
expect(parseAdvancedSearchQuery('added:[-3 months to now]')).toEqual(
|
||||
group(
|
||||
And,
|
||||
atom(
|
||||
AdvancedSearchField.Added,
|
||||
AdvancedSearchOperator.WithinLast,
|
||||
'3',
|
||||
{
|
||||
unit: AdvancedSearchDateUnit.Month,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['', 'nothing'],
|
||||
[' ', 'whitespace'],
|
||||
['type:invoice', 'a field alias'],
|
||||
['notes:call', 'a bare notes prefix'],
|
||||
['unknown:x', 'an unknown field'],
|
||||
['title:a AND content:b OR title:c', 'AND and OR mixed at one level'],
|
||||
['title:a b', 'an implicit AND'],
|
||||
['title:"unterminated', 'an unterminated phrase'],
|
||||
['asn:[50 to', 'an unterminated range'],
|
||||
['title:a*b', 'a wildcard in the middle'],
|
||||
['title:invoice^2', 'a boost'],
|
||||
['asn:[50 to abc]', 'a non-numeric bound'],
|
||||
['asn:invoice', 'a word on a number field'],
|
||||
['created:[2024 to 2025]', 'a year-only date range'],
|
||||
['added:"last tuesday"', 'a date keyword paperless does not have'],
|
||||
['title:[a to b]', 'a range on a text field'],
|
||||
['checksum:9f86d081', 'a checksum without a wildcard'],
|
||||
['(title:invoice)', 'parentheses the editor would not write'],
|
||||
['title:invoice AND', 'a trailing operator'],
|
||||
['title:"a"b', 'a value running into the next'],
|
||||
['ADDED:today', 'an uppercase field name'],
|
||||
])('leaves %s alone, having %s', (query) => {
|
||||
expect(parseAdvancedSearchQuery(query)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,500 @@
|
||||
import {
|
||||
ADVANCED_SEARCH_DATE_KEYWORDS,
|
||||
ADVANCED_SEARCH_FIELD_KINDS,
|
||||
AdvancedSearchDateUnit,
|
||||
AdvancedSearchField,
|
||||
AdvancedSearchFieldKind,
|
||||
AdvancedSearchLogicalOperator,
|
||||
AdvancedSearchOperator,
|
||||
AdvancedSearchQueryAtom,
|
||||
AdvancedSearchQueryElement,
|
||||
AdvancedSearchQueryElementType,
|
||||
AdvancedSearchQueryGroup,
|
||||
} from '../data/advanced-search-query'
|
||||
|
||||
// Anything the query grammar would read as syntax rather than as a word
|
||||
const SYNTAX_CHARS = /[\s():"[\]*?,{}^~\\]/
|
||||
const SYNTAX_CHARS_GLOBAL = new RegExp(SYNTAX_CHARS, 'g')
|
||||
// A word wrapped in single quotes is also syntax, an apostrophe inside one is not
|
||||
const EDGE_SINGLE_QUOTE = /^'|'$/
|
||||
const RESERVED_WORDS = /^(AND|OR|NOT|TO)$/
|
||||
const HAS_WORD_CHAR = /[\p{L}\p{N}]/u
|
||||
const WHOLE_NUMBER = /^\d+$/
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
interface Serialized {
|
||||
text: string
|
||||
// The operator joining the top level of `text`, null when it is self-delimiting
|
||||
join:
|
||||
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or | null
|
||||
}
|
||||
|
||||
const prefix = (field: AdvancedSearchField) => (field ? `${field}:` : '')
|
||||
|
||||
const quote = (text: string) => `"${text.replace(/"/g, '')}"`
|
||||
|
||||
const words = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => HAS_WORD_CHAR.test(word))
|
||||
|
||||
const word = (w: string) =>
|
||||
SYNTAX_CHARS.test(w) || EDGE_SINGLE_QUOTE.test(w) || RESERVED_WORDS.test(w)
|
||||
? quote(w)
|
||||
: w
|
||||
|
||||
const atomic = (text: string): Serialized => ({ text, join: null })
|
||||
|
||||
function serializeWords(
|
||||
atom: AdvancedSearchQueryAtom,
|
||||
join: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
): Serialized {
|
||||
// A field applies only to the word right after it, so repeat it per word
|
||||
const terms = words(atom.value ?? '').map(
|
||||
(w) => `${prefix(atom.field)}${word(w)}`
|
||||
)
|
||||
if (terms.length === 0) return null
|
||||
if (terms.length === 1) return atomic(terms[0])
|
||||
return { text: terms.join(` ${join} `), join }
|
||||
}
|
||||
|
||||
function serializeRange(
|
||||
atom: AdvancedSearchQueryAtom,
|
||||
isValid: (v: string) => boolean
|
||||
): Serialized {
|
||||
const lo = atom.value?.trim() ?? ''
|
||||
const hi = atom.valueTo?.trim() ?? ''
|
||||
const field = prefix(atom.field)
|
||||
switch (atom.operator) {
|
||||
case AdvancedSearchOperator.Equals:
|
||||
return isValid(lo) ? atomic(`${field}${lo}`) : null
|
||||
case AdvancedSearchOperator.AtLeast:
|
||||
return isValid(lo) ? atomic(`${field}[${lo} to]`) : null
|
||||
case AdvancedSearchOperator.AtMost:
|
||||
return isValid(lo) ? atomic(`${field}[to ${lo}]`) : null
|
||||
case AdvancedSearchOperator.Between:
|
||||
return isValid(lo) && isValid(hi)
|
||||
? atomic(`${field}[${lo} to ${hi}]`)
|
||||
: null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function serializeAtom(atom: AdvancedSearchQueryAtom): Serialized {
|
||||
const field = prefix(atom.field)
|
||||
const value = atom.value?.trim() ?? ''
|
||||
|
||||
switch (atom.operator) {
|
||||
case AdvancedSearchOperator.AllWords:
|
||||
return serializeWords(atom, AdvancedSearchLogicalOperator.And)
|
||||
case AdvancedSearchOperator.AnyWord:
|
||||
return serializeWords(atom, AdvancedSearchLogicalOperator.Or)
|
||||
case AdvancedSearchOperator.Phrase:
|
||||
return HAS_WORD_CHAR.test(value)
|
||||
? atomic(`${field}${quote(value)}`)
|
||||
: null
|
||||
case AdvancedSearchOperator.StartsWith: {
|
||||
if (/\s/.test(value)) return null
|
||||
let stem = value.replace(SYNTAX_CHARS_GLOBAL, '')
|
||||
if (!HAS_WORD_CHAR.test(stem)) return null
|
||||
// checksum is indexed as-is, in lowercase
|
||||
if (atom.field === AdvancedSearchField.Checksum) {
|
||||
stem = stem.toLowerCase()
|
||||
}
|
||||
return atomic(`${field}${stem}*`)
|
||||
}
|
||||
case AdvancedSearchOperator.DateKeyword:
|
||||
return (ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]).includes(
|
||||
value
|
||||
)
|
||||
? atomic(`${field}${word(value)}`)
|
||||
: null
|
||||
case AdvancedSearchOperator.WithinLast: {
|
||||
const amount = parseInt(value, 10)
|
||||
const units = Object.values(AdvancedSearchDateUnit) as string[]
|
||||
if (!WHOLE_NUMBER.test(value) || amount < 1) return null
|
||||
if (!units.includes(atom.unit)) return null
|
||||
const unit = amount === 1 ? atom.unit : `${atom.unit}s`
|
||||
return atomic(`${field}[-${amount} ${unit} to now]`)
|
||||
}
|
||||
case AdvancedSearchOperator.Equals:
|
||||
case AdvancedSearchOperator.AtLeast:
|
||||
case AdvancedSearchOperator.AtMost:
|
||||
case AdvancedSearchOperator.Between:
|
||||
return serializeRange(
|
||||
atom,
|
||||
ADVANCED_SEARCH_FIELD_KINDS[atom.field] === AdvancedSearchFieldKind.Date
|
||||
? (v) => ISO_DATE.test(v)
|
||||
: (v) => WHOLE_NUMBER.test(v)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function wrap(
|
||||
child: Serialized,
|
||||
parentJoin: AdvancedSearchLogicalOperator
|
||||
): string {
|
||||
return child.join === null || child.join === parentJoin
|
||||
? child.text
|
||||
: `(${child.text})`
|
||||
}
|
||||
|
||||
function serializeGroup(group: AdvancedSearchQueryGroup): Serialized {
|
||||
const children = group.children.map(serializeElement).filter(Boolean)
|
||||
if (children.length === 0) return null
|
||||
|
||||
if (group.operator === AdvancedSearchLogicalOperator.Not) {
|
||||
// A Not group matches documents matching none of its children
|
||||
if (children.length === 1 && children[0].join === null) {
|
||||
return atomic(`NOT ${children[0].text}`)
|
||||
}
|
||||
const inner =
|
||||
children.length === 1
|
||||
? children[0].text
|
||||
: children
|
||||
.map((c) => wrap(c, AdvancedSearchLogicalOperator.Or))
|
||||
.join(' OR ')
|
||||
return atomic(`NOT (${inner})`)
|
||||
}
|
||||
|
||||
if (children.length === 1) return children[0]
|
||||
return {
|
||||
text: children
|
||||
.map((c) => wrap(c, group.operator))
|
||||
.join(` ${group.operator} `),
|
||||
join: group.operator,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeElement(element: AdvancedSearchQueryElement): Serialized {
|
||||
return element.type === AdvancedSearchQueryElementType.Group
|
||||
? serializeGroup(element)
|
||||
: serializeAtom(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an editor tree as a full-text query. Atoms that are not filled in
|
||||
* (or not valid) are left out, and empty groups with them.
|
||||
*/
|
||||
export function serializeAdvancedSearchQuery(
|
||||
element: AdvancedSearchQueryElement
|
||||
): string {
|
||||
return serializeElement(element)?.text ?? ''
|
||||
}
|
||||
|
||||
// --- Reading a query back into the editor --------------------------------
|
||||
//
|
||||
// Deliberately narrow: this reads the forms serializeAdvancedSearchQuery
|
||||
// writes, and nothing else. A query it cannot read is not a failure, it just
|
||||
// stays text, so there is never a lossy or surprising conversion. The final
|
||||
// round-trip check below is what holds that promise: a tree is only returned
|
||||
// when writing it out again reproduces the query exactly.
|
||||
|
||||
const RELATIVE_BOUND = /^-(\d+) (day|week|month|year)s?$/
|
||||
const FIELD_PREFIX = /^([a-z_]+(?:\.[a-z_]+)?):/
|
||||
const KEYWORD_TOKEN = /^(AND|OR|NOT)(?=[\s(]|$)/
|
||||
// Either bound may be missing: [50 to 150], [50 to], [to 50]
|
||||
const RANGE_BOUNDS = /^(?:(.+?) )?to(?: (.+))?$/
|
||||
const TRAILING_WILDCARD = /^([^*?]+)\*$/
|
||||
|
||||
class UnreadableQuery extends Error {}
|
||||
|
||||
interface Token {
|
||||
type: 'term' | 'AND' | 'OR' | 'NOT' | '(' | ')'
|
||||
field?: string
|
||||
value?: string
|
||||
quoted?: boolean
|
||||
range?: boolean
|
||||
}
|
||||
|
||||
// A parsed element, plus what it takes to merge the per-word terms the
|
||||
// serializer writes for "contains all words" back into a single condition
|
||||
interface Parsed {
|
||||
element: AdvancedSearchQueryElement
|
||||
word?: { field: AdvancedSearchField; text: string }
|
||||
}
|
||||
|
||||
function tokenize(query: string): Token[] {
|
||||
const tokens: Token[] = []
|
||||
let i = 0
|
||||
while (i < query.length) {
|
||||
const rest = query.slice(i)
|
||||
if (/^\s/.test(rest)) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (rest[0] === '(' || rest[0] === ')') {
|
||||
tokens.push({ type: rest[0] as '(' | ')' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const keyword = KEYWORD_TOKEN.exec(rest)
|
||||
if (keyword) {
|
||||
tokens.push({ type: keyword[1] as 'AND' | 'OR' | 'NOT' })
|
||||
i += keyword[1].length
|
||||
continue
|
||||
}
|
||||
const fieldMatch = FIELD_PREFIX.exec(rest)
|
||||
const field = fieldMatch ? fieldMatch[1] : ''
|
||||
i += fieldMatch ? fieldMatch[0].length : 0
|
||||
|
||||
const value = query.slice(i)
|
||||
if (value.startsWith('"')) {
|
||||
const end = query.indexOf('"', i + 1)
|
||||
if (end < 0) throw new UnreadableQuery()
|
||||
tokens.push({
|
||||
type: 'term',
|
||||
field,
|
||||
value: query.slice(i + 1, end),
|
||||
quoted: true,
|
||||
})
|
||||
i = end + 1
|
||||
} else if (value.startsWith('[')) {
|
||||
const end = query.indexOf(']', i + 1)
|
||||
if (end < 0) throw new UnreadableQuery()
|
||||
tokens.push({
|
||||
type: 'term',
|
||||
field,
|
||||
value: query.slice(i + 1, end),
|
||||
range: true,
|
||||
})
|
||||
i = end + 1
|
||||
} else {
|
||||
const bare = /^[^\s()]+/.exec(value)
|
||||
if (!bare) throw new UnreadableQuery()
|
||||
tokens.push({ type: 'term', field, value: bare[0] })
|
||||
i += bare[0].length
|
||||
}
|
||||
// Nothing may run on directly after a value, e.g. title:"a"b
|
||||
if (i < query.length && !/[\s)]/.test(query[i])) throw new UnreadableQuery()
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function resolveField(name: string): AdvancedSearchField {
|
||||
const fields = Object.values(AdvancedSearchField) as string[]
|
||||
// Aliases (type:, path:, notes:) are left to the text box on purpose:
|
||||
// reading one would mean rewriting the user's query as it was read
|
||||
if (!fields.includes(name)) throw new UnreadableQuery()
|
||||
return name as AdvancedSearchField
|
||||
}
|
||||
|
||||
function atomFrom(
|
||||
field: AdvancedSearchField,
|
||||
operator: AdvancedSearchOperator,
|
||||
value: string,
|
||||
extra: Partial<AdvancedSearchQueryAtom> = {}
|
||||
): AdvancedSearchQueryAtom {
|
||||
return {
|
||||
type: AdvancedSearchQueryElementType.Atom,
|
||||
field,
|
||||
operator,
|
||||
value,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function parseRange(
|
||||
field: AdvancedSearchField,
|
||||
kind: AdvancedSearchFieldKind,
|
||||
body: string
|
||||
): AdvancedSearchQueryAtom {
|
||||
const bounds = RANGE_BOUNDS.exec(body)
|
||||
if (!bounds) throw new UnreadableQuery()
|
||||
const lo = bounds[1] ?? ''
|
||||
const hi = bounds[2] ?? ''
|
||||
|
||||
if (kind === AdvancedSearchFieldKind.Date) {
|
||||
const relative = RELATIVE_BOUND.exec(lo)
|
||||
if (relative && hi === 'now') {
|
||||
return atomFrom(field, AdvancedSearchOperator.WithinLast, relative[1], {
|
||||
unit: relative[2] as AdvancedSearchDateUnit,
|
||||
})
|
||||
}
|
||||
} else if (kind !== AdvancedSearchFieldKind.Number) {
|
||||
throw new UnreadableQuery()
|
||||
}
|
||||
|
||||
const isValid =
|
||||
kind === AdvancedSearchFieldKind.Date
|
||||
? (v: string) => ISO_DATE.test(v)
|
||||
: (v: string) => WHOLE_NUMBER.test(v)
|
||||
|
||||
if (lo && hi) {
|
||||
if (!isValid(lo) || !isValid(hi)) throw new UnreadableQuery()
|
||||
return atomFrom(field, AdvancedSearchOperator.Between, lo, { valueTo: hi })
|
||||
}
|
||||
if (lo && isValid(lo))
|
||||
return atomFrom(field, AdvancedSearchOperator.AtLeast, lo)
|
||||
if (hi && isValid(hi))
|
||||
return atomFrom(field, AdvancedSearchOperator.AtMost, hi)
|
||||
throw new UnreadableQuery()
|
||||
}
|
||||
|
||||
function parseTerm(token: Token): Parsed {
|
||||
const field = resolveField(token.field)
|
||||
const kind = ADVANCED_SEARCH_FIELD_KINDS[field]
|
||||
const value = token.value
|
||||
const isKeyword = (
|
||||
ADVANCED_SEARCH_DATE_KEYWORDS as readonly string[]
|
||||
).includes(value)
|
||||
|
||||
if (token.range) {
|
||||
return { element: parseRange(field, kind, value) }
|
||||
}
|
||||
|
||||
if (kind === AdvancedSearchFieldKind.Date) {
|
||||
if (!isKeyword) throw new UnreadableQuery()
|
||||
return {
|
||||
element: atomFrom(field, AdvancedSearchOperator.DateKeyword, value),
|
||||
}
|
||||
}
|
||||
|
||||
if (token.quoted) {
|
||||
if (kind !== AdvancedSearchFieldKind.Text) throw new UnreadableQuery()
|
||||
return { element: atomFrom(field, AdvancedSearchOperator.Phrase, value) }
|
||||
}
|
||||
|
||||
const wildcard = TRAILING_WILDCARD.exec(value)
|
||||
if (wildcard) {
|
||||
if (kind === AdvancedSearchFieldKind.Number) throw new UnreadableQuery()
|
||||
return {
|
||||
element: atomFrom(field, AdvancedSearchOperator.StartsWith, wildcard[1]),
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === AdvancedSearchFieldKind.Number) {
|
||||
if (!WHOLE_NUMBER.test(value)) throw new UnreadableQuery()
|
||||
return { element: atomFrom(field, AdvancedSearchOperator.Equals, value) }
|
||||
}
|
||||
// A checksum is only ever searched by its first characters
|
||||
if (kind === AdvancedSearchFieldKind.Checksum) throw new UnreadableQuery()
|
||||
|
||||
return {
|
||||
element: atomFrom(field, AdvancedSearchOperator.AllWords, value),
|
||||
word: { field, text: value },
|
||||
}
|
||||
}
|
||||
|
||||
// The serializer repeats the field for every word, because a field applies
|
||||
// only to the word after it. Put those back together into one condition.
|
||||
function mergeWords(
|
||||
parts: Parsed[],
|
||||
operator: AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
): AdvancedSearchQueryElement[] {
|
||||
const merged: AdvancedSearchQueryElement[] = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const run = [parts[i]]
|
||||
while (
|
||||
parts[i].word &&
|
||||
parts[i + 1]?.word &&
|
||||
parts[i + 1].word.field === parts[i].word.field
|
||||
) {
|
||||
run.push(parts[++i])
|
||||
}
|
||||
if (run.length === 1) {
|
||||
merged.push(run[0].element)
|
||||
continue
|
||||
}
|
||||
merged.push(
|
||||
atomFrom(
|
||||
run[0].word.field,
|
||||
operator === AdvancedSearchLogicalOperator.And
|
||||
? AdvancedSearchOperator.AllWords
|
||||
: AdvancedSearchOperator.AnyWord,
|
||||
run.map((part) => part.word.text).join(' ')
|
||||
)
|
||||
)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
interface Cursor {
|
||||
tokens: Token[]
|
||||
at: number
|
||||
}
|
||||
|
||||
function parseExpression(cursor: Cursor): Parsed {
|
||||
const parts: Parsed[] = [parseOperand(cursor)]
|
||||
let operator:
|
||||
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
while (
|
||||
cursor.tokens[cursor.at]?.type === 'AND' ||
|
||||
cursor.tokens[cursor.at]?.type === 'OR'
|
||||
) {
|
||||
const next = cursor.tokens[cursor.at++].type as
|
||||
AdvancedSearchLogicalOperator.And | AdvancedSearchLogicalOperator.Or
|
||||
// One level mixing AND and OR is never something the editor wrote
|
||||
if (operator && next !== operator) throw new UnreadableQuery()
|
||||
operator = next
|
||||
parts.push(parseOperand(cursor))
|
||||
}
|
||||
if (parts.length === 1) return parts[0]
|
||||
|
||||
const children = mergeWords(parts, operator)
|
||||
if (children.length === 1) return { element: children[0] }
|
||||
return {
|
||||
element: {
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator,
|
||||
children,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseOperand(cursor: Cursor): Parsed {
|
||||
const token = cursor.tokens[cursor.at++]
|
||||
if (!token) throw new UnreadableQuery()
|
||||
|
||||
if (token.type === 'NOT') {
|
||||
const child = parseOperand(cursor)
|
||||
return {
|
||||
element: {
|
||||
type: AdvancedSearchQueryElementType.Group,
|
||||
operator: AdvancedSearchLogicalOperator.Not,
|
||||
children: [child.element],
|
||||
},
|
||||
}
|
||||
}
|
||||
if (token.type === '(') {
|
||||
const inner = parseExpression(cursor)
|
||||
if (cursor.tokens[cursor.at++]?.type !== ')') throw new UnreadableQuery()
|
||||
return { element: inner.element }
|
||||
}
|
||||
if (token.type !== 'term') throw new UnreadableQuery()
|
||||
return parseTerm(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a query the editor could have written back into an editor tree, or
|
||||
* returns null when the editor cannot show it, in which case the query stays
|
||||
* text. Never returns a tree that would be written back differently.
|
||||
*/
|
||||
export function parseAdvancedSearchQuery(
|
||||
query: string
|
||||
): AdvancedSearchQueryGroup | null {
|
||||
const trimmed = query?.trim() ?? ''
|
||||
if (!trimmed) return null
|
||||
|
||||
let parsed: Parsed
|
||||
try {
|
||||
const cursor: Cursor = { tokens: tokenize(trimmed), at: 0 }
|
||||
parsed = parseExpression(cursor)
|
||||
if (cursor.at !== cursor.tokens.length) throw new UnreadableQuery()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const root =
|
||||
parsed.element.type === AdvancedSearchQueryElementType.Group
|
||||
? parsed.element
|
||||
: {
|
||||
type: AdvancedSearchQueryElementType.Group as const,
|
||||
operator: AdvancedSearchLogicalOperator.And,
|
||||
children: [parsed.element],
|
||||
}
|
||||
|
||||
return serializeAdvancedSearchQuery(root) === trimmed ? root : null
|
||||
}
|
||||
@@ -148,6 +148,7 @@ import {
|
||||
send,
|
||||
shop,
|
||||
slashCircle,
|
||||
sliders,
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
@@ -397,6 +398,7 @@ const icons = {
|
||||
send,
|
||||
slashCircle,
|
||||
shop,
|
||||
sliders,
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
|
||||
@@ -337,4 +337,13 @@ def open_or_rebuild_index(index_dir: Path | None = None) -> tantivy.Index:
|
||||
idx = tantivy.Index(build_schema(), path=str(index_dir))
|
||||
_write_sentinels(index_dir)
|
||||
return idx
|
||||
return tantivy.Index.open(str(index_dir))
|
||||
try:
|
||||
return tantivy.Index.open(str(index_dir))
|
||||
except ValueError:
|
||||
logger.exception(
|
||||
"Search index is corrupted or incomplete - rebuilding from scratch.",
|
||||
)
|
||||
wipe_index(index_dir)
|
||||
idx = tantivy.Index(build_schema(), path=str(index_dir))
|
||||
_write_sentinels(index_dir)
|
||||
return idx
|
||||
|
||||
@@ -21,9 +21,11 @@ import pytest
|
||||
import tantivy
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from documents.search._schema import SCHEMA_VERSION
|
||||
from documents.search._schema import build_schema
|
||||
from documents.search._schema import needs_rebuild
|
||||
from documents.search._schema import open_or_rebuild_index
|
||||
from documents.search._schema import schema_fingerprint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -176,3 +178,50 @@ class TestUpgradeFromReleasedV1Index:
|
||||
open_or_rebuild_index(released_v1_index)
|
||||
|
||||
assert needs_rebuild(released_v1_index) is False
|
||||
|
||||
|
||||
class TestCorruptedIndex:
|
||||
@pytest.fixture
|
||||
def current_index_missing_meta_json(self, tmp_path: Path) -> Path:
|
||||
"""A current-schema index directory whose meta.json has gone missing.
|
||||
|
||||
The sentinel matches the current schema, so needs_rebuild() reports
|
||||
False and open_or_rebuild_index() takes the plain Index.open() path.
|
||||
"""
|
||||
index_dir = tmp_path / "index"
|
||||
index_dir.mkdir()
|
||||
tantivy.Index(build_schema(), path=str(index_dir))
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"language": django_settings.SEARCH_LANGUAGE,
|
||||
"schema_fingerprint": schema_fingerprint(),
|
||||
},
|
||||
),
|
||||
)
|
||||
(index_dir / "meta.json").unlink()
|
||||
return index_dir
|
||||
|
||||
def test_missing_meta_json_is_rebuilt_instead_of_raising(
|
||||
self,
|
||||
current_index_missing_meta_json: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An index directory with a sentinel matching the current
|
||||
schema, but a missing/corrupted meta.json (e.g. an
|
||||
interrupted write left the directory in a torn state)
|
||||
WHEN:
|
||||
- open_or_rebuild_index() is called against it
|
||||
THEN:
|
||||
- It rebuilds the index instead of letting tantivy's
|
||||
ValueError propagate; before this, a corrupted index left
|
||||
every read, write, and `document_index reindex --if-needed`
|
||||
hard-failing indefinitely, since the sentinel already
|
||||
matched and reported the index as up to date
|
||||
"""
|
||||
open_or_rebuild_index(current_index_missing_meta_json)
|
||||
|
||||
assert (current_index_missing_meta_json / "meta.json").exists()
|
||||
tantivy.Index.open(str(current_index_missing_meta_json))
|
||||
|
||||
+35
-32
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from celery import Task
|
||||
from celery import shared_task
|
||||
from django.core.cache import cache
|
||||
|
||||
from documents.models import PaperlessTask
|
||||
from paperless_mail.mail import MailAccountHandler
|
||||
from paperless_mail.mail import MailError
|
||||
from paperless_mail.models import MailAccount
|
||||
@@ -11,43 +12,45 @@ from paperless_mail.models import MailRule
|
||||
|
||||
logger = logging.getLogger("paperless.mail.tasks")
|
||||
|
||||
# Cache-backed lock guarding overlapping mail-account processing runs; unlike
|
||||
# a PaperlessTask row, it self-heals if the owning worker dies mid-run.
|
||||
MAIL_FETCH_LOCK_KEY: Final = "paperless_mail_fetch_lock"
|
||||
# Ceiling on how long a run may hold the lock; renewed after each account.
|
||||
MAIL_FETCH_LOCK_TTL: Final = 30 * 60
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def process_mail_accounts(self: Task, account_ids: list[int] | None = None) -> str:
|
||||
# A scheduled check can still be running (or queued) when the next one
|
||||
# ProcessedMail dedup only records a message once its
|
||||
# handling has finished, so an overlapping run can still pick up the same
|
||||
# not-yet-recorded message. Skip outright rather than race it.
|
||||
other_mail_fetch_running = (
|
||||
PaperlessTask.objects.filter(
|
||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
||||
status__in=[PaperlessTask.Status.PENDING, PaperlessTask.Status.STARTED],
|
||||
)
|
||||
.exclude(task_id=self.request.id)
|
||||
.exists()
|
||||
)
|
||||
if other_mail_fetch_running:
|
||||
if not cache.add(MAIL_FETCH_LOCK_KEY, self.request.id, timeout=MAIL_FETCH_LOCK_TTL):
|
||||
logger.info(
|
||||
"Mail account processing is already running; skipping this run.",
|
||||
)
|
||||
return "Skipped: mail account processing already in progress."
|
||||
|
||||
total_new_documents = 0
|
||||
accounts = (
|
||||
MailAccount.objects.filter(pk__in=account_ids)
|
||||
if account_ids
|
||||
else MailAccount.objects.all()
|
||||
)
|
||||
for account in accounts:
|
||||
if not MailRule.objects.filter(account=account, enabled=True).exists():
|
||||
logger.info(f"No rules enabled for account {account}. Skipping.")
|
||||
continue
|
||||
try:
|
||||
total_new_documents += MailAccountHandler().handle_mail_account(account)
|
||||
except MailError:
|
||||
logger.exception(f"Error while processing mail account {account}")
|
||||
try:
|
||||
total_new_documents = 0
|
||||
accounts = (
|
||||
MailAccount.objects.filter(pk__in=account_ids)
|
||||
if account_ids
|
||||
else MailAccount.objects.all()
|
||||
)
|
||||
for account in accounts:
|
||||
if not MailRule.objects.filter(account=account, enabled=True).exists():
|
||||
logger.info(f"No rules enabled for account {account}. Skipping.")
|
||||
continue
|
||||
try:
|
||||
total_new_documents += MailAccountHandler().handle_mail_account(
|
||||
account,
|
||||
)
|
||||
except MailError:
|
||||
logger.exception(f"Error while processing mail account {account}")
|
||||
# Renew the lock so a run still genuinely in progress doesn't
|
||||
# lose it to the TTL partway through a long account list.
|
||||
cache.touch(MAIL_FETCH_LOCK_KEY, MAIL_FETCH_LOCK_TTL)
|
||||
|
||||
if total_new_documents > 0:
|
||||
return f"Added {total_new_documents} document(s)."
|
||||
else:
|
||||
return "No new documents were added."
|
||||
if total_new_documents > 0:
|
||||
return f"Added {total_new_documents} document(s)."
|
||||
else:
|
||||
return "No new documents were added."
|
||||
finally:
|
||||
cache.delete(MAIL_FETCH_LOCK_KEY)
|
||||
|
||||
@@ -2,9 +2,8 @@ from typing import Final
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from django.core.cache import cache
|
||||
|
||||
from documents.models import PaperlessTask
|
||||
from documents.tests.factories import PaperlessTaskFactory
|
||||
from paperless_mail import tasks
|
||||
from paperless_mail.tests.factories import MailAccountFactory
|
||||
from paperless_mail.tests.factories import MailRuleFactory
|
||||
@@ -13,6 +12,13 @@ NO_DOCUMENTS_ADDED: Final = "No new documents were added."
|
||||
SKIPPED: Final = "Skipped: mail account processing already in progress."
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_mail_fetch_lock():
|
||||
cache.delete(tasks.MAIL_FETCH_LOCK_KEY)
|
||||
yield
|
||||
cache.delete(tasks.MAIL_FETCH_LOCK_KEY)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("account_with_rule")
|
||||
class TestProcessMailAccountsOverlap:
|
||||
@@ -22,50 +28,20 @@ class TestProcessMailAccountsOverlap:
|
||||
account = MailAccountFactory.create()
|
||||
MailRuleFactory.create(account=account, enabled=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected_result", "expected_call_count"),
|
||||
[
|
||||
pytest.param(
|
||||
PaperlessTask.Status.PENDING,
|
||||
SKIPPED,
|
||||
0,
|
||||
id="pending-task-blocks",
|
||||
),
|
||||
pytest.param(
|
||||
PaperlessTask.Status.STARTED,
|
||||
SKIPPED,
|
||||
0,
|
||||
id="started-task-blocks",
|
||||
),
|
||||
pytest.param(
|
||||
PaperlessTask.Status.SUCCESS,
|
||||
NO_DOCUMENTS_ADDED,
|
||||
1,
|
||||
id="finished-task-does-not-block",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_skips_only_while_another_mail_fetch_task_runs(
|
||||
def test_skips_while_lock_is_held(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
status: PaperlessTask.Status,
|
||||
expected_result: str,
|
||||
expected_call_count: int,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An enabled mail account with a rule
|
||||
- Another mail fetch task row in the given status
|
||||
- The mail fetch lock is already held by another run
|
||||
WHEN:
|
||||
- Mail accounts are processed
|
||||
THEN:
|
||||
- Processing is skipped only if that other task is pending or running
|
||||
- Processing is skipped and no account is handled
|
||||
"""
|
||||
PaperlessTaskFactory.create(
|
||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
||||
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
|
||||
status=status,
|
||||
)
|
||||
cache.add(tasks.MAIL_FETCH_LOCK_KEY, "other-task-id", timeout=60)
|
||||
|
||||
mocked_handle = mocker.patch.object(
|
||||
tasks.MailAccountHandler,
|
||||
@@ -75,21 +51,21 @@ class TestProcessMailAccountsOverlap:
|
||||
|
||||
result = tasks.process_mail_accounts()
|
||||
|
||||
assert mocked_handle.call_count == expected_call_count
|
||||
assert result == expected_result
|
||||
assert mocked_handle.call_count == 0
|
||||
assert result == SKIPPED
|
||||
|
||||
def test_runs_when_no_other_mail_fetch_task_exists(
|
||||
def test_runs_when_lock_is_free(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An enabled mail account with a rule
|
||||
- No other mail fetch task rows
|
||||
- No mail fetch run currently holds the lock
|
||||
WHEN:
|
||||
- Mail accounts are processed
|
||||
THEN:
|
||||
- The account is handled
|
||||
- The account is handled and the lock is released afterwards
|
||||
"""
|
||||
mocked_handle = mocker.patch.object(
|
||||
tasks.MailAccountHandler,
|
||||
@@ -101,34 +77,56 @@ class TestProcessMailAccountsOverlap:
|
||||
|
||||
mocked_handle.assert_called_once()
|
||||
assert result == NO_DOCUMENTS_ADDED
|
||||
assert cache.get(tasks.MAIL_FETCH_LOCK_KEY) is None
|
||||
|
||||
def test_does_not_skip_due_to_its_own_task_row(
|
||||
def test_releases_lock_even_if_handling_raises(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An enabled mail account with a rule
|
||||
- A running mail fetch task row belonging to this very task
|
||||
- Handling the account raises an unexpected exception
|
||||
WHEN:
|
||||
- Mail accounts are processed under that task id
|
||||
- Mail accounts are processed
|
||||
THEN:
|
||||
- The task does not skip itself and handles the account
|
||||
- The lock is still released so the next run is not blocked forever
|
||||
"""
|
||||
PaperlessTaskFactory.create(
|
||||
task_id="self-task-id",
|
||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
||||
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
|
||||
status=PaperlessTask.Status.STARTED,
|
||||
mocker.patch.object(
|
||||
tasks.MailAccountHandler,
|
||||
"handle_mail_account",
|
||||
side_effect=RuntimeError("boom"),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
tasks.process_mail_accounts()
|
||||
|
||||
assert cache.get(tasks.MAIL_FETCH_LOCK_KEY) is None
|
||||
|
||||
def test_recovers_after_lock_ttl_expires(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An enabled mail account with a rule
|
||||
- A lock left behind by a run that never released it (e.g. the
|
||||
worker was killed mid-run) but whose TTL has since expired
|
||||
WHEN:
|
||||
- Mail accounts are processed
|
||||
THEN:
|
||||
- The account is handled instead of being permanently blocked
|
||||
"""
|
||||
cache.add(tasks.MAIL_FETCH_LOCK_KEY, "dead-task-id", timeout=1)
|
||||
cache.delete(tasks.MAIL_FETCH_LOCK_KEY) # simulate TTL expiry
|
||||
|
||||
mocked_handle = mocker.patch.object(
|
||||
tasks.MailAccountHandler,
|
||||
"handle_mail_account",
|
||||
return_value=0,
|
||||
)
|
||||
|
||||
result = tasks.process_mail_accounts.apply(task_id="self-task-id").result
|
||||
result = tasks.process_mail_accounts()
|
||||
|
||||
mocked_handle.assert_called_once()
|
||||
assert result == NO_DOCUMENTS_ADDED
|
||||
|
||||
@@ -2802,14 +2802,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ocrmypdf"
|
||||
version = "17.11.0"
|
||||
version = "17.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fpdf2" },
|
||||
{ name = "img2pdf" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pdfminer-six" },
|
||||
{ name = "pi-heif" },
|
||||
{ name = "pikepdf" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pluggy" },
|
||||
@@ -2819,9 +2818,14 @@ dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "uharfbuzz" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/9a/f7d7c943e07b0a28cb0958229ac480f3c51b4169ad9df030477f103b298a/ocrmypdf-17.11.0.tar.gz", hash = "sha256:8e41cbba23bba9ce20bc557576211e1829d1beaef00183bacd227b4b9482af87", size = 7544564, upload-time = "2026-08-28T22:15:37.774Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/b5/2ffe5431eb7a8e4fb620d30351e06b8f97212800d23145979c459784bea0/ocrmypdf-17.12.1.tar.gz", hash = "sha256:aca73a23a80cf2f46a4c15040b0035203dbf74054b7a0c3a7ee93b05abc555a6", size = 7627547, upload-time = "2026-09-16T23:20:37.172Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/85/5a996c67cf44ecd3dfb6154202c7fecefb71b5ed52ec00b6a4de46a77e15/ocrmypdf-17.11.0-py3-none-any.whl", hash = "sha256:56cc82e49e22ba0fe77585d7fe788ab951b58ff1e7f75025eafa917e37efb2a9", size = 534921, upload-time = "2026-08-28T22:15:35.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ca/767bbc482a0ca81fc52b735a95f7fb3a9e2df286b753cc1853bd67361e3a/ocrmypdf-17.12.1-py3-none-any.whl", hash = "sha256:d1fc83dd2b567011f10afdd7612576f13c40183cbf1d867f898b64bb62d5f515", size = 541638, upload-time = "2026-09-16T23:20:34.553Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
heic = [
|
||||
{ name = "pillow-heif" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2909,7 +2913,7 @@ dependencies = [
|
||||
{ name = "llama-index-embeddings-openai-like" },
|
||||
{ name = "llama-index-llms-ollama" },
|
||||
{ name = "llama-index-llms-openai-like" },
|
||||
{ name = "ocrmypdf" },
|
||||
{ name = "ocrmypdf", extra = ["heic"] },
|
||||
{ name = "openai" },
|
||||
{ name = "pathvalidate" },
|
||||
{ name = "pdf2image" },
|
||||
@@ -3061,7 +3065,7 @@ requires-dist = [
|
||||
{ name = "llama-index-llms-ollama", specifier = ">=0.9.1" },
|
||||
{ name = "llama-index-llms-openai-like", specifier = ">=0.7.1" },
|
||||
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
|
||||
{ name = "ocrmypdf", specifier = ">=17.7,<17.12" },
|
||||
{ name = "ocrmypdf", extras = ["heic"], specifier = ">=17.12,<17.13" },
|
||||
{ name = "openai", specifier = ">=2.48" },
|
||||
{ name = "pathvalidate", specifier = "~=3.3.1" },
|
||||
{ name = "pdf2image", specifier = "~=1.17.0" },
|
||||
@@ -3201,51 +3205,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/8b/28c4eaec9d6b036a52cb44720408f26b1a143ca9bce76cc19e8f5de00ab4/pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9", size = 6592252, upload-time = "2026-01-07T13:29:10.742Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pi-heif"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/4a/4a18057a7b64254abdcc4f78d92503fc4f5b8fcc66da118ba87989111ee8/pi_heif-1.3.0.tar.gz", hash = "sha256:58151840d0d60507330654a466b06cbf7ca8fb3759eadb5234d70b4dc2bc990c", size = 17131114, upload-time = "2026-02-27T12:22:40.544Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/cb/2d351be04962981a0deb49d747bcc721a7ece8e2272aa156e9251511804b/pi_heif-1.3.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:183ebd05e88f8e1b69e603164619f6ca79031e26078a6795d2a81c6afff36190", size = 1047016, upload-time = "2026-02-27T12:21:48.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b3/2706ee866c6b461363f9fadb13a850a13a41f26952a52e6f50158cecd303/pi_heif-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3513f82c6039d00cd2f9b4e025f3742115f4802bb613d5bb50a8be62b256830a", size = 942338, upload-time = "2026-02-27T12:21:50.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/aa/1d6b92b782ac82ee8fa1f45f9dc8545866d738bad65f4f847ec7e53f246b/pi_heif-1.3.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39e84d64681adae5184f9376ce53d24b738831612dfa595f3efd4a4479393a7d", size = 1362499, upload-time = "2026-02-27T12:21:51.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e6/3a72c309807942ff3a944fa69eb8e47b52a8a5f9670ef3168bf18fb901bb/pi_heif-1.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de8d6705b4b118ef3fa140c8ebdc6981e9d77b6176cd1315ad5a9ac79549dbc3", size = 1490234, upload-time = "2026-02-27T12:21:52.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/09/cac5841d60f85d72272ed2d46fd37d4d0aabe5cf7db2823693db9e136e17/pi_heif-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:beb9dd91455a0bd2a3c7a5da66fb922efac86b24e45ddcde6dc4121909de6db0", size = 2345034, upload-time = "2026-02-27T12:21:53.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/89/2ff1499e18ad0160d6458a8113337beb8379a19ed54a38b699bf806b8b64/pi_heif-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d248f8b83009a980cd86719524ac3f7aa81427d998460479df36b8188326985", size = 2508816, upload-time = "2026-02-27T12:21:55.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/eb/4cb3f9789c2fff42ca0b40b0f57fc2a72f68cf62d54c836864cbc2032ec6/pi_heif-1.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:09cba007708cef90f95c15c382ece6f51e7ba33fb7fce96b54d786b02c9544e6", size = 1047196, upload-time = "2026-02-27T12:21:58.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/58/5aeeec1b7f0030902f9d96b168f26b7adaae0c8f758262bba0fa489036a4/pi_heif-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:04ce68ac95103d59b5c8fd25a8a51b40541e76d161d0eff834b9a9a3350fa401", size = 942299, upload-time = "2026-02-27T12:21:59.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/d706a05b96945aabb122932028f14c21524a81e9655f38fad40de9c096f1/pi_heif-1.3.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7aa8e52e3d736cc07dd0657f87c841be069954a7717ecd6fd24ca8afcc16f6cb", size = 1361016, upload-time = "2026-02-27T12:22:00.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/78/c7e141f8a9943d711a63d1f9c55b4f69b6cad0718d8c80e3a65ca3d42a61/pi_heif-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ed464485f7df1d1b575dc1ff539182b09b8312d06c141882bbcfd428dc842cb1", size = 1489604, upload-time = "2026-02-27T12:22:01.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/26/06f0ba0fcb6a800d8afa73e63c78be6baaae0c442d17da13ff3e7d9033af/pi_heif-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c2f7d26435d25be915914aba7ed383025a594453e3e84fd297975a9584b580c", size = 2343656, upload-time = "2026-02-27T12:22:02.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f5/9deb76f59f36451dea69ebf0330171c1f953ae514dd03ac82ef2aa902ee3/pi_heif-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:26b3d101f838fbacebaa63e0c8b60a4333ba4d3fe93f4a3b51169ecaaf13c0ac", size = 2507970, upload-time = "2026-02-27T12:22:03.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/a3/e921a28ea4b24bbd96cb9e1cd9272ab9a6525e875dcf1fadaeaf73369e81/pi_heif-1.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:1b151e3fb9a0ac4f3729da083eacca2ec4389d312d879ac4e01bb6a1c5fa0812", size = 1047186, upload-time = "2026-02-27T12:22:05.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/c9/ea00b10871c63bc856760a47f9a40b2d6c3c50aaff2e7bc336b6f1205749/pi_heif-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ee96ef255f37df9ed0b2d7865e6a746ff594d328c510ee457913f2f677c4f759", size = 942286, upload-time = "2026-02-27T12:22:06.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/28/3accdd524cc56417df99a87d0e1416656100fe3e13e6aee42f5657540eb5/pi_heif-1.3.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d73d35540119e3ccce88a070fbe10e1cf29d119b149bd344c40ac30824edc8f5", size = 1361062, upload-time = "2026-02-27T12:22:08.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/e68468fea402318a1a422467b1077a053ac192281bdd04625a452c3e13ad/pi_heif-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd610ad8bc319e78c65e106da2ab71f3f4ba85851f77c1634e7c2352a09e7f97", size = 1489616, upload-time = "2026-02-27T12:22:09.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/9b/470790bb3f37ac52edaba9f4b6ec315060fb0e9114e6ac9b8a704754f1d3/pi_heif-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:baedb73888a9d7c2dc2cfe86831c725b6ee640d6405b709d801e09409a7d0da6", size = 2343656, upload-time = "2026-02-27T12:22:11.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/50/17dcf1f8c05eb1cc0ebd479faba3f5832eb5f2dc477ce48d772bebca196c/pi_heif-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:74488dc873986f584beb27c25fa1484a9d9ae10272f442a2571ca771915c28ea", size = 2508037, upload-time = "2026-02-27T12:22:12.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/e6/a4c05ae1fe025f5fe3839b8ab277a6dc861c5feac5214e286bc277ae5ae3/pi_heif-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c00a918a20fb8da1883b3142506c0acb52ecff7901014962aa8d30b3ab78a5e2", size = 1047211, upload-time = "2026-02-27T12:22:14.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/fe/b99741aa4ebd31a28ed4f1bb5703b242211b2968aec15f574a7c75993c89/pi_heif-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e224db6932794bde6d18a2f4e417785a3944b8a61a6b582d8473026b5cdf0408", size = 942366, upload-time = "2026-02-27T12:22:15.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/2b/2a07a116a843a70b4f1320d75727ec2ab616609a4f84201fcbeb72afc685/pi_heif-1.3.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab4764fbf8ec958c6c2b3643a2fa313a7f0275649783ce99ed68a1ce5b71ea96", size = 1361322, upload-time = "2026-02-27T12:22:16.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/3c/93fb4aa1734722d4182ad521832c8e5009934d453b157e994b36e4444c02/pi_heif-1.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f84471adc59a80b06476aba241cfd7c56550ba891a3b6525f5b7aa8eadf8166b", size = 1489732, upload-time = "2026-02-27T12:22:17.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/5c/62f7be4abb279c8ff69bad8c811cdb1224618ab0c5c857ffdb9b4149dc28/pi_heif-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dc2cd95a871d26d604d2a6bbf99c4e7644afbe0d302cdf34065deca41f8a2c30", size = 2343780, upload-time = "2026-02-27T12:22:18.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7c/26bdeb9f632058d8558e409c37dddd069e58c726286247d693ecef833516/pi_heif-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71f568ec93271bedd53917e59f617cf2410dbd8ca307e4bd55e319110d253bc1", size = 2508113, upload-time = "2026-02-27T12:22:20.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/2a/03baff344d2d664ca955c8d8797920bae49d66c8928134c0a071ab6e0319/pi_heif-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3ffaf9a8a73c686cf6c24aedc9151f06c776591db47ff4245ee8a41a23f1cd22", size = 1048171, upload-time = "2026-02-27T12:22:22.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/06/6b7f6f7e7d5bb08c720d04b15c67d4802154d4516feb371e46dd3d0f6698/pi_heif-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:42db92eb41825e9a3cb58a497bd382e61478dd4e2b0e531cdec3f5ddc2f6cefc", size = 943106, upload-time = "2026-02-27T12:22:23.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/21/75c676f96307eef0da33955481658adbedfff85c37f943b9ed528f633a76/pi_heif-1.3.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ea595ea1fdd64dbcc29e4ab4e84902b22ef16812a12f459e876b3928d35c848", size = 1366398, upload-time = "2026-02-27T12:22:24.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/aa/b8fb005c0e09dfee67fc4965d12bee41a2333e004574e47e1290a16bf851/pi_heif-1.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86d10a002567de7e7b2da6ae993fb5c99d6f6a727c9b457e238987b047ad7f98", size = 1493859, upload-time = "2026-02-27T12:22:25.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/55/f76fba8d8ca1b95d89673e72067455ea1ba85c8d4cacacb0cee4c4882f52/pi_heif-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:95651a2a628ea1560e9f2669f9bb58ecbd02436cc52b6a8f2fff91d4f73107fb", size = 2348962, upload-time = "2026-02-27T12:22:26.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/5a/af51148cf5804a120615548e5ec2fee2f22c19b1d88a0ee705a9f09b9f75/pi_heif-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:437f424d8d8bad9f4f23ee4febd8e93b4a2800746e45f676f4543435a7938ca1", size = 2512181, upload-time = "2026-02-27T12:22:29.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/c8/54667ba54daac7e0abf84044bcace1c75df4bf3cf6caf9eec1f8a8b510cb/pi_heif-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e9b8a8f91336e64d9f5c334ca769ccb1063452043bac7297ab8048f424bd4b92", size = 1035290, upload-time = "2026-02-27T12:22:32.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/7b/faa0b54c6598afc8880c6d63914cfdc8f30569dbba96cb649aeaea2dff76/pi_heif-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8cb3e208171db38926b48feaa874365e37f2ff98389cb9dc8d3cfbd027114e63", size = 938798, upload-time = "2026-02-27T12:22:33.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/30/9b9d61c429d8e6e3bc867c3fd13a3cb80579d53aea143de57d74ce7b390d/pi_heif-1.3.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5827ccf996649b32c473ea965cde3b5221734b5d366242348038c819ff7ae33", size = 1320483, upload-time = "2026-02-27T12:22:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ae/ac8fac4afbafeeb63f02e4faad05b1fcc2e3e8c8903fe3c3d669b27bf14a/pi_heif-1.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f378ca0bc5f9c8bef69911c9a1965f2469cff67f3e2a8c1c17c535733e3767e", size = 1445293, upload-time = "2026-02-27T12:22:36.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pikepdf"
|
||||
version = "10.2.0"
|
||||
@@ -3347,6 +3306,63 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow-heif"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/be/0202b6492a225ec0a6cebc615fbf587179a72cf3a7fa91c0753cba7b986f/pillow_heif-1.7.0.tar.gz", hash = "sha256:1caa5a97364665d45056d25be302e586c39df64ee42ce55241e3735e089809ed", size = 17118169, upload-time = "2026-09-06T09:36:53.109Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4b/5f2ea81e48a683423d1eeb4eed1052b4873c6b2a16891b1e87ddac4fbc8e/pillow_heif-1.7.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:bfa2e9607d13d6d2dc8fa14916bac5e8793bcebdb7357d41a7dca6754a6d6967", size = 4759580, upload-time = "2026-09-06T09:34:41.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/97/a3bc9b757a80beb7dd1a341e246070587f082529e27b6692ccb45cda286f/pillow_heif-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e33fc164268d96ce119e2bee43311bae77a7e770f3b6fc38deef082adaee5b3", size = 4277407, upload-time = "2026-09-06T09:34:43.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/be/8c399b9ac1af825e1b45a57b65f7955cd5918fcd50b6ef974958934aadb5/pillow_heif-1.7.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e7280cbb6db614a6dd88b71c1d7eb7e9cfc2e0d58e733afdae85e3227a63306", size = 6368897, upload-time = "2026-09-06T09:34:45.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/5e/7d3e1f4d88b811e43df85b5094238d43e093fbf82589a8a8dab413130cf2/pillow_heif-1.7.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7b31b45bfab2b3f6893199bd13387283445ba656d2febb13c386fc749b2438a", size = 5621744, upload-time = "2026-09-06T09:34:47.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d4/6d72d93d2ee681c9b2ed0c45bc21a20f6a7ae2235d62a6853537fd3d767c/pillow_heif-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0049ecf390e7915e753e585651d22134284176ae583e54c4b863330c838a9835", size = 7401602, upload-time = "2026-09-06T09:34:49.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d9/24dc10716589dc748d3b48effbc8f3c5d974fd708fc404301a5f54593118/pillow_heif-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9abac47964c29585f8c7914ec8cff0a010723a0a127cf6efe9ee78a7aa91c2a9", size = 6656918, upload-time = "2026-09-06T09:34:51.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ee/ec6ee6fc94088f213ab17738966f9670e832f18a6b5d03ceba1e74dfa653/pillow_heif-1.7.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:cba79f030caf9e543b6c9a7c77cd8df465785bc6dcc1361b3fd5f905b3ca4e01", size = 4759735, upload-time = "2026-09-06T09:34:58.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/4f/15a43142f42d29e302d3054df18bdbedc744af4002b490deea74cf543ce9/pillow_heif-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a1f1d001576e4cc2a37f1a7a3c06b3241e7966be16670789daba428a3edf8e30", size = 4279520, upload-time = "2026-09-06T09:35:00.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/17/5bc352ebdc2924edcb3798d284416dfb77f5d90c7c08273104bda7edeeac/pillow_heif-1.7.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f23113505672a6c55e88d7c96cbd82f3d8a5f902d31a3c071be96a9a7e98c5dc", size = 6366611, upload-time = "2026-09-06T09:35:02.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/4d/b7be3eebd23e042b738ee57ed211e5fc0d6fc037b24e0047f99e3a16ba9c/pillow_heif-1.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c4751fcffb55f555a7559cfa6721bdfb30f50f14b7f0cbd2b1cba3b5d5961b7", size = 5620429, upload-time = "2026-09-06T09:35:04.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c4/b4cc21989894938e49b20a6c58e14719ce8a8b2991b1c297d92bf9525442/pillow_heif-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b37df3c08df3401085737e7ed68026398046eb01a8850df27b7a461e9045ed92", size = 7399452, upload-time = "2026-09-06T09:35:06.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/0d/272a1b5ba521dc1448a434a7a043861b0a9b5264e470d40c6cfd02efa6fa/pillow_heif-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b156781538370f3b30a3d2e9899d71a5bc1c89c7e5264123e55d21153e0aa555", size = 6655604, upload-time = "2026-09-06T09:35:08.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/56/f5aa099875cc881aa19bcd1b8c5ef3d97376a05bdc3b56487e49d01dd9be/pillow_heif-1.7.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:2c602d5177e46fca3e0491572d76ed4de66c63ac5aaef999e5b3a4c1fe101395", size = 4759721, upload-time = "2026-09-06T09:35:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/32/59dfe8f1799eef6a442fe5007e5c199b1961d33c7a56c64e0babc9d4a95c/pillow_heif-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9912a8301d469012fe2ba1f2da539b56de048aab3db649857b4ab5fa9a07919b", size = 4279534, upload-time = "2026-09-06T09:35:17.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/9c/f2ce4e20fcb898757c0793e56a1696e789a209e7082b9ca4c81e07089d0a/pillow_heif-1.7.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fc18bacac527936118edca386c19b44a5e4e492b3d484284f990f47c0ec6443", size = 6366688, upload-time = "2026-09-06T09:35:19.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a0/ed893eed7d7b6b1cc160a4ddd56d7c2ac1a2122d7c2e530ecd46c1b86c3c/pillow_heif-1.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25978cd8b765e30901d6f1fb39c5172ab9d9c00e1b1b39430e2243f3b4f3e9d8", size = 5620509, upload-time = "2026-09-06T09:35:21.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/a2/488b9e0d072b20dd34844e3967be7b22c5e9600e6af927a137a37e90c6e2/pillow_heif-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35d6be7a1633f8ebba25617b45f35893e5e4200f944a223090fd6f5e529caa51", size = 7399490, upload-time = "2026-09-06T09:35:23.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/fd/a225a80f3b545c2aeb8eca8cfab3b98267aaa5c79df7b0908a17a99e95ca/pillow_heif-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b8de90c07085a45182ca214911fbe211af0fd1c65c78deda108b4164d1586df", size = 6655639, upload-time = "2026-09-06T09:35:26.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/cf/810c2ad915e017cdc129579093eddaa00017143de227b93a955ce97abc8b/pillow_heif-1.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6188e8a4203be50366e24345e8fdb50f8f6af54ac6b1875a2fe21b17505e01ae", size = 4759761, upload-time = "2026-09-06T09:35:32.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d2/3ac9f209ea5adebbbee6da55e28a3017b672370078feb5c2940526d5a57c/pillow_heif-1.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbb155e2fe0c3c95414661b48bfc6972d53f94eaa487e0ccd29dd3ea17725b1a", size = 4279585, upload-time = "2026-09-06T09:35:34.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/04/d4481661a720860f48998575916ce5c19d62369b90155a58bbed3ea49e1a/pillow_heif-1.7.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09e29bd7d1cecfcbdb9041c3e2a2f1d6f9327ec3804d9035e2909ec88578f25d", size = 6366862, upload-time = "2026-09-06T09:35:36.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/20/fe24ebe67fcac47864b2966620958c21f403feda4fd37fdf4a7108134c17/pillow_heif-1.7.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:850005a64f30b1905866d4ee4c99b09e3b4d5358873e95e64161bcc45ee9f406", size = 5620591, upload-time = "2026-09-06T09:35:38.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/fb/332ff7e2ed90c8fe87a799221e25d762807073b2e6678bb9c6521cd7bc36/pillow_heif-1.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d62d25bb7d2db573b9386d1624bdb4bd7ba1b8b8aeb7989af06ae33b9333bc4f", size = 7399716, upload-time = "2026-09-06T09:35:40.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/b6/4ce87892f1db087ca4aad720c8694912b0cc4477ad27bb5b45a40caec5bd/pillow_heif-1.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b7a259056bd7de5a5d56799f5ed01591d33c1a622aef18be8562db7979961d3", size = 6655686, upload-time = "2026-09-06T09:35:42.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/5a/e5083fc7dc247312f717dc418b0503d3cb6b83b2972dd004ba36df6fda99/pillow_heif-1.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:43d79fa16ab142a9a48577417b3efa6ec2d7a610b8a5aa3c77548ecfeb30e473", size = 4760669, upload-time = "2026-09-06T09:35:50.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/4a/eadca94ba7e38dd9257a6814e71a30c8dedde54cac7be509057ff2d1bdaa/pillow_heif-1.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1494eaf01de9b5571bf064f14239e0036d59e9e0db6c99ad47b9a216f6187383", size = 4280430, upload-time = "2026-09-06T09:35:51.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/43/e400e019d3e36d1c7e13c45633b8c36b072b378825b103c60d28611ba5b8/pillow_heif-1.7.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52513a022d3fcf14993dcd7290df9b43bb92b5aa8471db79bb680b62c8378b7c", size = 6372810, upload-time = "2026-09-06T09:35:53.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/cf/bd34565fd81a32ff930894f2823bebed27a44e0954224f71d5ec597b1518/pillow_heif-1.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2444f95432fd3556a915378789cbc1282e1da55a7f17633e8d928fa72e8fbc9a", size = 5624815, upload-time = "2026-09-06T09:35:55.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/28/233c03f61e214ebb6213a48fba1ee4a17202244e1800ad92a884d02500ce/pillow_heif-1.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f7af31a970c620b25dd144e3094ebf74162e72e08d367a1901a80ccb321859b2", size = 7404821, upload-time = "2026-09-06T09:35:58.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/5a/0a836f5dac24a517f48b0871e31d234792246aa6e94fbeb2210da84b5b28/pillow_heif-1.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3f70c05d9f732b54ffd226750cc0c2e0d89e4cfd9e19eeb88971c899406ecc98", size = 6659823, upload-time = "2026-09-06T09:36:00.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/20/9c71eea91927ca295411d0ea60a909a41d20de11a00fe09221fcb05a070e/pillow_heif-1.7.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:5e4a1f37a71e870477ce23ab0232798e811415266c7ef5923dcd68ba4742a9af", size = 4759759, upload-time = "2026-09-06T09:36:06.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/561a1ecd28623342b52622f96ce5661c42b0f89ea6872c315ee80b3af89e/pillow_heif-1.7.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e2f2b9116924a676c04f1e825758d73475d162bf17602bbd2c2ee3d4380cdf99", size = 4279463, upload-time = "2026-09-06T09:36:08.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/38/69437a171e5b42fdece78f8ed8791b9458c58ec65572301e03b672a5a29c/pillow_heif-1.7.0-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4efb50275159e8a22783cdcbec278e69e39be49e344b09ca11e4b2fe9e1f504", size = 6367405, upload-time = "2026-09-06T09:36:10.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/b1/8e39198c99017f962507d593a941057e33e93bd526cab47c79f877ed2420/pillow_heif-1.7.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90c25eeae139de6b006e34a53c8fa69254234bb866e95684b8b905f4f84452df", size = 5621094, upload-time = "2026-09-06T09:36:13.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/97/8312facd4dd87577acfb2ac2c3d57c0b16aff402a31e3273c1d4cbc5430b/pillow_heif-1.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:65a441dd236440816783875685a7d46f6ffc5ef1237581bf2e0c28c397e19b63", size = 7400244, upload-time = "2026-09-06T09:36:15.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/2a/0066619ada3af41ec0cd7dad794ff1046201747fcbf21b70e4ca285df43f/pillow_heif-1.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f599d543d319156a445a7784054467e70c16f7da1753f35ad6dc7805d4b6a4aa", size = 6656169, upload-time = "2026-09-06T09:36:17.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/1c/7754d8702db4b03038243c7cc814be4f41f3949742b87b7648531ec8d92b/pillow_heif-1.7.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:82c06a57830c81c4c374c9f7cd82e8f90a1ef70392d4d03e714c8d33dcaac8ac", size = 4760671, upload-time = "2026-09-06T09:36:24.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/c5/b1f1762fb61481cd49f6d8013820ba1073e98b91f9a324047d583582b981/pillow_heif-1.7.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:692cfd7c3a66bb8acb8a32f784d529261727d42692cb068e2e33b28acb3c698b", size = 4280315, upload-time = "2026-09-06T09:36:26.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5c/602eb28c80b9f5d4f56a30d0ae3905f5e494f5e15e24363933c0a741e1bb/pillow_heif-1.7.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6fa696e1a8ba618c74602c6dd42020364a9d4ab65f3b715063a734785cfa32", size = 6373370, upload-time = "2026-09-06T09:36:28.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/00/714b375eba2bfe293076c49e0dc74fd7dd9f021a23f5d30cbd11f7fe6174/pillow_heif-1.7.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d168b3337ac5982cd3f78458301da63ab0f6089095ad99cfe95bcde3c5422d", size = 5625421, upload-time = "2026-09-06T09:36:30.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/a8/857319381b23465a383be35b6eb408f0666b55793e3ea40e6044d3a2b3c5/pillow_heif-1.7.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a2dc2a387f5d73dbb1046f8e5cc03de12a98fe3d24b279bb820581cf8e578759", size = 7405426, upload-time = "2026-09-06T09:36:33.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/21/00f694f2e58ffd4e3f2b50913257c7a344acef984b4760828c46e932e619/pillow_heif-1.7.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f79143e66081c5e4433330ddb0908f494d7aab0034b7809b9d51ec4598583793", size = 6660429, upload-time = "2026-09-06T09:36:35.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/28/ad58e9e3be95105658705eec3b9a2b1dce7ad73d543463cfb5e1e483bac8/pillow_heif-1.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:28e9d787079d8f7e937c6482a3cb2cd816fa23a763d3056e563a8f8a9743c0a1", size = 4747497, upload-time = "2026-09-06T09:36:41.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/f6/2fd98449b811b391e200500b615c327f5cdaa559788e3ac81b69a694af01/pillow_heif-1.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ef4a6dd7868f1789d9e0496c83a567903adc947a410338504839b00fe755484", size = 4276386, upload-time = "2026-09-06T09:36:43.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/33/b5594f4838765c0d8ea247dce5ba5f6631fbda6adac24cc349fc80d667f1/pillow_heif-1.7.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc5f92590ac3be371cb5af448918e33e2aaf7770a075e26f5bec10d9c90b2c61", size = 6322385, upload-time = "2026-09-06T09:36:45.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/21/67716eba85d369c177f5aad65b23ac89db0ff758a840e0a6519de55cd9a2/pillow_heif-1.7.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c6e5ea1084caa5ae9f0571eaa46cb8a584ac32943fb8946b085bb2bce4aa052", size = 5572048, upload-time = "2026-09-06T09:36:48.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.1"
|
||||
|
||||
Reference in New Issue
Block a user