mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-03 00:17:16 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5afe0968 | ||
|
|
78ea120e0e | ||
|
|
6b2a5e1a2e | ||
|
|
bb88453a5f | ||
|
|
135f9f6251 | ||
|
|
1c0fbeb6f3 | ||
|
|
1ba1f2b9c2 | ||
|
|
07f1a356f8 | ||
|
|
8d41d31bd7 | ||
|
|
351892bbab | ||
|
|
5d6ea11828 | ||
|
|
c5765a50a1 | ||
|
|
c2a9532b8f | ||
|
|
713c857a08 |
+1
-1
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
|
|||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
|
|||||||
+61
-65
@@ -5,14 +5,14 @@
|
|||||||
<trans-unit id="ngb.alert.close" datatype="html">
|
<trans-unit id="ngb.alert.close" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/alert/alert.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/alert/alert.ts</context>
|
||||||
<context context-type="linenumber">39</context>
|
<context context-type="linenumber">39</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
||||||
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
|
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">129</context>
|
<context context-type="linenumber">129</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||||
@@ -20,219 +20,219 @@
|
|||||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">147,148</context>
|
<context context-type="linenumber">147,148</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.next" datatype="html">
|
<trans-unit id="ngb.carousel.next" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">187</context>
|
<context context-type="linenumber">187</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
||||||
<source>Select month</source>
|
<source>Select month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
||||||
<source>Select year</source>
|
<source>Select year</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||||
<context context-type="linenumber">89</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
||||||
<source>Previous month</source>
|
<source>Previous month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">74,76</context>
|
<context context-type="linenumber">74,76</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
||||||
<source>Next month</source>
|
<source>Next month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first" datatype="html">
|
<trans-unit id="ngb.pagination.first" datatype="html">
|
||||||
<source>««</source>
|
<source>««</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">297</context>
|
<context context-type="linenumber">297</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous" datatype="html">
|
<trans-unit id="ngb.pagination.previous" datatype="html">
|
||||||
<source>«</source>
|
<source>«</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">322</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next" datatype="html">
|
<trans-unit id="ngb.pagination.next" datatype="html">
|
||||||
<source>»</source>
|
<source>»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">358</context>
|
<context context-type="linenumber">358</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last" datatype="html">
|
<trans-unit id="ngb.pagination.last" datatype="html">
|
||||||
<source>»»</source>
|
<source>»»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">396</context>
|
<context context-type="linenumber">396</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
||||||
<source>First</source>
|
<source>First</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">414,415</context>
|
<context context-type="linenumber">414,415</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">444,445</context>
|
<context context-type="linenumber">444,445</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">479,480</context>
|
<context context-type="linenumber">479,480</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
||||||
<source>Last</source>
|
<source>Last</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||||
<context context-type="linenumber">506</context>
|
<context context-type="linenumber">506</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.progressbar.value" datatype="html">
|
<trans-unit id="ngb.progressbar.value" datatype="html">
|
||||||
<source><x id="INTERPOLATION"/></source>
|
<source><x id="INTERPOLATION"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/progressbar/progressbar.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/progressbar/progressbar.ts</context>
|
||||||
<context context-type="linenumber">40</context>
|
<context context-type="linenumber">40</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
||||||
<source>HH</source>
|
<source>HH</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">256,259</context>
|
<context context-type="linenumber">256,259</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
||||||
<source>Hours</source>
|
<source>Hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">293,295</context>
|
<context context-type="linenumber">293,295</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
||||||
<source>MM</source>
|
<source>MM</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">322</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
||||||
<source>Minutes</source>
|
<source>Minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">360</context>
|
<context context-type="linenumber">360</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
||||||
<source>Increment hours</source>
|
<source>Increment hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">397,399</context>
|
<context context-type="linenumber">397,399</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
||||||
<source>Decrement hours</source>
|
<source>Decrement hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">435,438</context>
|
<context context-type="linenumber">435,438</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
||||||
<source>Increment minutes</source>
|
<source>Increment minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
||||||
<source>Decrement minutes</source>
|
<source>Decrement minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
||||||
<source>SS</source>
|
<source>SS</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
||||||
<source>Seconds</source>
|
<source>Seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
||||||
<source>Increment seconds</source>
|
<source>Increment seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
||||||
<source>Decrement seconds</source>
|
<source>Decrement seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
||||||
<source><x id="INTERPOLATION"/></source>
|
<source><x id="INTERPOLATION"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||||
<context context-type="linenumber">442</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/toast/toast.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/toast/toast.ts</context>
|
||||||
<context context-type="linenumber">110,112</context>
|
<context context-type="linenumber">110,112</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -775,7 +775,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">19,20</context>
|
<context context-type="linenumber">20,21</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3894950702316166331" datatype="html">
|
<trans-unit id="3894950702316166331" datatype="html">
|
||||||
@@ -788,10 +788,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
||||||
<context context-type="linenumber">49</context>
|
<context context-type="linenumber">49</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
|
||||||
<context context-type="linenumber">26,29</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||||
<context context-type="linenumber">47</context>
|
<context context-type="linenumber">47</context>
|
||||||
@@ -1854,7 +1850,7 @@
|
|||||||
<source>Clear selection</source>
|
<source>Clear selection</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">10</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||||
@@ -1869,14 +1865,14 @@
|
|||||||
<source>Dismiss all</source>
|
<source>Dismiss all</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">15</context>
|
<context context-type="linenumber">16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1616102757855967475" datatype="html">
|
<trans-unit id="1616102757855967475" datatype="html">
|
||||||
<source>All</source>
|
<source>All</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">39</context>
|
<context context-type="linenumber">35</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
@@ -1915,14 +1911,14 @@
|
|||||||
<source>Filter by</source>
|
<source>Filter by</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">59</context>
|
<context context-type="linenumber">55</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="424356320420294719" datatype="html">
|
<trans-unit id="424356320420294719" datatype="html">
|
||||||
<source>All types</source>
|
<source>All types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">64,65</context>
|
<context context-type="linenumber">60,61</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
@@ -1933,7 +1929,7 @@
|
|||||||
<source>All sources</source>
|
<source>All sources</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">73,74</context>
|
<context context-type="linenumber">69,70</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
@@ -1944,7 +1940,7 @@
|
|||||||
<source>Clear search</source>
|
<source>Clear search</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">92</context>
|
<context context-type="linenumber">88</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||||
@@ -1955,14 +1951,14 @@
|
|||||||
<source>Search tasks</source>
|
<source>Search tasks</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">96,97</context>
|
<context context-type="linenumber">92,93</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6849725902312323996" datatype="html">
|
<trans-unit id="6849725902312323996" datatype="html">
|
||||||
<source>Reset filters</source>
|
<source>Reset filters</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">105,106</context>
|
<context context-type="linenumber">101,102</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||||
@@ -1977,14 +1973,14 @@
|
|||||||
<source>{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</source>
|
<source>{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">126</context>
|
<context context-type="linenumber">122</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8953033926734869941" datatype="html">
|
<trans-unit id="8953033926734869941" datatype="html">
|
||||||
<source>Name</source>
|
<source>Name</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">148,149</context>
|
<context context-type="linenumber">144,145</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
@@ -2095,7 +2091,7 @@
|
|||||||
<source>Created</source>
|
<source>Created</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">149,150</context>
|
<context context-type="linenumber">145,146</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
|
||||||
@@ -2126,21 +2122,21 @@
|
|||||||
<source>Results</source>
|
<source>Results</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">151,152</context>
|
<context context-type="linenumber">147,148</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="314315645942131479" datatype="html">
|
<trans-unit id="314315645942131479" datatype="html">
|
||||||
<source>Info</source>
|
<source>Info</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">153,154</context>
|
<context context-type="linenumber">149,150</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3193976279273491157" datatype="html">
|
<trans-unit id="3193976279273491157" datatype="html">
|
||||||
<source>Actions</source>
|
<source>Actions</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">154,155</context>
|
<context context-type="linenumber">150,151</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||||
@@ -2255,21 +2251,21 @@
|
|||||||
<source>click for full output</source>
|
<source>click for full output</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">205</context>
|
<context context-type="linenumber">201</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8946500279334661567" datatype="html">
|
<trans-unit id="8946500279334661567" datatype="html">
|
||||||
<source>View task details</source>
|
<source>View task details</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">211</context>
|
<context context-type="linenumber">207</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1536087519743707362" datatype="html">
|
<trans-unit id="1536087519743707362" datatype="html">
|
||||||
<source>Dismiss</source>
|
<source>Dismiss</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">218</context>
|
<context context-type="linenumber">214</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
@@ -2292,28 +2288,28 @@
|
|||||||
<source>Open Document</source>
|
<source>Open Document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">223</context>
|
<context context-type="linenumber">219</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5404759957685833020" datatype="html">
|
<trans-unit id="5404759957685833020" datatype="html">
|
||||||
<source>Result message</source>
|
<source>Result message</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">236,237</context>
|
<context context-type="linenumber">232,233</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6621329748219109148" datatype="html">
|
<trans-unit id="6621329748219109148" datatype="html">
|
||||||
<source>Duplicate</source>
|
<source>Duplicate</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">243,244</context>
|
<context context-type="linenumber">239,240</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7593555694782789615" datatype="html">
|
<trans-unit id="7593555694782789615" datatype="html">
|
||||||
<source>Open</source>
|
<source>Open</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">251</context>
|
<context context-type="linenumber">247</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
|
||||||
@@ -2344,21 +2340,21 @@
|
|||||||
<source>Input data</source>
|
<source>Input data</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">261,262</context>
|
<context context-type="linenumber">257,258</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1585185618099050920" datatype="html">
|
<trans-unit id="1585185618099050920" datatype="html">
|
||||||
<source>Result data</source>
|
<source>Result data</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">267,268</context>
|
<context context-type="linenumber">263,264</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7976920528153858271" datatype="html">
|
<trans-unit id="7976920528153858271" datatype="html">
|
||||||
<source>No tasks match the current filters.</source>
|
<source>No tasks match the current filters.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">289,291</context>
|
<context context-type="linenumber">285,287</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2525230676386818985" datatype="html">
|
<trans-unit id="2525230676386818985" datatype="html">
|
||||||
|
|||||||
+13
-13
@@ -15,14 +15,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.1",
|
"@angular/cdk": "^22.1.4",
|
||||||
"@angular/common": "~22.1.1",
|
"@angular/common": "~22.1.3",
|
||||||
"@angular/compiler": "~22.1.1",
|
"@angular/compiler": "~22.1.3",
|
||||||
"@angular/core": "~22.1.1",
|
"@angular/core": "~22.1.3",
|
||||||
"@angular/forms": "~22.1.1",
|
"@angular/forms": "~22.1.3",
|
||||||
"@angular/localize": "~22.1.1",
|
"@angular/localize": "~22.1.3",
|
||||||
"@angular/platform-browser": "~22.1.1",
|
"@angular/platform-browser": "~22.1.3",
|
||||||
"@angular/router": "~22.1.1",
|
"@angular/router": "~22.1.3",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~23.6.0",
|
"@ng-select/ng-select": "~23.6.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
@@ -45,16 +45,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.1.3",
|
"@angular-devkit/core": "^22.1.6",
|
||||||
"@angular-devkit/schematics": "^22.1.3",
|
"@angular-devkit/schematics": "^22.1.6",
|
||||||
"@angular-eslint/builder": "22.1.0",
|
"@angular-eslint/builder": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||||
"@angular-eslint/schematics": "22.1.0",
|
"@angular-eslint/schematics": "22.1.0",
|
||||||
"@angular-eslint/template-parser": "22.1.0",
|
"@angular-eslint/template-parser": "22.1.0",
|
||||||
"@angular/build": "22.1.3",
|
"@angular/build": "22.1.6",
|
||||||
"@angular/cli": "22.1.3",
|
"@angular/cli": "22.1.6",
|
||||||
"@angular/compiler-cli": "~22.1.1",
|
"@angular/compiler-cli": "~22.1.3",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
|
|||||||
Generated
+437
-629
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
info="Tasks shows detailed information about document consumption and system tasks."
|
info="Tasks shows detailed information about document consumption and system tasks."
|
||||||
i18n-info
|
i18n-info
|
||||||
|
[loading]="loading() && pagedTasks().length === 0"
|
||||||
>
|
>
|
||||||
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
||||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||||
@@ -21,11 +22,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@if (loading() && pagedTasks().length === 0) {
|
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
|
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
|
||||||
<div class="task-view-scope btn-group btn-group-sm" role="group">
|
<div class="task-view-scope btn-group btn-group-sm" role="group">
|
||||||
<input
|
<input
|
||||||
@@ -285,6 +281,6 @@
|
|||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else if (!loading() || pagedTasks().length > 0) {
|
||||||
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
&.expanded {
|
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
@@ -113,6 +111,13 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only animate when the user toggles slim mode
|
||||||
|
.sidebar:not(.animating),
|
||||||
|
.sidebar:not(.animating) ~ main,
|
||||||
|
.sidebar:not(.animating) .sidebar-slim-toggler {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.slim {
|
.sidebar.slim {
|
||||||
max-width: 55px;
|
max-width: 55px;
|
||||||
|
|
||||||
@@ -123,8 +128,6 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) {
|
.sidebar.slim:not(.animating) {
|
||||||
transition: none;
|
|
||||||
|
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -144,10 +147,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.animating {
|
.sidebar.animating {
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
@@ -196,6 +195,26 @@ main {
|
|||||||
--bs-popover-body-padding-y: .5rem;
|
--bs-popover-body-padding-y: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.sidebar-sticky > ul,
|
||||||
|
.sidebar-sticky > .nav-group {
|
||||||
|
animation: sidebar-nav-in .3s cubic-bezier(.22, .61, .36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 2 through 5 {
|
||||||
|
.sidebar-sticky > :nth-child(#{$i}) {
|
||||||
|
animation-delay: #{($i - 1) * 0.04}s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sidebar-nav-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-sticky {
|
.sidebar-sticky {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
+6
@@ -7,6 +7,8 @@
|
|||||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
.indicator {
|
.indicator {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -18,3 +20,7 @@
|
|||||||
margin-left: .5rem;
|
margin-left: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ def _consume_file(
|
|||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
*,
|
*,
|
||||||
subdirs_as_tags: bool,
|
subdirs_as_tags: bool,
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Queue a file for consumption.
|
Queue a file for consumption.
|
||||||
|
|
||||||
@@ -322,15 +322,20 @@ def _consume_file(
|
|||||||
filepath: Path to the file to consume.
|
filepath: Path to the file to consume.
|
||||||
consumption_dir: Base consumption directory.
|
consumption_dir: Base consumption directory.
|
||||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the file was successfully handed to Celery, False otherwise.
|
||||||
|
Callers must not record the file as queued on failure, or the rescan
|
||||||
|
will never retry it.
|
||||||
"""
|
"""
|
||||||
# Verify file still exists and is accessible
|
# Verify file still exists and is accessible
|
||||||
try:
|
try:
|
||||||
if not filepath.is_file():
|
if not filepath.is_file():
|
||||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||||
return
|
return False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning(f"Not consuming {filepath}: {e}")
|
logger.warning(f"Not consuming {filepath}: {e}")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Get tags from path if configured
|
# Get tags from path if configured
|
||||||
tag_ids: list[int] | None = None
|
tag_ids: list[int] | None = None
|
||||||
@@ -355,6 +360,9 @@ def _consume_file(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Error while queuing document {filepath}")
|
logger.exception(f"Error while queuing document {filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -492,12 +500,12 @@ class Command(BaseCommand):
|
|||||||
if not consumer_filter(Change.added, str(filepath)):
|
if not consumer_filter(Change.added, str(filepath)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_consume_file(
|
if _consume_file(
|
||||||
filepath=filepath,
|
filepath=filepath,
|
||||||
consumption_dir=directory,
|
consumption_dir=directory,
|
||||||
subdirs_as_tags=subdirs_as_tags,
|
subdirs_as_tags=subdirs_as_tags,
|
||||||
)
|
):
|
||||||
queued.add(filepath.resolve())
|
queued.add(filepath.resolve())
|
||||||
|
|
||||||
return queued
|
return queued
|
||||||
|
|
||||||
@@ -651,14 +659,16 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Check for stable files
|
# Check for stable files
|
||||||
for stable_path in tracker.get_stable_files():
|
for stable_path in tracker.get_stable_files():
|
||||||
_consume_file(
|
# Only remember files that were actually queued, so the
|
||||||
|
# rescan does not re-queue them while the consume task
|
||||||
|
# has yet to remove them from disk, but does retry a
|
||||||
|
# failed publish instead of stranding it
|
||||||
|
if _consume_file(
|
||||||
filepath=stable_path,
|
filepath=stable_path,
|
||||||
consumption_dir=directory,
|
consumption_dir=directory,
|
||||||
subdirs_as_tags=subdirs_as_tags,
|
subdirs_as_tags=subdirs_as_tags,
|
||||||
)
|
):
|
||||||
# Remember it so the rescan does not re-queue it while
|
queued.add(stable_path)
|
||||||
# the consume task has yet to remove it from disk
|
|
||||||
queued.add(stable_path)
|
|
||||||
|
|
||||||
# Exit watch loop to reconfigure timeout
|
# Exit watch loop to reconfigure timeout
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1003,7 +1003,7 @@ def run_workflows(
|
|||||||
|
|
||||||
# kwargs so the PaperlessTask record can note the
|
# kwargs so the PaperlessTask record can note the
|
||||||
# document, see _extract_input_data
|
# document, see _extract_input_data
|
||||||
apply_ai_suggestions.delay(
|
apply_ai_suggestions.delay_on_commit(
|
||||||
action_id=action.pk,
|
action_id=action.pk,
|
||||||
document_id=document.pk,
|
document_id=document.pk,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
class TestChatStreamingViewInputValidation(APITestCase):
|
class TestChatStreamingViewInputValidation(APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -42,3 +49,73 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestChatStreamingViewUnrestrictedFlag:
|
||||||
|
"""The document id filter may only be skipped (``unrestricted=True``) for
|
||||||
|
an active superuser, never for a regular user -- regardless of what
|
||||||
|
permissions that user holds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mocked_stream_chat(self, mocker: MockerFixture) -> mock.MagicMock:
|
||||||
|
"""AI enabled, with stream_chat_with_documents patched so the view
|
||||||
|
never touches the real vector store; returns the patched callable so
|
||||||
|
tests can inspect how it was called.
|
||||||
|
"""
|
||||||
|
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
|
||||||
|
return mocker.patch(
|
||||||
|
"documents.views.stream_chat_with_documents",
|
||||||
|
return_value=iter(()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def viewer_client(self, user_client: APIClient, regular_user: User) -> APIClient:
|
||||||
|
"""The conftest regular-user client, granted the global
|
||||||
|
view_document permission -- the minimum ViewDocumentsPermissions
|
||||||
|
needs to reach the view at all. Model-level only: says nothing
|
||||||
|
about which documents (if any) this user can actually see.
|
||||||
|
"""
|
||||||
|
regular_user.user_permissions.add(
|
||||||
|
*Permission.objects.filter(codename="view_document"),
|
||||||
|
)
|
||||||
|
return user_client
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("client_fixture", "expected_unrestricted"),
|
||||||
|
[
|
||||||
|
pytest.param("admin_client", True, id="superuser_is_unrestricted"),
|
||||||
|
pytest.param("viewer_client", False, id="regular_user_is_restricted"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unrestricted_only_for_superuser(
|
||||||
|
self,
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
mocked_stream_chat: mock.MagicMock,
|
||||||
|
client_fixture: str,
|
||||||
|
*,
|
||||||
|
expected_unrestricted: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A superuser, or a regular user holding the global
|
||||||
|
view_document permission (but no object-level document access)
|
||||||
|
WHEN:
|
||||||
|
- They post a chat question with no document_id
|
||||||
|
THEN:
|
||||||
|
- stream_chat_with_documents is called with unrestricted=True
|
||||||
|
only for the superuser; the regular user is always
|
||||||
|
unrestricted=False, regardless of their permissions
|
||||||
|
"""
|
||||||
|
client: APIClient = request.getfixturevalue(client_fixture)
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/documents/chat/",
|
||||||
|
data={"q": "What's in these documents?"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mocked_stream_chat.call_args.kwargs["unrestricted"] is expected_unrestricted
|
||||||
|
)
|
||||||
|
|||||||
@@ -445,12 +445,13 @@ class TestConsumeFile:
|
|||||||
target = consumption_dir / "document.pdf"
|
target = consumption_dir / "document.pdf"
|
||||||
shutil.copy(sample_pdf, target)
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
mock_consume_file_delay.apply_async.assert_called_once()
|
mock_consume_file_delay.apply_async.assert_called_once()
|
||||||
call_args = mock_consume_file_delay.apply_async.call_args
|
call_args = mock_consume_file_delay.apply_async.call_args
|
||||||
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
||||||
@@ -464,11 +465,12 @@ class TestConsumeFile:
|
|||||||
mock_consume_file_delay: MagicMock,
|
mock_consume_file_delay: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test _consume_file handles nonexistent files gracefully."""
|
"""Test _consume_file handles nonexistent files gracefully."""
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=consumption_dir / "nonexistent.pdf",
|
filepath=consumption_dir / "nonexistent.pdf",
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
def test_consume_directory(
|
def test_consume_directory(
|
||||||
@@ -480,11 +482,12 @@ class TestConsumeFile:
|
|||||||
subdir = consumption_dir / "subdir"
|
subdir = consumption_dir / "subdir"
|
||||||
subdir.mkdir()
|
subdir.mkdir()
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=subdir,
|
filepath=subdir,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
def test_consume_with_permission_error(
|
def test_consume_with_permission_error(
|
||||||
@@ -499,13 +502,33 @@ class TestConsumeFile:
|
|||||||
shutil.copy(sample_pdf, target)
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
|
def test_consume_with_apply_async_failure(
|
||||||
|
self,
|
||||||
|
consumption_dir: Path,
|
||||||
|
sample_pdf: Path,
|
||||||
|
mock_consume_file_delay: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test _consume_file reports failure when apply_async raises."""
|
||||||
|
target = consumption_dir / "document.pdf"
|
||||||
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
|
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
|
||||||
|
|
||||||
|
result = _consume_file(
|
||||||
|
filepath=target,
|
||||||
|
consumption_dir=consumption_dir,
|
||||||
|
subdirs_as_tags=False,
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
def test_consume_with_tags_error(
|
def test_consume_with_tags_error(
|
||||||
self,
|
self,
|
||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
@@ -522,11 +545,12 @@ class TestConsumeFile:
|
|||||||
side_effect=DatabaseError("Something happened"),
|
side_effect=DatabaseError("Something happened"),
|
||||||
)
|
)
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=True,
|
subdirs_as_tags=True,
|
||||||
)
|
)
|
||||||
|
assert result is True
|
||||||
mock_consume_file_delay.apply_async.assert_called_once()
|
mock_consume_file_delay.apply_async.assert_called_once()
|
||||||
call_args = mock_consume_file_delay.apply_async.call_args
|
call_args = mock_consume_file_delay.apply_async.call_args
|
||||||
overrides = call_args.kwargs["kwargs"]["overrides"]
|
overrides = call_args.kwargs["kwargs"]["overrides"]
|
||||||
@@ -1249,6 +1273,52 @@ class TestProcessExistingFilesQueued:
|
|||||||
assert target.resolve() in queued
|
assert target.resolve() in queued
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.management
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestCommandRetryAfterQueueFailure:
|
||||||
|
"""
|
||||||
|
Regression test for GH #13923.
|
||||||
|
|
||||||
|
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
|
||||||
|
must not be marked as queued, so the periodic rescan retries it once
|
||||||
|
the broker recovers, instead of stranding it until the consumer
|
||||||
|
process is restarted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_watch_loop_retries_failed_publish_on_rescan(
|
||||||
|
self,
|
||||||
|
consumption_dir: Path,
|
||||||
|
sample_pdf: Path,
|
||||||
|
mock_consume_file_delay: MagicMock,
|
||||||
|
start_consumer: Callable[..., ConsumerThread],
|
||||||
|
) -> None:
|
||||||
|
"""A publish failure from the watch loop is retried by the rescan."""
|
||||||
|
apply_async = mock_consume_file_delay.apply_async
|
||||||
|
|
||||||
|
def fail_first_call(*args: object, **kwargs: object) -> None:
|
||||||
|
if apply_async.call_count == 1:
|
||||||
|
raise Exception("broker down")
|
||||||
|
|
||||||
|
apply_async.side_effect = fail_first_call
|
||||||
|
|
||||||
|
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
|
||||||
|
|
||||||
|
target = consumption_dir / "document.pdf"
|
||||||
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
|
deadline = monotonic() + 5.0
|
||||||
|
while apply_async.call_count < 2 and monotonic() < deadline:
|
||||||
|
sleep(0.1)
|
||||||
|
|
||||||
|
if thread.exception:
|
||||||
|
raise thread.exception
|
||||||
|
|
||||||
|
assert apply_async.call_count >= 2, (
|
||||||
|
"Expected the failed publish to be retried by the rescan, "
|
||||||
|
f"but apply_async was only called {apply_async.call_count} time(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestCommandRescanRecovery:
|
class TestCommandRescanRecovery:
|
||||||
|
|||||||
@@ -5621,11 +5621,15 @@ class TestApplyAISuggestionsWorkflowAction(
|
|||||||
action = self.make_action()
|
action = self.make_action()
|
||||||
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
||||||
|
|
||||||
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
|
with (
|
||||||
|
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
||||||
|
self.captureOnCommitCallbacks(execute=True),
|
||||||
|
):
|
||||||
run_workflows(
|
run_workflows(
|
||||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
self.doc,
|
self.doc,
|
||||||
)
|
)
|
||||||
|
delay.assert_not_called()
|
||||||
|
|
||||||
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ from documents.permissions import has_system_status_permission
|
|||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
|
from documents.permissions import user_is_unrestricted
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.schema import generate_object_with_permissions_schema
|
from documents.schema import generate_object_with_permissions_schema
|
||||||
from documents.search import SearchHit
|
from documents.search import SearchHit
|
||||||
@@ -2329,10 +2330,12 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
documents = Document.objects.filter(pk=document.pk)
|
documents = Document.objects.filter(pk=document.pk)
|
||||||
|
unrestricted = False
|
||||||
else:
|
else:
|
||||||
documents = Document.objects.filter(
|
documents = Document.objects.filter(
|
||||||
id__in=permitted_document_ids(request.user),
|
id__in=permitted_document_ids(request.user),
|
||||||
)
|
)
|
||||||
|
unrestricted = user_is_unrestricted(request.user)
|
||||||
|
|
||||||
output_language = get_llm_output_language(
|
output_language = get_llm_output_language(
|
||||||
ai_config=ai_config,
|
ai_config=ai_config,
|
||||||
@@ -2343,6 +2346,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
stream_chat_with_documents(
|
stream_chat_with_documents(
|
||||||
query_str=question,
|
query_str=question,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
|
unrestricted=unrestricted,
|
||||||
output_language=output_language,
|
output_language=output_language,
|
||||||
),
|
),
|
||||||
content_type="text/event-stream",
|
content_type="text/event-stream",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-01 19:54+0000\n"
|
"POT-Creation-Date: 2026-09-02 18:09+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1628,7 +1628,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:524 documents/serialisers.py:878
|
#: documents/serialisers.py:524 documents/serialisers.py:878
|
||||||
#: documents/serialisers.py:2830 documents/views.py:313 documents/views.py:2619
|
#: documents/serialisers.py:2830 documents/views.py:314 documents/views.py:2623
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1669,7 +1669,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2916 documents/views.py:4620
|
#: documents/serialisers.py:2916 documents/views.py:4624
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1937,36 +1937,36 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:306 documents/views.py:2616
|
#: documents/views.py:307 documents/views.py:2620
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1590
|
#: documents/views.py:1591
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1601
|
#: documents/views.py:1602
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2441 documents/views.py:2762
|
#: documents/views.py:2445 documents/views.py:2766
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4633
|
#: documents/views.py:4637
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4679
|
#: documents/views.py:4683
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4743
|
#: documents/views.py:4747
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4757
|
#: documents/views.py:4761
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ def _rewrite_request_to_pinned_ip(
|
|||||||
method=request.method,
|
method=request.method,
|
||||||
url=new_url,
|
url=new_url,
|
||||||
headers=new_headers,
|
headers=new_headers,
|
||||||
content=request.stream,
|
stream=request.stream,
|
||||||
extensions=request.extensions,
|
extensions=request.extensions,
|
||||||
)
|
)
|
||||||
rewritten_request.extensions["sni_hostname"] = hostname
|
rewritten_request.extensions["sni_hostname"] = hostname
|
||||||
|
|||||||
@@ -705,6 +705,12 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
|
|||||||
CELERY_TASK_TRACK_STARTED = True
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
||||||
|
|
||||||
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_allow_error_cb_on_chord_header
|
||||||
|
# Without this, a failing chord header never triggers the errback, so a mail
|
||||||
|
# whose attachments all fail is never recorded and is re-fetched forever.
|
||||||
|
# The errback runs once per failed header task, so it must be idempotent.
|
||||||
|
CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER = True
|
||||||
|
|
||||||
CELERY_CACHE_BACKEND = "default"
|
CELERY_CACHE_BACKEND = "default"
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from documents.models import Document
|
|||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.client import AIClient
|
from paperless_ai.client import AIClient
|
||||||
from paperless_ai.db import db_connection_released
|
from paperless_ai.db import db_connection_released
|
||||||
from paperless_ai.indexing import _document_id_filters
|
from paperless_ai.indexing import document_id_filters
|
||||||
|
from paperless_ai.indexing import exclude_document_ids_filter
|
||||||
from paperless_ai.indexing import get_rag_prompt_helper
|
from paperless_ai.indexing import get_rag_prompt_helper
|
||||||
from paperless_ai.indexing import load_or_build_index
|
from paperless_ai.indexing import load_or_build_index
|
||||||
from paperless_ai.indexing import read_store
|
from paperless_ai.indexing import read_store
|
||||||
@@ -95,12 +96,15 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
|
|||||||
def stream_chat_with_documents(
|
def stream_chat_with_documents(
|
||||||
query_str: str,
|
query_str: str,
|
||||||
documents: QuerySet[Document],
|
documents: QuerySet[Document],
|
||||||
|
*,
|
||||||
|
unrestricted: bool = False,
|
||||||
output_language: str | None = None,
|
output_language: str | None = None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
yield from _stream_chat_with_documents(
|
yield from _stream_chat_with_documents(
|
||||||
query_str,
|
query_str,
|
||||||
documents,
|
documents,
|
||||||
|
unrestricted=unrestricted,
|
||||||
output_language=output_language,
|
output_language=output_language,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -111,6 +115,8 @@ def stream_chat_with_documents(
|
|||||||
def _stream_chat_with_documents(
|
def _stream_chat_with_documents(
|
||||||
query_str: str,
|
query_str: str,
|
||||||
documents: QuerySet[Document],
|
documents: QuerySet[Document],
|
||||||
|
*,
|
||||||
|
unrestricted: bool = False,
|
||||||
output_language: str | None = None,
|
output_language: str | None = None,
|
||||||
):
|
):
|
||||||
if not documents.exists():
|
if not documents.exists():
|
||||||
@@ -123,9 +129,18 @@ def _stream_chat_with_documents(
|
|||||||
from llama_index.core.retrievers import VectorIndexRetriever
|
from llama_index.core.retrievers import VectorIndexRetriever
|
||||||
|
|
||||||
config = AIConfig()
|
config = AIConfig()
|
||||||
filters = _document_id_filters(
|
if unrestricted:
|
||||||
str(pk) for pk in documents.values_list("pk", flat=True)
|
# Exclude trashed ids (usually few) instead of an IN filter over the
|
||||||
)
|
# full permitted set, which risks the vector store's bound parameter
|
||||||
|
# limit (_MAX_IN_VALUES) on large installs. Trashed documents stay
|
||||||
|
# indexed until permanent deletion (delete_document_from_llm_index
|
||||||
|
# hangs off post_delete, not trash), so must be excluded explicitly.
|
||||||
|
trashed_ids = Document.deleted_objects.values_list("pk", flat=True)
|
||||||
|
filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids)
|
||||||
|
else:
|
||||||
|
filters = document_id_filters(
|
||||||
|
str(pk) for pk in documents.values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
# Hold the shared read lock for the whole operation: the query engine
|
# Hold the shared read lock for the whole operation: the query engine
|
||||||
# retrieves from the vector store again during synthesis, so the connection
|
# retrieves from the vector store again during synthesis, so the connection
|
||||||
|
|||||||
@@ -131,11 +131,10 @@ class AIClient:
|
|||||||
|
|
||||||
from llama_index.core.llms import ChatMessage
|
from llama_index.core.llms import ChatMessage
|
||||||
|
|
||||||
user_msg = ChatMessage(role="user", content=prompt)
|
|
||||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||||
with self._normalize_timeouts():
|
with self._normalize_timeouts():
|
||||||
result = self.llm.chat(
|
result = self.llm.chat(
|
||||||
[user_msg],
|
[ChatMessage(role="user", content=prompt)],
|
||||||
format=DocumentClassifierSchema.model_json_schema(),
|
format=DocumentClassifierSchema.model_json_schema(),
|
||||||
think=False,
|
think=False,
|
||||||
)
|
)
|
||||||
@@ -149,6 +148,11 @@ class AIClient:
|
|||||||
from llama_index.core.program.function_program import get_function_tool
|
from llama_index.core.program.function_program import get_function_tool
|
||||||
|
|
||||||
tool = get_function_tool(DocumentClassifierSchema)
|
tool = get_function_tool(DocumentClassifierSchema)
|
||||||
|
user_msg = ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"{prompt}\n\n"
|
||||||
|
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
|
||||||
|
)
|
||||||
with self._normalize_timeouts():
|
with self._normalize_timeouts():
|
||||||
result = self.llm.chat_with_tools(
|
result = self.llm.chat_with_tools(
|
||||||
tools=[tool],
|
tools=[tool],
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ def _embed_nodes(nodes: list["BaseNode"], embed_model) -> None:
|
|||||||
node.embedding = emb
|
node.embedding = emb
|
||||||
|
|
||||||
|
|
||||||
def _document_id_filters(doc_ids):
|
def document_id_filters(doc_ids):
|
||||||
"""Return a MetadataFilters IN filter scoped to ``doc_ids``."""
|
"""Return a MetadataFilters IN filter scoped to ``doc_ids``."""
|
||||||
from llama_index.core.vector_stores.types import FilterOperator
|
from llama_index.core.vector_stores.types import FilterOperator
|
||||||
from llama_index.core.vector_stores.types import MetadataFilter
|
from llama_index.core.vector_stores.types import MetadataFilter
|
||||||
@@ -396,6 +396,23 @@ def _exclude_document_id_filter(document_id: int | str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exclude_document_ids_filter(doc_ids):
|
||||||
|
"""Return a MetadataFilters NIN filter excluding every id in ``doc_ids``."""
|
||||||
|
from llama_index.core.vector_stores.types import FilterOperator
|
||||||
|
from llama_index.core.vector_stores.types import MetadataFilter
|
||||||
|
from llama_index.core.vector_stores.types import MetadataFilters
|
||||||
|
|
||||||
|
return MetadataFilters(
|
||||||
|
filters=[
|
||||||
|
MetadataFilter(
|
||||||
|
key="document_id",
|
||||||
|
operator=FilterOperator.NIN,
|
||||||
|
value=list(doc_ids),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_llm_index(
|
def update_llm_index(
|
||||||
*,
|
*,
|
||||||
iter_wrapper: IterWrapper[Document] = identity,
|
iter_wrapper: IterWrapper[Document] = identity,
|
||||||
@@ -660,7 +677,7 @@ def retrieve_similar_nodes(
|
|||||||
|
|
||||||
filter_parts = []
|
filter_parts = []
|
||||||
if allowed_document_ids is not None:
|
if allowed_document_ids is not None:
|
||||||
filter_parts.extend(_document_id_filters(allowed_document_ids).filters)
|
filter_parts.extend(document_id_filters(allowed_document_ids).filters)
|
||||||
if document.pk is not None:
|
if document.pk is not None:
|
||||||
filter_parts.extend(_exclude_document_id_filter(document.pk).filters)
|
filter_parts.extend(_exclude_document_id_filter(document.pk).filters)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Rewrite only the "title", "tags", "document_types", and "storage_paths" fields i
|
|||||||
|
|
||||||
Do not translate correspondents or dates.
|
Do not translate correspondents or dates.
|
||||||
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
|
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
|
||||||
Return the same JSON schema with all fields present.
|
Keep every entry you were given in those four fields, in the same order, using the original wording where no translation applies.
|
||||||
|
|
||||||
Suggestions:
|
Suggestions:
|
||||||
{{ suggestions_json }}
|
{{ suggestions_json }}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.db.models.signals import post_init
|
from django.db.models.signals import post_init
|
||||||
|
from django.utils import timezone
|
||||||
from llama_index.core import settings as llama_settings
|
from llama_index.core import settings as llama_settings
|
||||||
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
||||||
from llama_index.core.schema import TextNode
|
from llama_index.core.schema import TextNode
|
||||||
@@ -18,6 +23,11 @@ from paperless_ai.chat import _build_chat_prompt
|
|||||||
from paperless_ai.chat import _build_refine_prompt
|
from paperless_ai.chat import _build_refine_prompt
|
||||||
from paperless_ai.chat import stream_chat_with_documents
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest_mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_embed_model():
|
def patch_embed_model():
|
||||||
@@ -310,8 +320,40 @@ def test_stream_chat_unexpected_failure_returns_generic_error(caplog) -> None:
|
|||||||
assert "private provider detail" in caplog.text
|
assert "private provider detail" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def _retriever_filter_values(captured_filters: list[Any]) -> list[str]:
|
||||||
|
"""The value list of the single MetadataFilter the retriever received."""
|
||||||
|
assert captured_filters, "VectorIndexRetriever was never constructed"
|
||||||
|
filt = captured_filters[0]
|
||||||
|
assert filt is not None, "Retriever must receive a MetadataFilters"
|
||||||
|
return filt.filters[0].value
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestStreamChatRetrieval:
|
class TestStreamChatRetrieval:
|
||||||
|
@pytest.fixture
|
||||||
|
def captured_filters(self, mocker: pytest_mock.MockerFixture) -> list[Any]:
|
||||||
|
"""Stub out the AI client and the retriever, capturing the ``filters``
|
||||||
|
kwarg of every VectorIndexRetriever construction.
|
||||||
|
|
||||||
|
VectorIndexRetriever is imported inside _stream_chat_with_documents,
|
||||||
|
so it is patched at the llama_index source for the lazy import to
|
||||||
|
pick it up.
|
||||||
|
"""
|
||||||
|
captured: list[Any] = []
|
||||||
|
retriever = mocker.MagicMock()
|
||||||
|
retriever.retrieve.return_value = []
|
||||||
|
|
||||||
|
def capture_retriever(*args, **kwargs) -> pytest_mock.MockType:
|
||||||
|
captured.append(kwargs.get("filters"))
|
||||||
|
return retriever
|
||||||
|
|
||||||
|
mocker.patch("paperless_ai.chat.AIClient")
|
||||||
|
mocker.patch(
|
||||||
|
"llama_index.core.retrievers.VectorIndexRetriever",
|
||||||
|
side_effect=capture_retriever,
|
||||||
|
)
|
||||||
|
return captured
|
||||||
|
|
||||||
def test_no_nodes_yields_no_content_message(
|
def test_no_nodes_yields_no_content_message(
|
||||||
self,
|
self,
|
||||||
temp_llm_index_dir,
|
temp_llm_index_dir,
|
||||||
@@ -329,9 +371,9 @@ class TestStreamChatRetrieval:
|
|||||||
|
|
||||||
def test_chat_filter_contains_only_requested_document_ids(
|
def test_chat_filter_contains_only_requested_document_ids(
|
||||||
self,
|
self,
|
||||||
temp_llm_index_dir,
|
temp_llm_index_dir: Path,
|
||||||
mock_embed_model,
|
mock_embed_model: pytest_mock.MockType,
|
||||||
mocker,
|
captured_filters: list[Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The MetadataFilter passed to the retriever must be scoped to the
|
"""The MetadataFilter passed to the retriever must be scoped to the
|
||||||
requested documents only — content from other indexed documents must
|
requested documents only — content from other indexed documents must
|
||||||
@@ -342,22 +384,6 @@ class TestStreamChatRetrieval:
|
|||||||
indexing.llm_index_add_or_update_document(included)
|
indexing.llm_index_add_or_update_document(included)
|
||||||
indexing.llm_index_add_or_update_document(excluded)
|
indexing.llm_index_add_or_update_document(excluded)
|
||||||
|
|
||||||
# VectorIndexRetriever is imported inside _stream_chat_with_documents;
|
|
||||||
# patch it at the llama_index source so the lazy import picks it up.
|
|
||||||
captured_filters = []
|
|
||||||
mock_retriever = mocker.MagicMock()
|
|
||||||
mock_retriever.retrieve.return_value = []
|
|
||||||
|
|
||||||
def capture_retriever(*args, **kwargs):
|
|
||||||
captured_filters.append(kwargs.get("filters"))
|
|
||||||
return mock_retriever
|
|
||||||
|
|
||||||
mocker.patch("paperless_ai.chat.AIClient")
|
|
||||||
mocker.patch(
|
|
||||||
"llama_index.core.retrievers.VectorIndexRetriever",
|
|
||||||
side_effect=capture_retriever,
|
|
||||||
)
|
|
||||||
|
|
||||||
list(
|
list(
|
||||||
chat.stream_chat_with_documents(
|
chat.stream_chat_with_documents(
|
||||||
"question?",
|
"question?",
|
||||||
@@ -365,13 +391,78 @@ class TestStreamChatRetrieval:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert captured_filters, "VectorIndexRetriever was never constructed"
|
filter_values = _retriever_filter_values(captured_filters)
|
||||||
filt = captured_filters[0]
|
|
||||||
assert filt is not None, "Retriever must receive a MetadataFilters"
|
|
||||||
filter_values = filt.filters[0].value
|
|
||||||
assert str(included.pk) in filter_values
|
assert str(included.pk) in filter_values
|
||||||
assert str(excluded.pk) not in filter_values
|
assert str(excluded.pk) not in filter_values
|
||||||
|
|
||||||
|
def test_unrestricted_chat_excludes_nothing_when_no_documents_are_trashed(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: pytest_mock.MockType,
|
||||||
|
captured_filters: list[Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document indexed in the vector store, nothing trashed
|
||||||
|
WHEN:
|
||||||
|
- stream_chat_with_documents is called with unrestricted=True
|
||||||
|
THEN:
|
||||||
|
- The retriever receives a NOT IN filter excluding zero ids, so
|
||||||
|
the whole index is effectively searched -- and no IN-list is
|
||||||
|
built from the full permitted set, which is what risks the
|
||||||
|
vector store's safety limit on large installs
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.create(content="indexed document content")
|
||||||
|
indexing.llm_index_add_or_update_document(document)
|
||||||
|
|
||||||
|
list(
|
||||||
|
chat.stream_chat_with_documents(
|
||||||
|
"question?",
|
||||||
|
Document.objects.filter(pk=document.pk),
|
||||||
|
unrestricted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _retriever_filter_values(captured_filters) == []
|
||||||
|
|
||||||
|
def test_unrestricted_chat_excludes_trashed_documents(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: pytest_mock.MockType,
|
||||||
|
captured_filters: list[Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Two indexed documents, one of them trashed -- trashed documents
|
||||||
|
stay in the vector index until permanently deleted, since
|
||||||
|
delete_document_from_llm_index is wired to post_delete
|
||||||
|
WHEN:
|
||||||
|
- stream_chat_with_documents is called with unrestricted=True
|
||||||
|
THEN:
|
||||||
|
- The retriever receives a NOT IN filter excluding the trashed
|
||||||
|
document's id, so an unrestricted caller (e.g. a superuser)
|
||||||
|
never has trashed content surfaced in a chat answer
|
||||||
|
"""
|
||||||
|
kept = DocumentFactory.create(content="kept document content")
|
||||||
|
trashed = DocumentFactory.create(content="trashed document content")
|
||||||
|
indexing.llm_index_add_or_update_document(kept)
|
||||||
|
indexing.llm_index_add_or_update_document(trashed)
|
||||||
|
Document.global_objects.filter(pk=trashed.pk).update(
|
||||||
|
deleted_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
list(
|
||||||
|
chat.stream_chat_with_documents(
|
||||||
|
"question?",
|
||||||
|
Document.objects.filter(pk=kept.pk),
|
||||||
|
unrestricted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_values = _retriever_filter_values(captured_filters)
|
||||||
|
assert str(trashed.pk) in filter_values
|
||||||
|
assert str(kept.pk) not in filter_values
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_get_document_references_only_queries_referenced_documents(
|
def test_get_document_references_only_queries_referenced_documents(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ def test_run_llm_query_ollama_uses_structured_json(mock_ai_config, mock_ollama_l
|
|||||||
format=ANY,
|
format=ANY,
|
||||||
think=False,
|
think=False,
|
||||||
)
|
)
|
||||||
|
messages = mock_llm_instance.chat.call_args.args[0]
|
||||||
|
assert messages[0].content == "test_prompt"
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
||||||
@@ -183,6 +185,13 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
|||||||
assert result["title"] == "Test Title"
|
assert result["title"] == "Test Title"
|
||||||
assert result["tags"] == {"existing_ids": [1], "new_names": []}
|
assert result["tags"] == {"existing_ids": [1], "new_names": []}
|
||||||
mock_llm_instance.chat_with_tools.assert_called_once()
|
mock_llm_instance.chat_with_tools.assert_called_once()
|
||||||
|
kwargs = mock_llm_instance.chat_with_tools.call_args.kwargs
|
||||||
|
offered_tool_name = kwargs["tools"][0].metadata.name
|
||||||
|
assert kwargs["user_msg"].content == (
|
||||||
|
"test_prompt\n\n"
|
||||||
|
f"Answer by calling the {offered_tool_name} tool. "
|
||||||
|
"Do not write the answer as text."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_openai_timeout_raises_local_error(
|
def test_run_llm_query_openai_timeout_raises_local_error(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -97,6 +98,18 @@ def _ne_filter(document_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _nin_filter(document_ids: list[int]):
|
||||||
|
return MetadataFilters(
|
||||||
|
filters=[
|
||||||
|
MetadataFilter(
|
||||||
|
key="document_id",
|
||||||
|
operator=FilterOperator.NIN,
|
||||||
|
value=document_ids,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCrud:
|
class TestCrud:
|
||||||
def test_add_then_query_returns_node(self, store) -> None:
|
def test_add_then_query_returns_node(self, store) -> None:
|
||||||
node = make_node("n1", 1)
|
node = make_node("n1", 1)
|
||||||
@@ -280,6 +293,47 @@ class TestBuildWhere:
|
|||||||
"b1",
|
"b1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_nin_filter_translates_to_not_in_clause(self) -> None:
|
||||||
|
where, params = _build_where(_nin_filter([1, 2]))
|
||||||
|
assert where == "(document_id NOT IN (?,?))"
|
||||||
|
assert params == [1, 2]
|
||||||
|
|
||||||
|
def test_query_with_nin_filter_excludes_matching_documents(self, store) -> None:
|
||||||
|
store.add([make_node("a1", 1), make_node("b1", 2), make_node("c1", 3)])
|
||||||
|
assert sorted(
|
||||||
|
_query(store, [0.0] * DIM, top_k=5, filters=_nin_filter([1, 2])).ids,
|
||||||
|
) == ["c1"]
|
||||||
|
|
||||||
|
def test_empty_in_filter_excludes_everything(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An IN filter with an empty value list
|
||||||
|
WHEN:
|
||||||
|
- _build_where() translates it to SQL
|
||||||
|
THEN:
|
||||||
|
- It excludes everything (the opposite of an empty NOT IN
|
||||||
|
filter) -- an empty inclusion list must never widen results
|
||||||
|
"""
|
||||||
|
where, params = _build_where(_in_filter([]))
|
||||||
|
assert where == "(1 = 0)"
|
||||||
|
assert params == []
|
||||||
|
|
||||||
|
def test_empty_nin_filter_excludes_nothing(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A NOT IN filter with an empty value list -- e.g. an
|
||||||
|
unrestricted chat caller when nothing is currently trashed
|
||||||
|
WHEN:
|
||||||
|
- _build_where() translates it to SQL
|
||||||
|
THEN:
|
||||||
|
- It excludes nothing (unlike an empty IN filter, which
|
||||||
|
excludes everything) -- an empty exclusion list must never
|
||||||
|
narrow results
|
||||||
|
"""
|
||||||
|
where, params = _build_where(_nin_filter([]))
|
||||||
|
assert where == "(1 = 1)"
|
||||||
|
assert params == []
|
||||||
|
|
||||||
def test_fails_closed_when_no_filter_is_translatable(self) -> None:
|
def test_fails_closed_when_no_filter_is_translatable(self) -> None:
|
||||||
# A nested MetadataFilters is not a MetadataFilter, so it is skipped.
|
# A nested MetadataFilters is not a MetadataFilter, so it is skipped.
|
||||||
# With no translatable clauses, the function must fail closed rather
|
# With no translatable clauses, the function must fail closed rather
|
||||||
@@ -297,24 +351,31 @@ class TestBuildWhere:
|
|||||||
assert where == "1 = 0"
|
assert where == "1 = 0"
|
||||||
assert params == []
|
assert params == []
|
||||||
|
|
||||||
def test_fails_closed_when_in_filter_exceeds_max_values(
|
@pytest.mark.parametrize(
|
||||||
|
"build_filter",
|
||||||
|
[_in_filter, _nin_filter],
|
||||||
|
ids=["in", "nin"],
|
||||||
|
)
|
||||||
|
def test_fails_closed_when_filter_exceeds_max_values(
|
||||||
self,
|
self,
|
||||||
|
build_filter: Callable[[list[str]], MetadataFilters],
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- An IN filter with more values than _MAX_IN_VALUES (SQLite's
|
- An IN or NOT IN filter with more values than _MAX_IN_VALUES
|
||||||
own bound-parameter limit is 32766; this guard sits below
|
(SQLite's own bound-parameter limit is 32766; this guard sits
|
||||||
that with headroom for the query's other bound parameters)
|
below that with headroom for the query's other bound parameters)
|
||||||
WHEN:
|
WHEN:
|
||||||
- _build_where() translates it to SQL
|
- _build_where() translates it to SQL
|
||||||
THEN:
|
THEN:
|
||||||
- It fails closed ("1 = 0", no params) instead of building an
|
- It fails closed ("1 = 0", no params) instead of building a
|
||||||
IN clause SQLite would reject, and logs a warning -- this
|
clause SQLite would reject, and logs a warning -- this filter
|
||||||
filter scopes document access, so refusing to build it must
|
scopes document access, so refusing to build it must never
|
||||||
never widen the scope to "everything" by accident
|
widen the scope to "everything" by accident. Failing open on
|
||||||
|
a NOT IN would surface exactly the excluded rows
|
||||||
"""
|
"""
|
||||||
oversized = _in_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
oversized = build_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
||||||
|
|
||||||
with caplog.at_level("WARNING"):
|
with caplog.at_level("WARNING"):
|
||||||
where, params = _build_where(oversized)
|
where, params = _build_where(oversized)
|
||||||
|
|||||||
@@ -107,12 +107,13 @@ def _vec0_params(rows: list[_Row]) -> list[tuple[str, int, str, bytes]]:
|
|||||||
|
|
||||||
|
|
||||||
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||||
"""Translate the EQ / IN / NE filters we use into a parameterized SQL
|
"""Translate the EQ / IN / NIN / NE filters we use into a parameterized
|
||||||
clause on vec0 metadata columns. Returns ("", []) when there is nothing
|
SQL clause on vec0 metadata columns. Returns ("", []) when there is
|
||||||
to filter. document_id is vec0's only filterable column and is INTEGER;
|
nothing to filter. document_id is vec0's only filterable column and is
|
||||||
every value is coerced via int() here so callers (which today still pass
|
INTEGER; every value is coerced via int() here so callers (which today
|
||||||
strings in places, e.g. indexing.py's MetadataFilter construction) don't
|
still pass strings in places, e.g. indexing.py's MetadataFilter
|
||||||
have to be individually correct -- vec0 doesn't coerce types itself.
|
construction) don't have to be individually correct -- vec0 doesn't
|
||||||
|
coerce types itself.
|
||||||
"""
|
"""
|
||||||
if filters is None or not filters.filters:
|
if filters is None or not filters.filters:
|
||||||
return "", []
|
return "", []
|
||||||
@@ -125,20 +126,25 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
|||||||
continue
|
continue
|
||||||
if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys
|
if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys
|
||||||
raise NotImplementedError(f"Unsupported filter column: {f.key}")
|
raise NotImplementedError(f"Unsupported filter column: {f.key}")
|
||||||
if f.operator == FilterOperator.IN:
|
if f.operator in (FilterOperator.IN, FilterOperator.NIN):
|
||||||
|
is_in = f.operator == FilterOperator.IN
|
||||||
|
sql_op = "IN" if is_in else "NOT IN"
|
||||||
values = [int(v) for v in f.value] # type: ignore[union-attr]
|
values = [int(v) for v in f.value] # type: ignore[union-attr]
|
||||||
if not values: # pragma: no cover
|
if not values:
|
||||||
clauses.append("1 = 0")
|
# An empty IN list matches nothing; an empty NOT IN list
|
||||||
|
# excludes nothing, so it matches everything.
|
||||||
|
clauses.append("1 = 0" if is_in else "1 = 1")
|
||||||
continue
|
continue
|
||||||
if len(values) > _MAX_IN_VALUES:
|
if len(values) > _MAX_IN_VALUES:
|
||||||
# Fail closed (see the empty-clauses case below) rather than
|
# Refuse rather than risk SQLite's own bound-parameter limit
|
||||||
# let SQLite raise "too many SQL variables" past its own
|
# ("too many SQL variables"): a list this large must match no
|
||||||
# limit: this filter scopes document access, so an IN list
|
# rows, never widen the scope to "everything" -- true for
|
||||||
# too large to safely bind must match no rows, never widen
|
# NOT IN too, where failing open would surface every
|
||||||
# the scope to "everything" by accident.
|
# excluded row.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Refusing to build an IN filter on %r with %d values "
|
"Refusing to build a %s filter on %r with %d values "
|
||||||
"(over the %d-value safety limit); returning no rows.",
|
"(over the %d-value safety limit); returning no rows.",
|
||||||
|
sql_op,
|
||||||
f.key,
|
f.key,
|
||||||
len(values),
|
len(values),
|
||||||
_MAX_IN_VALUES,
|
_MAX_IN_VALUES,
|
||||||
@@ -146,7 +152,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
|||||||
clauses.append("1 = 0")
|
clauses.append("1 = 0")
|
||||||
continue
|
continue
|
||||||
placeholders = ",".join("?" for _ in values)
|
placeholders = ",".join("?" for _ in values)
|
||||||
clauses.append(f"{f.key} IN ({placeholders})")
|
clauses.append(f"{f.key} {sql_op} ({placeholders})")
|
||||||
params.extend(values)
|
params.extend(values)
|
||||||
elif f.operator == FilterOperator.EQ:
|
elif f.operator == FilterOperator.EQ:
|
||||||
clauses.append(f"{f.key} = ?")
|
clauses.append(f"{f.key} = ?")
|
||||||
@@ -154,7 +160,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
|||||||
elif f.operator == FilterOperator.NE:
|
elif f.operator == FilterOperator.NE:
|
||||||
clauses.append(f"{f.key} != ?")
|
clauses.append(f"{f.key} != ?")
|
||||||
params.append(int(f.value))
|
params.append(int(f.value))
|
||||||
else: # pragma: no cover - we only ever build EQ/IN/NE filters
|
else: # pragma: no cover - we only ever build EQ/IN/NIN/NE filters
|
||||||
raise NotImplementedError(f"Unsupported filter operator: {f.operator}")
|
raise NotImplementedError(f"Unsupported filter operator: {f.operator}")
|
||||||
if not clauses:
|
if not clauses:
|
||||||
# Filters were requested but none could be translated. Fail closed
|
# Filters were requested but none could be translated. Fail closed
|
||||||
|
|||||||
@@ -334,18 +334,24 @@ def error_callback(
|
|||||||
"""
|
"""
|
||||||
A shared task that is called whenever something goes wrong during
|
A shared task that is called whenever something goes wrong during
|
||||||
consumption of a file. See queue_consumption_tasks.
|
consumption of a file. See queue_consumption_tasks.
|
||||||
|
|
||||||
|
With CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER enabled this runs once per
|
||||||
|
failed header task, not once per chord, so it must be idempotent.
|
||||||
"""
|
"""
|
||||||
rule = MailRule.objects.get(pk=rule_id)
|
rule = MailRule.objects.get(pk=rule_id)
|
||||||
|
received = make_aware(message_date) if is_naive(message_date) else message_date
|
||||||
|
|
||||||
ProcessedMail.objects.create(
|
ProcessedMail.objects.get_or_create(
|
||||||
rule=rule,
|
rule=rule,
|
||||||
folder=rule.folder,
|
folder=rule.folder,
|
||||||
uid=message_uid,
|
uid=message_uid,
|
||||||
uid_validity=uid_validity,
|
uid_validity=uid_validity,
|
||||||
subject=message_subject,
|
defaults={
|
||||||
received=make_aware(message_date) if is_naive(message_date) else message_date,
|
"subject": message_subject,
|
||||||
status="FAILED",
|
"received": received,
|
||||||
error=traceback.format_exc(),
|
"status": "FAILED",
|
||||||
|
"error": traceback.format_exc(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from paperless_mail.mail import MailAccountHandler
|
|||||||
from paperless_mail.mail import MailError
|
from paperless_mail.mail import MailError
|
||||||
from paperless_mail.mail import TagMailAction
|
from paperless_mail.mail import TagMailAction
|
||||||
from paperless_mail.mail import apply_mail_action
|
from paperless_mail.mail import apply_mail_action
|
||||||
|
from paperless_mail.mail import error_callback
|
||||||
from paperless_mail.mail import get_mailbox
|
from paperless_mail.mail import get_mailbox
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
@@ -2045,6 +2046,44 @@ class TestPostConsumeAction(TestCase):
|
|||||||
self.assertIn("Test Exception", processed_mail.error)
|
self.assertIn("Test Exception", processed_mail.error)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestErrorCallback:
|
||||||
|
def test_error_callback_is_idempotent_for_same_mail(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A mail rule and a mail that failed to be consumed
|
||||||
|
WHEN:
|
||||||
|
- error_callback is invoked more than once for the same mail, as
|
||||||
|
happens when task_allow_error_cb_on_chord_header fires the
|
||||||
|
errback once per failed header task in a chord
|
||||||
|
THEN:
|
||||||
|
- Only one ProcessedMail row is created for that mail
|
||||||
|
"""
|
||||||
|
rule = MailRuleFactory()
|
||||||
|
message_uid = "12345"
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
error_callback(
|
||||||
|
None,
|
||||||
|
Exception("Test Exception"),
|
||||||
|
None,
|
||||||
|
rule_id=rule.pk,
|
||||||
|
message_uid=message_uid,
|
||||||
|
message_subject="Test Subject",
|
||||||
|
message_date=timezone.make_aware(
|
||||||
|
timezone.datetime(2023, 1, 1, 12, 0, 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
processed_mails = ProcessedMail.objects.filter(
|
||||||
|
rule=rule,
|
||||||
|
uid=message_uid,
|
||||||
|
folder=rule.folder,
|
||||||
|
)
|
||||||
|
assert processed_mails.count() == 1
|
||||||
|
assert processed_mails.get().status == "FAILED"
|
||||||
|
|
||||||
|
|
||||||
class TestManagementCommand(TestCase):
|
class TestManagementCommand(TestCase):
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
|
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
|||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
@@ -1116,14 +1116,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djangorestframework"
|
name = "djangorestframework"
|
||||||
version = "3.17.1"
|
version = "3.17.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5014,10 +5014,10 @@ version = "2.13.0+cpu"
|
|||||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user