mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-17 05:18:12 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92d638cc74 |
No files matched your search
+1
-1
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock>=3.32,<4.1",
|
"filelock~=3.32.0",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.3",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ module.exports = {
|
|||||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||||
useESM: true,
|
useESM: true,
|
||||||
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
|
|
||||||
isolatedModules: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -624,8 +624,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
margin-left: 0.4em;
|
display: none;
|
||||||
vertical-align: 0.155em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .dropdown-menu {
|
.navbar .dropdown-menu {
|
||||||
|
|||||||
+10
-46
@@ -484,22 +484,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.textFilter).toBeNull()
|
expect(component.textFilter).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest title filter rules alongside a fulltext query with date created', () => {
|
|
||||||
component.filterRules = [
|
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
|
||||||
value: 'created:[-1 week to now]',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
|
|
||||||
expect(component.textFilter).toEqual('foo')
|
|
||||||
expect(component.textFilterTarget).toEqual('title') // TEXT_FILTER_TARGET_TITLE
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ingest text filter rules for fulltext query that include date added', () => {
|
it('should ingest text filter rules for fulltext query that include date added', () => {
|
||||||
expect(component.dateAddedRelativeDate).toBeNull()
|
expect(component.dateAddedRelativeDate).toBeNull()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1861,7 +1845,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from date created relative date', () => {
|
it('should carry over text filtering on date created with relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1871,18 +1855,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from created and added relative dates', () => {
|
it('should carry over text filtering once with created and added relative dates', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1893,18 +1873,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now],added:[-1 month to now]',
|
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep legacy title filters as a title search when adding a created relative date', () => {
|
it('should convert legacy title filters into full text query when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_TITLE,
|
rule_type: FILTER_TITLE,
|
||||||
@@ -1919,18 +1895,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep simple title filters as a title search when adding a created relative date', () => {
|
it('should convert simple title filters into full text query when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
@@ -1945,13 +1917,9 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -2037,7 +2005,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from date added relative date', () => {
|
it('should carry over text filtering on date added with relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -2047,13 +2015,9 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'added:[-1 week to now]',
|
value: 'foo,added:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1083,6 +1083,23 @@ export class FilterEditorComponent
|
|||||||
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// if had a title / content search and added a relative date we need to carry it over...
|
||||||
|
if (
|
||||||
|
!existingRule &&
|
||||||
|
this._textFilter?.length > 0 &&
|
||||||
|
(this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT ||
|
||||||
|
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE)
|
||||||
|
) {
|
||||||
|
existingRule = filterRules.find(
|
||||||
|
(fr) =>
|
||||||
|
fr.rule_type == FILTER_TITLE_CONTENT ||
|
||||||
|
fr.rule_type == FILTER_SIMPLE_TEXT ||
|
||||||
|
fr.rule_type == FILTER_TITLE ||
|
||||||
|
fr.rule_type == FILTER_SIMPLE_TITLE
|
||||||
|
)
|
||||||
|
existingRule.rule_type = FILTER_FULLTEXT_QUERY
|
||||||
|
}
|
||||||
|
|
||||||
let queryArgs = existingRule?.value.split(',') ?? []
|
let queryArgs = existingRule?.value.split(',') ?? []
|
||||||
if (this.dateCreatedRelativeDate !== null) {
|
if (this.dateCreatedRelativeDate !== null) {
|
||||||
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<context context-type="linenumber">386,393</context>
|
<context context-type="linenumber">386,393</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||||
<target state="translated"> スライド <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> / <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
|
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
@@ -479,7 +479,7 @@
|
|||||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||||
<context context-type="linenumber">201</context>
|
<context context-type="linenumber">201</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">タグ、取引先、ドキュメントの種類、保存先パス、カスタムフィールドなどの属性をここで管理できます。これらはドキュメントの編集画面から作成することもできます。</target>
|
<target state="needs-translation">Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7851939076947092983" datatype="html">
|
<trans-unit id="7851939076947092983" datatype="html">
|
||||||
<source>Manage e-mail accounts and rules for automatically importing documents.</source>
|
<source>Manage e-mail accounts and rules for automatically importing documents.</source>
|
||||||
@@ -507,7 +507,7 @@
|
|||||||
<context context-type="sourcefile">src/app/app.component.ts</context>
|
<context context-type="sourcefile">src/app/app.component.ts</context>
|
||||||
<context context-type="linenumber">225</context>
|
<context context-type="linenumber">225</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">タスクでは、バックグラウンド処理の進行状況、対応が必要な項目、最近完了した処理を確認できます。</target>
|
<target state="needs-translation">Tasks helps you track background work, what needs attention, and what recently completed.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2755033621682206902" datatype="html">
|
<trans-unit id="2755033621682206902" datatype="html">
|
||||||
<source>Check out the settings for various tweaks to the web app.</source>
|
<source>Check out the settings for various tweaks to the web app.</source>
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">35</context>
|
<context context-type="linenumber">35</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">この値は、Paperless の外で設定されている <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>を上書きします。</target>
|
<target state="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7221396516204435584" datatype="html">
|
<trans-unit id="7221396516204435584" datatype="html">
|
||||||
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
|
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
|
||||||
@@ -579,7 +579,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">37</context>
|
<context context-type="linenumber">37</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> はPaperlessの外部で設定されています。ここに値を入力すると、その設定を上書きできます。</target>
|
<target state="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8318849619178340389" datatype="html">
|
<trans-unit id="8318849619178340389" datatype="html">
|
||||||
<source>Use the externally configured value</source>
|
<source>Use the externally configured value</source>
|
||||||
@@ -587,7 +587,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">42</context>
|
<context context-type="linenumber">42</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">外部で設定された値を使用</target>
|
<target state="needs-translation">Use the externally configured value</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6032629623003430385" datatype="html">
|
<trans-unit id="6032629623003430385" datatype="html">
|
||||||
<source>Reset to external</source>
|
<source>Reset to external</source>
|
||||||
@@ -595,7 +595,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">43</context>
|
<context context-type="linenumber">43</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">外部設定に戻す</target>
|
<target state="needs-translation">Reset to external</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7808756054397155068" datatype="html">
|
<trans-unit id="7808756054397155068" datatype="html">
|
||||||
<source>Reset</source>
|
<source>Reset</source>
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
||||||
<context context-type="linenumber">18,19</context>
|
<context context-type="linenumber">18,19</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">行</target>
|
<target state="needs-translation">lines</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8838884664569764142" datatype="html">
|
<trans-unit id="8838884664569764142" datatype="html">
|
||||||
<source>Auto refresh</source>
|
<source>Auto refresh</source>
|
||||||
@@ -1003,7 +1003,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
||||||
<context context-type="linenumber">63,64</context>
|
<context context-type="linenumber">63,64</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">一番下へ移動</target>
|
<target state="needs-translation">Jump to bottom</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1255048712725285892" datatype="html">
|
<trans-unit id="1255048712725285892" datatype="html">
|
||||||
<source>Options to customize appearance, notifications and more. Settings apply to the <strong>current user only</strong>.</source>
|
<source>Options to customize appearance, notifications and more. Settings apply to the <strong>current user only</strong>.</source>
|
||||||
@@ -1135,7 +1135,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">115,116</context>
|
<context context-type="linenumber">115,116</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">サイドバーに表示するアイテム:</target>
|
<target state="needs-translation">Sidebar items to show:</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1356890996281769972" datatype="html">
|
<trans-unit id="1356890996281769972" datatype="html">
|
||||||
<source>Dark mode</source>
|
<source>Dark mode</source>
|
||||||
@@ -1323,7 +1323,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">201</context>
|
<context context-type="linenumber">201</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">サイドバーの保存済みビューにドキュメント数を表示</target>
|
<target state="needs-translation">Show document counts in sidebar saved views</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8939587804990976924" datatype="html">
|
<trans-unit id="8939587804990976924" datatype="html">
|
||||||
<source>Items per page</source>
|
<source>Items per page</source>
|
||||||
@@ -1403,7 +1403,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">259,260</context>
|
<context context-type="linenumber">259,260</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">提案機能のデフォルト設定</target>
|
<target state="needs-translation">Suggestions default to</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6251586038459969294" datatype="html">
|
<trans-unit id="6251586038459969294" datatype="html">
|
||||||
<source>Both</source>
|
<source>Both</source>
|
||||||
@@ -1411,7 +1411,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">264</context>
|
<context context-type="linenumber">264</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">両方</target>
|
<target state="needs-translation">Both</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8967224283122923634" datatype="html">
|
<trans-unit id="8967224283122923634" datatype="html">
|
||||||
<source>ML only</source>
|
<source>ML only</source>
|
||||||
@@ -1427,7 +1427,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">268</context>
|
<context context-type="linenumber">268</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">AIのみ</target>
|
<target state="needs-translation">AI only</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1043449647727456069" datatype="html">
|
<trans-unit id="1043449647727456069" datatype="html">
|
||||||
<source>Automatically request suggestions for inbox documents</source>
|
<source>Automatically request suggestions for inbox documents</source>
|
||||||
@@ -1435,7 +1435,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">276</context>
|
<context context-type="linenumber">276</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">受信トレイ内のドキュメントに対して自動的に提案をリクエストする</target>
|
<target state="needs-translation">Automatically request suggestions for inbox documents</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="460700039378563125" datatype="html">
|
<trans-unit id="460700039378563125" datatype="html">
|
||||||
<source>If un-checked, suggestions must be requested via the Suggest button.</source>
|
<source>If un-checked, suggestions must be requested via the Suggest button.</source>
|
||||||
@@ -1443,7 +1443,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">276</context>
|
<context context-type="linenumber">276</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">チェックを外した場合、提案を取得するには「提案」ボタンを押す必要があります。</target>
|
<target state="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8793267604636304297" datatype="html">
|
<trans-unit id="8793267604636304297" datatype="html">
|
||||||
<source>Show document thumbnail during loading</source>
|
<source>Show document thumbnail during loading</source>
|
||||||
@@ -1467,7 +1467,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">300,301</context>
|
<context context-type="linenumber">300,301</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">ドキュメントの詳細ページで非表示にするフィールドは、チェックを外してください。</target>
|
<target state="needs-translation">Uncheck fields to hide them on the document details page.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8508424367627989968" datatype="html">
|
<trans-unit id="8508424367627989968" datatype="html">
|
||||||
<source>Bulk editing</source>
|
<source>Bulk editing</source>
|
||||||
@@ -1507,7 +1507,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1907</context>
|
<context context-type="linenumber">1907</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">PDF エディター</target>
|
<target state="needs-translation">PDF Editor</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1577733187050997705" datatype="html">
|
<trans-unit id="1577733187050997705" datatype="html">
|
||||||
<source>Default editing mode</source>
|
<source>Default editing mode</source>
|
||||||
@@ -1515,7 +1515,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">317,318</context>
|
<context context-type="linenumber">317,318</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">デフォルトの編集モード</target>
|
<target state="needs-translation">Default editing mode</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7273640930165035289" datatype="html">
|
<trans-unit id="7273640930165035289" datatype="html">
|
||||||
<source>Create new document(s)</source>
|
<source>Create new document(s)</source>
|
||||||
@@ -1527,7 +1527,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
|
||||||
<context context-type="linenumber">82,83</context>
|
<context context-type="linenumber">82,83</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">新しいドキュメントを作成</target>
|
<target state="needs-translation">Create new document(s)</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7860582931776068318" datatype="html">
|
<trans-unit id="7860582931776068318" datatype="html">
|
||||||
<source>Add document version</source>
|
<source>Add document version</source>
|
||||||
@@ -1539,7 +1539,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
|
||||||
<context context-type="linenumber">87,88</context>
|
<context context-type="linenumber">87,88</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">ドキュメントのバージョンを追加</target>
|
<target state="needs-translation">Add document version</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8104421162933956065" datatype="html">
|
<trans-unit id="8104421162933956065" datatype="html">
|
||||||
<source>Notes</source>
|
<source>Notes</source>
|
||||||
@@ -1639,7 +1639,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">347</context>
|
<context context-type="linenumber">347</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">これらの設定は、Web UIから作成されたオブジェクト(タグ、メールルールなど)に対して、このユーザーアカウントに適用されます。ドキュメントには適用されません。</target>
|
<target state="needs-translation"> Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents. </target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4292903881380648974" datatype="html">
|
<trans-unit id="4292903881380648974" datatype="html">
|
||||||
<source>Default Owner</source>
|
<source>Default Owner</source>
|
||||||
@@ -2203,7 +2203,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">331</context>
|
<context context-type="linenumber">331</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">タスク</target>
|
<target state="needs-translation">Tasks</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8492095365580052820" datatype="html">
|
<trans-unit id="8492095365580052820" datatype="html">
|
||||||
<source>Tasks shows detailed information about document consumption and system tasks.</source>
|
<source>Tasks shows detailed information about document consumption and system tasks.</source>
|
||||||
@@ -2211,7 +2211,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">4,5</context>
|
<context context-type="linenumber">4,5</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">タスクでは、ドキュメントの取り込み処理やシステムタスクに関する詳細情報を確認できます。</target>
|
<target state="needs-translation">Tasks shows detailed information about document consumption and system tasks.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="103921551219467537" datatype="html">
|
<trans-unit id="103921551219467537" datatype="html">
|
||||||
<source>Clear selection</source>
|
<source>Clear selection</source>
|
||||||
@@ -2235,7 +2235,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">16</context>
|
<context context-type="linenumber">16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">すべて閉じる</target>
|
<target state="needs-translation">Dismiss all</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1616102757855967475" datatype="html">
|
<trans-unit id="1616102757855967475" datatype="html">
|
||||||
<source>All</source>
|
<source>All</source>
|
||||||
@@ -2295,7 +2295,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">219</context>
|
<context context-type="linenumber">219</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">すべての種類</target>
|
<target state="needs-translation">All types</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="131016739441837046" datatype="html">
|
<trans-unit id="131016739441837046" datatype="html">
|
||||||
<source>All sources</source>
|
<source>All sources</source>
|
||||||
@@ -2307,7 +2307,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">231</context>
|
<context context-type="linenumber">231</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">すべてのソース</target>
|
<target state="needs-translation">All sources</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3643866539136239218" datatype="html">
|
<trans-unit id="3643866539136239218" datatype="html">
|
||||||
<source>Clear search</source>
|
<source>Clear search</source>
|
||||||
@@ -2327,7 +2327,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">92,93</context>
|
<context context-type="linenumber">92,93</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">タスクを検索</target>
|
<target state="needs-translation">Search tasks</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6849725902312323996" datatype="html">
|
<trans-unit id="6849725902312323996" datatype="html">
|
||||||
<source>Reset filters</source>
|
<source>Reset filters</source>
|
||||||
@@ -2351,7 +2351,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">122</context>
|
<context context-type="linenumber">122</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">{VAR_PLURAL, plural, =1 {1件のタスク} other {<x id="INTERPOLATION"/>件のタスク}}</target>
|
<target state="needs-translation">{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8953033926734869941" datatype="html">
|
<trans-unit id="8953033926734869941" datatype="html">
|
||||||
<source>Name</source>
|
<source>Name</source>
|
||||||
@@ -2651,7 +2651,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">207</context>
|
<context context-type="linenumber">207</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">タスクの詳細を表示</target>
|
<target state="needs-translation">View task details</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1536087519743707362" datatype="html">
|
<trans-unit id="1536087519743707362" datatype="html">
|
||||||
<source>Dismiss</source>
|
<source>Dismiss</source>
|
||||||
@@ -2691,7 +2691,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">232,233</context>
|
<context context-type="linenumber">232,233</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">結果メッセージ</target>
|
<target state="needs-translation">Result message</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6621329748219109148" datatype="html">
|
<trans-unit id="6621329748219109148" datatype="html">
|
||||||
<source>Duplicate</source>
|
<source>Duplicate</source>
|
||||||
@@ -2699,7 +2699,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">239,240</context>
|
<context context-type="linenumber">239,240</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">重複</target>
|
<target state="needs-translation">Duplicate</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7593555694782789615" datatype="html">
|
<trans-unit id="7593555694782789615" datatype="html">
|
||||||
<source>Open</source>
|
<source>Open</source>
|
||||||
@@ -2739,7 +2739,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">257,258</context>
|
<context context-type="linenumber">257,258</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">入力データ</target>
|
<target state="needs-translation">Input data</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1585185618099050920" datatype="html">
|
<trans-unit id="1585185618099050920" datatype="html">
|
||||||
<source>Result data</source>
|
<source>Result data</source>
|
||||||
@@ -2747,7 +2747,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">263,264</context>
|
<context context-type="linenumber">263,264</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">結果データ</target>
|
<target state="needs-translation">Result data</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7976920528153858271" datatype="html">
|
<trans-unit id="7976920528153858271" datatype="html">
|
||||||
<source>No tasks match the current filters.</source>
|
<source>No tasks match the current filters.</source>
|
||||||
@@ -2755,7 +2755,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">285,287</context>
|
<context context-type="linenumber">285,287</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">現在のフィルター条件に一致するタスクはありません。</target>
|
<target state="needs-translation">No tasks match the current filters.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2525230676386818985" datatype="html">
|
<trans-unit id="2525230676386818985" datatype="html">
|
||||||
<source>Result</source>
|
<source>Result</source>
|
||||||
@@ -2771,7 +2771,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">55</context>
|
<context context-type="linenumber">55</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">注意が必要</target>
|
<target state="needs-translation">Needs attention</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3307214558906712688" datatype="html">
|
<trans-unit id="3307214558906712688" datatype="html">
|
||||||
<source>In progress</source>
|
<source>In progress</source>
|
||||||
@@ -2779,7 +2779,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">56</context>
|
<context context-type="linenumber">56</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">進行中</target>
|
<target state="needs-translation">In progress</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1371178633976505412" datatype="html">
|
<trans-unit id="1371178633976505412" datatype="html">
|
||||||
<source>Recently completed</source>
|
<source>Recently completed</source>
|
||||||
@@ -2787,7 +2787,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">57</context>
|
<context context-type="linenumber">57</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">最近完了したタスク</target>
|
<target state="needs-translation">Recently completed</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2936452645673957909" datatype="html">
|
<trans-unit id="2936452645673957909" datatype="html">
|
||||||
<source>Consume File</source>
|
<source>Consume File</source>
|
||||||
@@ -2795,7 +2795,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">66</context>
|
<context context-type="linenumber">66</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">ファイルの取り込み</target>
|
<target state="needs-translation">Consume File</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5622386857946132209" datatype="html">
|
<trans-unit id="5622386857946132209" datatype="html">
|
||||||
<source>Train Classifier</source>
|
<source>Train Classifier</source>
|
||||||
@@ -2811,7 +2811,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">74</context>
|
<context context-type="linenumber">74</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">整合性チェック</target>
|
<target state="needs-translation">Sanity Check</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7502272564743467653" datatype="html">
|
<trans-unit id="7502272564743467653" datatype="html">
|
||||||
<source>Mail Fetch</source>
|
<source>Mail Fetch</source>
|
||||||
@@ -2839,7 +2839,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">80</context>
|
<context context-type="linenumber">80</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">ゴミ箱を空にする</target>
|
<target state="needs-translation">Empty Trash</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4173754487295857311" datatype="html">
|
<trans-unit id="4173754487295857311" datatype="html">
|
||||||
<source>Check Workflows</source>
|
<source>Check Workflows</source>
|
||||||
@@ -2847,7 +2847,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">84</context>
|
<context context-type="linenumber">84</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">ワークフローの確認</target>
|
<target state="needs-translation">Check Workflows</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2065831805515896240" datatype="html">
|
<trans-unit id="2065831805515896240" datatype="html">
|
||||||
<source>Bulk Update</source>
|
<source>Bulk Update</source>
|
||||||
@@ -2855,7 +2855,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">88</context>
|
<context context-type="linenumber">88</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">一括更新</target>
|
<target state="needs-translation">Bulk Update</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5062348912423871240" datatype="html">
|
<trans-unit id="5062348912423871240" datatype="html">
|
||||||
<source>Reprocess Document</source>
|
<source>Reprocess Document</source>
|
||||||
@@ -2863,7 +2863,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">92</context>
|
<context context-type="linenumber">92</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">ドキュメントの再処理</target>
|
<target state="needs-translation">Reprocess Document</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6903696804811148799" datatype="html">
|
<trans-unit id="6903696804811148799" datatype="html">
|
||||||
<source>Build Share Link</source>
|
<source>Build Share Link</source>
|
||||||
@@ -2871,7 +2871,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">96</context>
|
<context context-type="linenumber">96</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">共有リンクの生成</target>
|
<target state="needs-translation">Build Share Link</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6581973658756561124" datatype="html">
|
<trans-unit id="6581973658756561124" datatype="html">
|
||||||
<source>Bulk Delete</source>
|
<source>Bulk Delete</source>
|
||||||
@@ -2879,7 +2879,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">100</context>
|
<context context-type="linenumber">100</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">一括削除</target>
|
<target state="needs-translation">Bulk Delete</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5672013191898828327" datatype="html">
|
<trans-unit id="5672013191898828327" datatype="html">
|
||||||
<source>Apply AI Suggestions</source>
|
<source>Apply AI Suggestions</source>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<context context-type="linenumber">386,393</context>
|
<context context-type="linenumber">386,393</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||||
<target state="translated"> Shlid <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> i <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/></target>
|
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">35</context>
|
<context context-type="linenumber">35</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Ky vlerë mbizotëron <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, i cili është vendosur jashtë Paperless.</target>
|
<target state="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7221396516204435584" datatype="html">
|
<trans-unit id="7221396516204435584" datatype="html">
|
||||||
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
|
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
|
||||||
@@ -579,7 +579,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">37</context>
|
<context context-type="linenumber">37</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> është vendosur jashtë Paperless. Shkruani një vlerë këtu për ta mbingarkuar atë.</target>
|
<target state="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8318849619178340389" datatype="html">
|
<trans-unit id="8318849619178340389" datatype="html">
|
||||||
<source>Use the externally configured value</source>
|
<source>Use the externally configured value</source>
|
||||||
@@ -587,7 +587,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">42</context>
|
<context context-type="linenumber">42</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Përdorni vlerën e konfiguruar nga jashtë</target>
|
<target state="needs-translation">Use the externally configured value</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6032629623003430385" datatype="html">
|
<trans-unit id="6032629623003430385" datatype="html">
|
||||||
<source>Reset to external</source>
|
<source>Reset to external</source>
|
||||||
@@ -595,7 +595,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||||
<context context-type="linenumber">43</context>
|
<context context-type="linenumber">43</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Rinisni në të jashtëm</target>
|
<target state="needs-translation">Reset to external</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7808756054397155068" datatype="html">
|
<trans-unit id="7808756054397155068" datatype="html">
|
||||||
<source>Reset</source>
|
<source>Reset</source>
|
||||||
@@ -1135,7 +1135,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">115,116</context>
|
<context context-type="linenumber">115,116</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Elementët e shiritit anësor për t'u shfaqur:</target>
|
<target state="needs-translation">Sidebar items to show:</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1356890996281769972" datatype="html">
|
<trans-unit id="1356890996281769972" datatype="html">
|
||||||
<source>Dark mode</source>
|
<source>Dark mode</source>
|
||||||
@@ -1403,7 +1403,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">259,260</context>
|
<context context-type="linenumber">259,260</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Sugjerimet parazgjedhore janë</target>
|
<target state="needs-translation">Suggestions default to</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6251586038459969294" datatype="html">
|
<trans-unit id="6251586038459969294" datatype="html">
|
||||||
<source>Both</source>
|
<source>Both</source>
|
||||||
@@ -1411,7 +1411,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">264</context>
|
<context context-type="linenumber">264</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Të dyja</target>
|
<target state="needs-translation">Both</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8967224283122923634" datatype="html">
|
<trans-unit id="8967224283122923634" datatype="html">
|
||||||
<source>ML only</source>
|
<source>ML only</source>
|
||||||
@@ -1419,7 +1419,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">266</context>
|
<context context-type="linenumber">266</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Vetëm ML</target>
|
<target state="needs-translation">ML only</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6418969597665857901" datatype="html">
|
<trans-unit id="6418969597665857901" datatype="html">
|
||||||
<source>AI only</source>
|
<source>AI only</source>
|
||||||
@@ -1427,7 +1427,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">268</context>
|
<context context-type="linenumber">268</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Vetëm AI</target>
|
<target state="needs-translation">AI only</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1043449647727456069" datatype="html">
|
<trans-unit id="1043449647727456069" datatype="html">
|
||||||
<source>Automatically request suggestions for inbox documents</source>
|
<source>Automatically request suggestions for inbox documents</source>
|
||||||
@@ -1435,7 +1435,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">276</context>
|
<context context-type="linenumber">276</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Kërkoni automatikisht sugjerime për dokumentet në kutinë e postës elektronike</target>
|
<target state="needs-translation">Automatically request suggestions for inbox documents</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="460700039378563125" datatype="html">
|
<trans-unit id="460700039378563125" datatype="html">
|
||||||
<source>If un-checked, suggestions must be requested via the Suggest button.</source>
|
<source>If un-checked, suggestions must be requested via the Suggest button.</source>
|
||||||
@@ -1443,7 +1443,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
<context context-type="linenumber">276</context>
|
<context context-type="linenumber">276</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Nëse nuk është shënuar, sugjerimet duhet të kërkohen përmes butonit Sugjero.</target>
|
<target state="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8793267604636304297" datatype="html">
|
<trans-unit id="8793267604636304297" datatype="html">
|
||||||
<source>Show document thumbnail during loading</source>
|
<source>Show document thumbnail during loading</source>
|
||||||
@@ -2319,7 +2319,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||||
<context context-type="linenumber">21</context>
|
<context context-type="linenumber">21</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Pastro kërkimin</target>
|
<target state="needs-translation">Clear search</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3570013039254960445" datatype="html">
|
<trans-unit id="3570013039254960445" datatype="html">
|
||||||
<source>Search tasks</source>
|
<source>Search tasks</source>
|
||||||
@@ -2651,7 +2651,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||||
<context context-type="linenumber">207</context>
|
<context context-type="linenumber">207</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Shiko detajet e detyrës</target>
|
<target state="needs-translation">View task details</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1536087519743707362" datatype="html">
|
<trans-unit id="1536087519743707362" datatype="html">
|
||||||
<source>Dismiss</source>
|
<source>Dismiss</source>
|
||||||
@@ -2887,7 +2887,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">104</context>
|
<context context-type="linenumber">104</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Apliko sugjerimet e AI</target>
|
<target state="needs-translation">Apply AI Suggestions</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9172233176401579786" datatype="html">
|
<trans-unit id="9172233176401579786" datatype="html">
|
||||||
<source>Scheduled</source>
|
<source>Scheduled</source>
|
||||||
@@ -3783,7 +3783,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">42</context>
|
<context context-type="linenumber">42</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Menuja e përdoruesit</target>
|
<target state="needs-translation">User menu</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2448391510242468907" datatype="html">
|
<trans-unit id="2448391510242468907" datatype="html">
|
||||||
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
|
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
|
||||||
@@ -3939,7 +3939,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
|
||||||
<context context-type="linenumber">2,3</context>
|
<context context-type="linenumber">2,3</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Ndani lidhjet</target>
|
<target state="needs-translation">Share links</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7844706011418789951" datatype="html">
|
<trans-unit id="7844706011418789951" datatype="html">
|
||||||
<source>Administration</source>
|
<source>Administration</source>
|
||||||
@@ -4015,7 +4015,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">403</context>
|
<context context-type="linenumber">403</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Konfiguro kontrollin e përditësimeve</target>
|
<target state="needs-translation">Configure update checking</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1542489069631984294" datatype="html">
|
<trans-unit id="1542489069631984294" datatype="html">
|
||||||
<source>Sidebar views updated</source>
|
<source>Sidebar views updated</source>
|
||||||
@@ -4259,7 +4259,7 @@
|
|||||||
<context context-type="sourcefile">src/app/data/saved-view-icons.ts</context>
|
<context context-type="sourcefile">src/app/data/saved-view-icons.ts</context>
|
||||||
<context context-type="linenumber">21</context>
|
<context context-type="linenumber">21</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Bisedë</target>
|
<target state="needs-translation">Chat</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8066608938393600549" datatype="html">
|
<trans-unit id="8066608938393600549" datatype="html">
|
||||||
<source>Message</source>
|
<source>Message</source>
|
||||||
@@ -4379,7 +4379,7 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component.html</context>
|
||||||
<context context-type="linenumber">8,9</context>
|
<context context-type="linenumber">8,9</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<target state="translated">Dokumenti rrënjësor:</target>
|
<target state="needs-translation">Root document:</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2121026240713807342" datatype="html">
|
<trans-unit id="2121026240713807342" datatype="html">
|
||||||
<source>Versions (oldest first):</source>
|
<source>Versions (oldest first):</source>
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
|||||||
cutoff so that uncertain predictions are discarded rather than assigned.
|
cutoff so that uncertain predictions are discarded rather than assigned.
|
||||||
"""
|
"""
|
||||||
probas = classifier.predict_proba(X)[0]
|
probas = classifier.predict_proba(X)[0]
|
||||||
if len(classifier.classes_) == 1:
|
|
||||||
# scikit-learn treats a single-class fit as binary, so predict_proba
|
|
||||||
# returns [1 - p, p] for one class. Column 0 is the real class.
|
|
||||||
probas = probas[:1]
|
|
||||||
best_idx = int(probas.argmax())
|
best_idx = int(probas.argmax())
|
||||||
best_class = int(classifier.classes_[best_idx])
|
best_class = int(classifier.classes_[best_idx])
|
||||||
|
|
||||||
|
|||||||
@@ -130,14 +130,12 @@ class DocumentMetadataOverrides:
|
|||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
doc,
|
doc,
|
||||||
only_with_perms_in=["view_document"],
|
only_with_perms_in=["view_document"],
|
||||||
with_group_users=False,
|
|
||||||
).values_list("id", flat=True),
|
).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
overrides.change_users = list(
|
overrides.change_users = list(
|
||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
doc,
|
doc,
|
||||||
only_with_perms_in=["change_document"],
|
only_with_perms_in=["change_document"],
|
||||||
with_group_users=False,
|
|
||||||
).values_list("id", flat=True),
|
).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
overrides.custom_fields = {
|
overrides.custom_fields = {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from itertools import islice
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from allauth.mfa.models import Authenticator
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.socialaccount.models import SocialAccount
|
from allauth.socialaccount.models import SocialAccount
|
||||||
@@ -71,15 +70,6 @@ from paperless.models import ApplicationConfiguration
|
|||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
|
||||||
# Derived names replace the original's extension with these suffixes
|
|
||||||
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
|
|
||||||
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
|
|
||||||
# Leave room for the longest derived suffix, which replaces the original's extension
|
|
||||||
MAX_BASE_NAME_BYTES: Final[int] = Document.MAX_PUBLIC_FILENAME_BYTES - max(
|
|
||||||
len(THUMBNAIL_SUFFIX),
|
|
||||||
len(ARCHIVE_SUFFIX),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_queryset_batched(
|
def serialize_queryset_batched(
|
||||||
queryset: "QuerySet[Any]",
|
queryset: "QuerySet[Any]",
|
||||||
@@ -522,10 +512,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
counter=filename_counter,
|
counter=filename_counter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
base_name = document.get_public_filename(
|
base_name = document.get_public_filename(counter=filename_counter)
|
||||||
counter=filename_counter,
|
|
||||||
max_bytes=MAX_BASE_NAME_BYTES,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The thumbnail and archive names are derived from the stem, so
|
# The thumbnail and archive names are derived from the stem, so
|
||||||
# uniqueness must hold without the original's extension
|
# uniqueness must hold without the original's extension
|
||||||
@@ -554,7 +541,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
document_dict[EXPORTER_FILE_NAME] = original_arc
|
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||||
|
|
||||||
if not self.no_thumbnail:
|
if not self.no_thumbnail:
|
||||||
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
|
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||||
thumbnail_arc = thumbnail_name.as_posix()
|
thumbnail_arc = thumbnail_name.as_posix()
|
||||||
@@ -563,7 +550,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
thumbnail_arc = None
|
thumbnail_arc = None
|
||||||
|
|
||||||
if not self.no_archive and document.has_archive_version:
|
if not self.no_archive and document.has_archive_version:
|
||||||
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
|
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
||||||
if self.use_folder_prefix:
|
if self.use_folder_prefix:
|
||||||
archive_name = Path("archive") / archive_name
|
archive_name = Path("archive") / archive_name
|
||||||
archive_arc = archive_name.as_posix()
|
archive_arc = archive_name.as_posix()
|
||||||
|
|||||||
+12
-23
@@ -157,8 +157,6 @@ class StoragePath(MatchingModel):
|
|||||||
|
|
||||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||||
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
|
||||||
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
|
||||||
|
|
||||||
correspondent = models.ForeignKey(
|
correspondent = models.ForeignKey(
|
||||||
Correspondent,
|
Correspondent,
|
||||||
@@ -486,37 +484,28 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
def archive_file(self):
|
def archive_file(self):
|
||||||
return Path(self.archive_path).open("rb")
|
return Path(self.archive_path).open("rb")
|
||||||
|
|
||||||
def get_public_filename(
|
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
archive: bool = False,
|
|
||||||
counter: int = 0,
|
|
||||||
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
Returns a sanitized filename for the document, not including any paths.
|
Returns a sanitized filename for the document, not including any paths.
|
||||||
|
|
||||||
The name fits in max_bytes (UTF-8). A long title is shortened before
|
|
||||||
the counter and extension are appended, because sanitize_filename
|
|
||||||
truncates from the end. The tail is cut only when max_bytes is
|
|
||||||
smaller than the tail itself.
|
|
||||||
"""
|
"""
|
||||||
# Root owns metadata for all versions
|
# Root owns metadata for all versions
|
||||||
context_document = (
|
context_document = (
|
||||||
self.root_document if self.root_document_id is not None else self
|
self.root_document if self.root_document_id is not None else self
|
||||||
)
|
)
|
||||||
|
result = str(context_document)
|
||||||
|
|
||||||
extension = ".pdf" if archive else self.file_type
|
if counter:
|
||||||
tail = f"_{counter:02}{extension}" if counter else extension
|
result += f"_{counter:02}"
|
||||||
|
|
||||||
head_bytes = max(0, max_bytes - len(tail.encode()))
|
if suffix:
|
||||||
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
|
result += suffix
|
||||||
|
|
||||||
return pathvalidate.sanitize_filename(
|
if archive:
|
||||||
head + tail,
|
result += ".pdf"
|
||||||
replacement_text="-",
|
else:
|
||||||
max_len=max_bytes,
|
result += self.file_type
|
||||||
)
|
|
||||||
|
return pathvalidate.sanitize_filename(result, replacement_text="-")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_type(self):
|
def file_type(self):
|
||||||
|
|||||||
@@ -511,28 +511,6 @@ def restrict_queryset_to_visible(
|
|||||||
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
||||||
|
|
||||||
|
|
||||||
def user_is_active_superuser(user: User | None) -> bool:
|
|
||||||
"""
|
|
||||||
True only for an active, authenticated superuser.
|
|
||||||
"""
|
|
||||||
return user is not None and user_is_unrestricted(user)
|
|
||||||
|
|
||||||
|
|
||||||
def restrict_queryset_to_owner_visible(
|
|
||||||
queryset: QuerySet[ModelT],
|
|
||||||
user: User | None,
|
|
||||||
perm: str,
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""
|
|
||||||
Like ``restrict_queryset_to_visible``, but ``user=None`` sees only unowned
|
|
||||||
rows (``permitted_object_ids(None, ...)`` semantics) instead of every row.
|
|
||||||
See ``user_is_active_superuser``.
|
|
||||||
"""
|
|
||||||
if user_is_active_superuser(user):
|
|
||||||
return queryset
|
|
||||||
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
|
||||||
|
|
||||||
|
|
||||||
def permitted_document_ids(
|
def permitted_document_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -926,7 +926,6 @@ class TantivyBackend:
|
|||||||
sort_field: str | None = None,
|
sort_field: str | None = None,
|
||||||
sort_reverse: bool = False,
|
sort_reverse: bool = False,
|
||||||
search_mode: SearchMode = SearchMode.QUERY,
|
search_mode: SearchMode = SearchMode.QUERY,
|
||||||
filter_query: str | None = None,
|
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""
|
"""
|
||||||
@@ -942,7 +941,6 @@ class TantivyBackend:
|
|||||||
sort_field: Field to sort by (None for relevance ranking)
|
sort_field: Field to sort by (None for relevance ranking)
|
||||||
sort_reverse: Whether to reverse the sort order
|
sort_reverse: Whether to reverse the sort order
|
||||||
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
|
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
|
||||||
filter_query: Optional advanced query results must also match
|
|
||||||
limit: Maximum number of IDs to return (None = all matching docs)
|
limit: Maximum number of IDs to return (None = all matching docs)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -950,13 +948,6 @@ class TantivyBackend:
|
|||||||
"""
|
"""
|
||||||
self._ensure_open()
|
self._ensure_open()
|
||||||
user_query = self._parse_query(query, search_mode)
|
user_query = self._parse_query(query, search_mode)
|
||||||
if filter_query is not None:
|
|
||||||
user_query = user_query.and_must_match(
|
|
||||||
tantivy.Query.const_score_query(
|
|
||||||
self._parse_query(filter_query, SearchMode.QUERY),
|
|
||||||
0.0, # zero so doesn't affect relevance
|
|
||||||
),
|
|
||||||
)
|
|
||||||
final_query = self._apply_permission_filter(user_query, user)
|
final_query = self._apply_permission_filter(user_query, user)
|
||||||
|
|
||||||
searcher = self._index.searcher()
|
searcher = self._index.searcher()
|
||||||
|
|||||||
@@ -835,7 +835,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
for filters in (
|
for filters in (
|
||||||
{"text": "new doc 2017-03-16"},
|
{"text": "new doc 2017-03-16"},
|
||||||
{"title_search": "apple"},
|
{"title_search": "apple"},
|
||||||
{"title_search": "apple", "query": "created:[-1 year to now]"},
|
|
||||||
):
|
):
|
||||||
with self.subTest(filters=filters):
|
with self.subTest(filters=filters):
|
||||||
get_backend.return_value.search_ids.return_value = [self.doc2.id]
|
get_backend.return_value.search_ids.return_value = [self.doc2.id]
|
||||||
@@ -887,7 +886,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
"all": True,
|
"all": True,
|
||||||
"filters": {
|
"filters": {
|
||||||
"text": "B",
|
"text": "B",
|
||||||
"title_search": "c1",
|
"query": "c1",
|
||||||
},
|
},
|
||||||
"method": "set_storage_path",
|
"method": "set_storage_path",
|
||||||
"parameters": {"storage_path": self.sp1.id},
|
"parameters": {"storage_path": self.sp1.id},
|
||||||
|
|||||||
@@ -245,68 +245,8 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.data["count"], 1)
|
self.assertEqual(response.data["count"], 1)
|
||||||
self.assertEqual(response.data["results"][0]["id"], title_match.id)
|
self.assertEqual(response.data["results"][0]["id"], title_match.id)
|
||||||
|
|
||||||
def test_simple_search_combined_with_query_filter(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document with the term in its title, created this year
|
|
||||||
- A document with the term only in its content, created this year
|
|
||||||
- A document with the term in its title, created years ago
|
|
||||||
WHEN:
|
|
||||||
- A title or text search is combined with a relative created
|
|
||||||
date query, as the filter editor sends it
|
|
||||||
THEN:
|
|
||||||
- The simple search keeps its own field scope and the query only
|
|
||||||
narrows the results (issue #14436)
|
|
||||||
"""
|
|
||||||
this_year = timezone.localdate().replace(month=1, day=1)
|
|
||||||
title_match = Document.objects.create(
|
|
||||||
title="foo report",
|
|
||||||
content="nothing relevant",
|
|
||||||
checksum="F1",
|
|
||||||
created=this_year,
|
|
||||||
pk=21,
|
|
||||||
)
|
|
||||||
content_match = Document.objects.create(
|
|
||||||
title="something else",
|
|
||||||
content="foo appears here",
|
|
||||||
checksum="F2",
|
|
||||||
created=this_year,
|
|
||||||
pk=22,
|
|
||||||
)
|
|
||||||
old_title_match = Document.objects.create(
|
|
||||||
title="foo archive",
|
|
||||||
content="nothing relevant",
|
|
||||||
checksum="F3",
|
|
||||||
created=datetime.date(2001, 1, 1),
|
|
||||||
pk=23,
|
|
||||||
)
|
|
||||||
backend = get_backend()
|
|
||||||
for doc in (title_match, content_match, old_title_match):
|
|
||||||
backend.add_or_update(doc)
|
|
||||||
|
|
||||||
query = "created:[-1 year to now]"
|
|
||||||
response = self.client.get(
|
|
||||||
"/api/documents/",
|
|
||||||
{"title_search": "foo", "query": query},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(
|
|
||||||
[r["id"] for r in response.data["results"]],
|
|
||||||
[title_match.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
"/api/documents/",
|
|
||||||
{"text": "foo", "query": query},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertCountEqual(
|
|
||||||
[r["id"] for r in response.data["results"]],
|
|
||||||
[title_match.id, content_match.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_search_rejects_multiple_search_modes(self) -> None:
|
def test_search_rejects_multiple_search_modes(self) -> None:
|
||||||
response = self.client.get("/api/documents/?text=bank&title_search=bank")
|
response = self.client.get("/api/documents/?text=bank&query=bank")
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.data["detail"],
|
response.data["detail"],
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from django.test import override_settings
|
|||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from sklearn.neural_network import MLPClassifier
|
|
||||||
|
|
||||||
from documents.classifier import ClassifierModelCorruptError
|
from documents.classifier import ClassifierModelCorruptError
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
@@ -1037,49 +1036,6 @@ def test_classifier_match_threshold_default() -> None:
|
|||||||
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
|
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
|
||||||
|
|
||||||
|
|
||||||
class TestPredictWithThresholdSingleClass:
|
|
||||||
@pytest.fixture
|
|
||||||
def single_class_model(self) -> tuple[MLPClassifier, np.ndarray]:
|
|
||||||
X = np.random.default_rng(0).random((2, 5))
|
|
||||||
return MLPClassifier(tol=0.01, random_state=0).fit(X, [3, 3]), X[:1]
|
|
||||||
|
|
||||||
def test_returns_only_class(
|
|
||||||
self,
|
|
||||||
single_class_model: tuple[MLPClassifier, np.ndarray],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A classifier fitted on a single class (every document shares one label)
|
|
||||||
WHEN:
|
|
||||||
- A prediction is requested with the threshold disabled
|
|
||||||
THEN:
|
|
||||||
- No error is raised and the only class is returned
|
|
||||||
"""
|
|
||||||
classifier, X = single_class_model
|
|
||||||
|
|
||||||
assert _predict_with_threshold(classifier, X, threshold=0.0) == 3
|
|
||||||
|
|
||||||
def test_applies_threshold(
|
|
||||||
self,
|
|
||||||
single_class_model: tuple[MLPClassifier, np.ndarray],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A classifier fitted on a single class
|
|
||||||
WHEN:
|
|
||||||
- A prediction is requested
|
|
||||||
THEN:
|
|
||||||
- The class's confidence is still compared against the threshold
|
|
||||||
"""
|
|
||||||
classifier, X = single_class_model
|
|
||||||
confidence = classifier.predict_proba(X)[0][0]
|
|
||||||
|
|
||||||
assert _predict_with_threshold(classifier, X, threshold=confidence - 0.01) == 3
|
|
||||||
assert (
|
|
||||||
_predict_with_threshold(classifier, X, threshold=confidence + 0.01) is None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreprocessContent:
|
class TestPreprocessContent:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def samples(self) -> Path:
|
def samples(self) -> Path:
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ from documents.tests.utils import GetConsumerMixin
|
|||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
from paperless_testing.permissions import grant_object
|
|
||||||
|
|
||||||
|
|
||||||
class _BaseNewStyleParser:
|
class _BaseNewStyleParser:
|
||||||
@@ -1685,32 +1683,6 @@ class TestMetadataOverrides(TestCase):
|
|||||||
self.assertEqual(base.actor_id, 2)
|
self.assertEqual(base.actor_id, 2)
|
||||||
self.assertEqual(base.version_label, "v2")
|
self.assertEqual(base.version_label, "v2")
|
||||||
|
|
||||||
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document with view and change granted directly to one user and
|
|
||||||
to a group with another user as a member
|
|
||||||
WHEN:
|
|
||||||
- Overrides are built from the document
|
|
||||||
THEN:
|
|
||||||
- The user lists hold only the directly granted user
|
|
||||||
- The group lists hold the group
|
|
||||||
"""
|
|
||||||
direct_user = UserFactory()
|
|
||||||
group_member = UserFactory()
|
|
||||||
group = Group.objects.create(name="group")
|
|
||||||
group_member.groups.add(group)
|
|
||||||
document = DocumentFactory()
|
|
||||||
grant_object(direct_user, document, "view_document", "change_document")
|
|
||||||
grant_object(group, document, "view_document", "change_document")
|
|
||||||
|
|
||||||
overrides = DocumentMetadataOverrides.from_document(document)
|
|
||||||
|
|
||||||
self.assertEqual(overrides.view_users, [direct_user.pk])
|
|
||||||
self.assertEqual(overrides.change_users, [direct_user.pk])
|
|
||||||
self.assertEqual(overrides.view_groups, [group.pk])
|
|
||||||
self.assertEqual(overrides.change_groups, [group.pk])
|
|
||||||
|
|
||||||
|
|
||||||
class TestBarcodeApplyDetectedASN(TestCase):
|
class TestBarcodeApplyDetectedASN(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ from datetime import date
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tasks import empty_trash
|
from documents.tasks import empty_trash
|
||||||
from paperless_testing.factories import DocumentFactory
|
|
||||||
|
|
||||||
|
|
||||||
class TestDocument(TestCase):
|
class TestDocument(TestCase):
|
||||||
@@ -260,72 +257,3 @@ def test_suggestion_content() -> None:
|
|||||||
)
|
)
|
||||||
doc.content = content_over_limit
|
doc.content = content_over_limit
|
||||||
assert doc.suggestion_content == expected_cropped_content
|
assert doc.suggestion_content == expected_cropped_content
|
||||||
|
|
||||||
|
|
||||||
class TestPublicFilenameLength:
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _no_parser_registry(self, mocker: MockerFixture) -> None:
|
|
||||||
# The parser registry reads ApplicationConfiguration from the database
|
|
||||||
extensions = {"application/pdf": ".pdf", "image/jpeg": ".jpg"}
|
|
||||||
mocker.patch(
|
|
||||||
"documents.models.get_default_file_extension",
|
|
||||||
side_effect=extensions.__getitem__,
|
|
||||||
)
|
|
||||||
|
|
||||||
LIMIT = Document.MAX_PUBLIC_FILENAME_BYTES
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("title", "archive", "counter", "max_bytes", "tail"),
|
|
||||||
[
|
|
||||||
pytest.param("a" * 300, False, 0, LIMIT, ".jpg", id="ascii"),
|
|
||||||
pytest.param("a" * 300, True, 0, LIMIT, ".pdf", id="archive"),
|
|
||||||
pytest.param("ä" * 200, False, 0, LIMIT, ".jpg", id="umlaut"),
|
|
||||||
pytest.param("ä" * 200, False, 1, LIMIT, "_01.jpg", id="count"),
|
|
||||||
pytest.param("a" * 300, False, 1, 100, "_01.jpg", id="max"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_long_title_keeps_tail_within_limit(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
*,
|
|
||||||
archive: bool,
|
|
||||||
counter: int,
|
|
||||||
max_bytes: int,
|
|
||||||
tail: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document whose title alone is longer than the byte limit
|
|
||||||
WHEN:
|
|
||||||
- The public file name is generated
|
|
||||||
THEN:
|
|
||||||
- The name ends with the counter and extension
|
|
||||||
- The name fits in the limit
|
|
||||||
"""
|
|
||||||
name = DocumentFactory.build(
|
|
||||||
title=title,
|
|
||||||
mime_type="image/jpeg",
|
|
||||||
).get_public_filename(
|
|
||||||
archive=archive,
|
|
||||||
counter=counter,
|
|
||||||
max_bytes=max_bytes,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert name.endswith(tail)
|
|
||||||
assert len(name.encode()) <= max_bytes
|
|
||||||
|
|
||||||
def test_long_title_counters_give_distinct_names(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document whose title alone is longer than the byte limit
|
|
||||||
WHEN:
|
|
||||||
- Public file names are generated with increasing counters
|
|
||||||
THEN:
|
|
||||||
- Each counter produces a distinct name
|
|
||||||
"""
|
|
||||||
doc = DocumentFactory.build(title="ä" * 200, mime_type="application/pdf")
|
|
||||||
|
|
||||||
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
|
|
||||||
|
|
||||||
assert len(set(names)) == 3
|
|
||||||
assert all(len(n.encode()) <= self.LIMIT for n in names)
|
|
||||||
@@ -1537,70 +1537,3 @@ class TestExportDerivedFileNameCollisions:
|
|||||||
assert (
|
assert (
|
||||||
target / record[EXPORTER_ARCHIVE_NAME]
|
target / record[EXPORTER_ARCHIVE_NAME]
|
||||||
).read_text() == f"archive {pk}"
|
).read_text() == f"archive {pk}"
|
||||||
|
|
||||||
def test_long_titles_export_within_file_name_limit(
|
|
||||||
self,
|
|
||||||
paperless_dirs: PaperlessDirs,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Two documents with the same created date and the same title
|
|
||||||
- The title alone is longer than 255 bytes and contains umlauts
|
|
||||||
WHEN:
|
|
||||||
- The exporter runs
|
|
||||||
THEN:
|
|
||||||
- The export finishes with distinct original, thumbnail and archive names
|
|
||||||
- Every exported file name fits in the byte limit
|
|
||||||
- Each exported file holds its own document's content
|
|
||||||
"""
|
|
||||||
for pk in (1, 2):
|
|
||||||
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
|
|
||||||
f"original {pk}".encode(),
|
|
||||||
)
|
|
||||||
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
|
|
||||||
f"archive {pk}".encode(),
|
|
||||||
)
|
|
||||||
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
|
|
||||||
f"thumbnail {pk}".encode(),
|
|
||||||
)
|
|
||||||
DocumentFactory(
|
|
||||||
pk=pk,
|
|
||||||
title="Bestellbestätigung " * 15,
|
|
||||||
created=date(2026, 10, 15),
|
|
||||||
filename=f"{pk:07}.pdf",
|
|
||||||
archive_filename=f"{pk:07}.pdf",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
target = tmp_path / "export"
|
|
||||||
target.mkdir()
|
|
||||||
call_command(
|
|
||||||
"document_exporter",
|
|
||||||
str(target),
|
|
||||||
"--no-progress-bar",
|
|
||||||
skip_checks=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = json.loads((target / "manifest.json").read_text())
|
|
||||||
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
|
|
||||||
|
|
||||||
for key in (
|
|
||||||
EXPORTER_FILE_NAME,
|
|
||||||
EXPORTER_THUMBNAIL_NAME,
|
|
||||||
EXPORTER_ARCHIVE_NAME,
|
|
||||||
):
|
|
||||||
assert records[1][key] != records[2][key]
|
|
||||||
|
|
||||||
for pk in (1, 2):
|
|
||||||
record = records[pk]
|
|
||||||
for key, content in (
|
|
||||||
(EXPORTER_FILE_NAME, f"original {pk}"),
|
|
||||||
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
|
|
||||||
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
|
|
||||||
):
|
|
||||||
assert (
|
|
||||||
len(Path(record[key]).name.encode())
|
|
||||||
<= Document.MAX_PUBLIC_FILENAME_BYTES
|
|
||||||
)
|
|
||||||
assert (target / record[key]).read_text() == content
|
|
||||||
@@ -6176,50 +6176,6 @@ class TestApplyAISuggestionsWorkflowAction(
|
|||||||
|
|
||||||
self.assertEqual(self.doc.tags.count(), 0)
|
self.assertEqual(self.doc.tags.count(), 0)
|
||||||
|
|
||||||
def test_unowned_document_does_not_get_private_objects_by_id(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An unowned document
|
|
||||||
- AI suggestions selecting another user's private tag and
|
|
||||||
correspondent by ID, plus an unowned tag
|
|
||||||
WHEN:
|
|
||||||
- The suggestions are applied
|
|
||||||
THEN:
|
|
||||||
- Only the unowned tag is assigned; an unowned document must not
|
|
||||||
be treated as unrestricted
|
|
||||||
"""
|
|
||||||
self.doc.owner = None
|
|
||||||
self.doc.save(update_fields=["owner"])
|
|
||||||
other = User.objects.create(username="someone-else")
|
|
||||||
private_tag = Tag.objects.create(name="Private Tag", owner=other)
|
|
||||||
public_tag = Tag.objects.create(name="Public Tag", owner=None)
|
|
||||||
private_correspondent = Correspondent.objects.create(
|
|
||||||
name="Private Correspondent",
|
|
||||||
owner=other,
|
|
||||||
)
|
|
||||||
action = self.make_action(
|
|
||||||
ai_suggestion_fields=[
|
|
||||||
WorkflowAction.AISuggestionField.TAGS,
|
|
||||||
WorkflowAction.AISuggestionField.CORRESPONDENT,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
suggestions: ClassificationSuggestions = {
|
|
||||||
**SUGGESTIONS,
|
|
||||||
"tags": {
|
|
||||||
"existing_ids": [private_tag.pk, public_tag.pk],
|
|
||||||
"new_names": [],
|
|
||||||
},
|
|
||||||
"correspondents": {
|
|
||||||
"existing_ids": [private_correspondent.pk],
|
|
||||||
"new_names": [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
self.apply(action, suggestions)
|
|
||||||
|
|
||||||
self.assertEqual(list(self.doc.tags.all()), [public_tag])
|
|
||||||
self.assertIsNone(self.doc.correspondent)
|
|
||||||
|
|
||||||
def test_unparsable_dates_are_skipped(self) -> None:
|
def test_unparsable_dates_are_skipped(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
+22
-34
@@ -289,7 +289,6 @@ logger = logging.getLogger("paperless.api")
|
|||||||
# clauses efficiently, so this threshold mainly protects SQLite users.
|
# clauses efficiently, so this threshold mainly protects SQLite users.
|
||||||
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
||||||
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
||||||
_TANTIVY_COMBINABLE_SEARCH_PARAMS = ({"text", "query"}, {"title_search", "query"})
|
|
||||||
|
|
||||||
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
|
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
|
||||||
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
|
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
|
||||||
@@ -309,18 +308,6 @@ _TANTIVY_COMBINABLE_SEARCH_PARAMS = ({"text", "query"}, {"title_search", "query"
|
|||||||
_MAX_QUERY_LENGTH: Final[int] = 4096
|
_MAX_QUERY_LENGTH: Final[int] = 4096
|
||||||
|
|
||||||
|
|
||||||
def _validate_tantivy_search_params(active: list[str]) -> None:
|
|
||||||
# may also have a filter query alongside a simple search e.g. relative dates
|
|
||||||
if len(active) > 1 and set(active) not in _TANTIVY_COMBINABLE_SEARCH_PARAMS:
|
|
||||||
raise ValidationError(
|
|
||||||
{
|
|
||||||
"detail": _(
|
|
||||||
"Specify only one of text, title_search, query, or more_like_id.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_tantivy_query_and_mode(params):
|
def _get_tantivy_query_and_mode(params):
|
||||||
from documents.search import QueryTooLongError
|
from documents.search import QueryTooLongError
|
||||||
from documents.search import SearchMode
|
from documents.search import SearchMode
|
||||||
@@ -334,15 +321,9 @@ def _get_tantivy_query_and_mode(params):
|
|||||||
else:
|
else:
|
||||||
return None # pragma: no cover
|
return None # pragma: no cover
|
||||||
|
|
||||||
filter_query = (
|
if len(raw) > _MAX_QUERY_LENGTH:
|
||||||
str(params["query"])
|
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
|
||||||
if mode is not SearchMode.QUERY and "query" in params
|
return raw, mode
|
||||||
else None
|
|
||||||
)
|
|
||||||
for value in (raw, filter_query):
|
|
||||||
if value is not None and len(value) > _MAX_QUERY_LENGTH:
|
|
||||||
raise QueryTooLongError(len(value), _MAX_QUERY_LENGTH)
|
|
||||||
return raw, mode, filter_query
|
|
||||||
|
|
||||||
|
|
||||||
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
||||||
@@ -2585,9 +2566,15 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
|
|
||||||
def parse_search_params() -> SearchParams:
|
def parse_search_params() -> SearchParams:
|
||||||
"""Extract query string, search mode, and ordering from request."""
|
"""Extract query string, search mode, and ordering from request."""
|
||||||
_validate_tantivy_search_params(
|
active = self._get_active_search_params(request)
|
||||||
self._get_active_search_params(request),
|
if len(active) > 1:
|
||||||
)
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"detail": _(
|
||||||
|
"Specify only one of text, title_search, query, or more_like_id.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
ordering_param = request.query_params.get("ordering", "")
|
ordering_param = request.query_params.get("ordering", "")
|
||||||
sort_reverse = ordering_param.startswith("-")
|
sort_reverse = ordering_param.startswith("-")
|
||||||
@@ -2649,9 +2636,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
filtered_qs: QuerySet[Document],
|
filtered_qs: QuerySet[Document],
|
||||||
) -> SearchResultPage:
|
) -> SearchResultPage:
|
||||||
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
||||||
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
query_str, search_mode = _get_tantivy_query_and_mode(request.query_params)
|
||||||
request.query_params,
|
|
||||||
)
|
|
||||||
|
|
||||||
# "score" is not a real Tantivy sort field — it means relevance order,
|
# "score" is not a real Tantivy sort field — it means relevance order,
|
||||||
# which is Tantivy's default when no sort field is specified.
|
# which is Tantivy's default when no sort field is specified.
|
||||||
@@ -2664,7 +2649,6 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
),
|
),
|
||||||
sort_reverse=sort_reverse,
|
sort_reverse=sort_reverse,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
filter_query=filter_query,
|
|
||||||
)
|
)
|
||||||
ordered_ids = intersect_and_order(
|
ordered_ids = intersect_and_order(
|
||||||
all_ids,
|
all_ids,
|
||||||
@@ -2899,7 +2883,14 @@ class DocumentSelectionMixin:
|
|||||||
]
|
]
|
||||||
if not search_filters:
|
if not search_filters:
|
||||||
return None
|
return None
|
||||||
_validate_tantivy_search_params(search_filters)
|
if len(search_filters) > 1:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"detail": _(
|
||||||
|
"Specify only one of text, title_search, query, or more_like_id.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
from documents.search import SearchQueryError
|
from documents.search import SearchQueryError
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
@@ -2918,14 +2909,11 @@ class DocumentSelectionMixin:
|
|||||||
user=search_user,
|
user=search_user,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
query_str, search_mode, filter_query = _get_tantivy_query_and_mode(
|
query_str, search_mode = _get_tantivy_query_and_mode(filters)
|
||||||
filters,
|
|
||||||
)
|
|
||||||
search_ids = backend.search_ids(
|
search_ids = backend.search_ids(
|
||||||
query_str,
|
query_str,
|
||||||
user=search_user,
|
user=search_user,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
filter_query=filter_query,
|
|
||||||
)
|
)
|
||||||
except SearchQueryError as e:
|
except SearchQueryError as e:
|
||||||
# Same user-fixable-query mapping as the search list endpoint:
|
# Same user-fixable-query mapping as the search list endpoint:
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
|
|||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import user_is_active_superuser
|
from documents.permissions import user_is_unrestricted
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.base_model import ClassificationSuggestions
|
from paperless_ai.base_model import ClassificationSuggestions
|
||||||
@@ -50,17 +50,16 @@ def _fulltext_similar_documents(
|
|||||||
similarity - cruder, but far better than no candidates at all.
|
similarity - cruder, but far better than no candidates at all.
|
||||||
more_like_this_ids returns only a ranked ID list, no scores, so weight is
|
more_like_this_ids returns only a ranked ID list, no scores, so weight is
|
||||||
synthesized from rank (descending from top_k) rather than claiming a
|
synthesized from rank (descending from top_k) rather than claiming a
|
||||||
similarity magnitude that doesn't exist. An active superuser is
|
similarity magnitude that doesn't exist. An unrestricted user (none, or an
|
||||||
normalized to ``None`` (no filtering) before calling, since the backend's
|
active superuser - see user_is_unrestricted) is normalized to ``None``
|
||||||
permission filter has no superuser short-circuit of its own. ``user=None``
|
before calling, since the backend's permission filter has no superuser
|
||||||
means the document is unowned, so results are limited to unowned documents
|
short-circuit of its own. Results are re-checked with
|
||||||
rather than the whole library. Results are re-checked with
|
|
||||||
permitted_document_ids() since Tantivy's indexed permission fields lag the
|
permitted_document_ids() since Tantivy's indexed permission fields lag the
|
||||||
DB via async reindexing and judge a version by its own owner.
|
DB via async reindexing and judge a version by its own owner.
|
||||||
"""
|
"""
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
unrestricted = user_is_active_superuser(user)
|
unrestricted = user_is_unrestricted(user)
|
||||||
search_user = None if unrestricted else user
|
search_user = None if unrestricted else user
|
||||||
backend = get_backend()
|
backend = get_backend()
|
||||||
similar_ids = backend.more_like_this_ids(
|
similar_ids = backend.more_like_this_ids(
|
||||||
@@ -188,16 +187,13 @@ def get_taxonomy_context(
|
|||||||
candidates/context rather than propagating the exception - neither a
|
candidates/context rather than propagating the exception - neither a
|
||||||
vector-store outage nor a search-index issue should block classification,
|
vector-store outage nor a search-index issue should block classification,
|
||||||
only its context-assisted enrichment.
|
only its context-assisted enrichment.
|
||||||
|
|
||||||
``user=None`` means "acting for an unowned document" (workflows act as the
|
|
||||||
document owner), NOT "no restriction": only unowned documents and taxonomy
|
|
||||||
are considered, so another user's private documents never reach the prompt.
|
|
||||||
"""
|
"""
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
try:
|
try:
|
||||||
if ai_config.llm_embedding_backend:
|
if ai_config.llm_embedding_backend:
|
||||||
# None means "no restriction" to retrieve_similar_nodes. An active
|
# None means "no restriction" to retrieve_similar_nodes. An
|
||||||
# superuser can see every document, so skip
|
# unrestricted user (no user at all, or an active superuser -- see
|
||||||
|
# user_is_unrestricted) can see every document, so skip
|
||||||
# materializing every visible pk into a Python list and passing it
|
# materializing every visible pk into a Python list and passing it
|
||||||
# through as an IN filter: for a large library that is a wasted
|
# through as an IN filter: for a large library that is a wasted
|
||||||
# quadratic scan in the vector store at best, and past ~32,763
|
# quadratic scan in the vector store at best, and past ~32,763
|
||||||
@@ -208,7 +204,7 @@ def get_taxonomy_context(
|
|||||||
# which documents are considered -- only how we get there.
|
# which documents are considered -- only how we get there.
|
||||||
visible_document_ids = (
|
visible_document_ids = (
|
||||||
None
|
None
|
||||||
if user_is_active_superuser(user)
|
if user_is_unrestricted(user)
|
||||||
else list(permitted_document_ids(user))
|
else list(permitted_document_ids(user))
|
||||||
)
|
)
|
||||||
nodes = retrieve_similar_nodes(
|
nodes = retrieve_similar_nodes(
|
||||||
@@ -230,14 +226,8 @@ def get_taxonomy_context(
|
|||||||
|
|
||||||
# similar_documents is already ordered by descending weight; don't lose it.
|
# similar_documents is already ordered by descending weight; don't lose it.
|
||||||
similar_document_ids = [s["document_id"] for s in similar_documents]
|
similar_document_ids = [s["document_id"] for s in similar_documents]
|
||||||
similar_queryset = Document.objects.all()
|
|
||||||
if not user_is_active_superuser(user):
|
|
||||||
# Already scoped by retrieval but re-checked here before being used in the prompt
|
|
||||||
similar_queryset = similar_queryset.filter(
|
|
||||||
id__in=permitted_document_ids(user),
|
|
||||||
)
|
|
||||||
similar_documents_by_id = annotate_effective_content(
|
similar_documents_by_id = annotate_effective_content(
|
||||||
similar_queryset,
|
Document.objects.all(),
|
||||||
).in_bulk(similar_document_ids)
|
).in_bulk(similar_document_ids)
|
||||||
similar_docs = [
|
similar_docs = [
|
||||||
similar_documents_by_id[document_id]
|
similar_documents_by_id[document_id]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import restrict_queryset_to_owner_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
|
|
||||||
MATCH_THRESHOLD = 0.8
|
MATCH_THRESHOLD = 0.8
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ def _resolve_visible_ids(
|
|||||||
"""
|
"""
|
||||||
if not ids:
|
if not ids:
|
||||||
return []
|
return []
|
||||||
queryset = restrict_queryset_to_owner_visible(
|
queryset = restrict_queryset_to_visible(
|
||||||
model.objects.filter(pk__in=ids),
|
model.objects.filter(pk__in=ids),
|
||||||
user,
|
user,
|
||||||
perm,
|
perm,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import restrict_queryset_to_owner_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
from paperless_ai.prompts.context import TaxonomyBlockPromptContext
|
from paperless_ai.prompts.context import TaxonomyBlockPromptContext
|
||||||
from paperless_ai.prompts.render import render_prompt
|
from paperless_ai.prompts.render import render_prompt
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ def _visible_ranked_candidates(
|
|||||||
"""
|
"""
|
||||||
if not weighted_ids:
|
if not weighted_ids:
|
||||||
return []
|
return []
|
||||||
visible_queryset = restrict_queryset_to_owner_visible(
|
visible_queryset = restrict_queryset_to_visible(
|
||||||
model.objects.filter(pk__in=weighted_ids),
|
model.objects.filter(pk__in=weighted_ids),
|
||||||
user,
|
user,
|
||||||
perm,
|
perm,
|
||||||
|
|||||||
@@ -613,83 +613,32 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
def test_restricts_to_unowned_documents_when_no_user(
|
def test_skips_permission_lookup_when_no_user(
|
||||||
self,
|
self,
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- No user is supplied (a workflow acting for an unowned document)
|
- No user is supplied
|
||||||
- Another user's private document
|
|
||||||
WHEN:
|
WHEN:
|
||||||
- get_taxonomy_context() is called
|
- get_taxonomy_context() is called
|
||||||
THEN:
|
THEN:
|
||||||
- Only unowned documents are passed to retrieve_similar_nodes(),
|
- Permission lookup is skipped and no document_ids restriction is
|
||||||
never the whole library
|
passed to retrieve_similar_nodes()
|
||||||
"""
|
"""
|
||||||
other_owner = UserFactory.create()
|
document = DocumentFactory.create(content="Some content")
|
||||||
document = DocumentFactory.create(content="Some content", owner=None)
|
|
||||||
unowned = DocumentFactory.create(content="Public", owner=None)
|
|
||||||
private = DocumentFactory.create(content="Private", owner=other_owner)
|
|
||||||
mock_retrieve = mocker.patch(
|
mock_retrieve = mocker.patch(
|
||||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
)
|
)
|
||||||
|
mock_permitted = mocker.patch(
|
||||||
|
"paperless_ai.ai_classifier.permitted_document_ids",
|
||||||
|
)
|
||||||
|
|
||||||
get_taxonomy_context(document, None)
|
get_taxonomy_context(document, None)
|
||||||
|
|
||||||
visible = mock_retrieve.call_args.kwargs["document_ids"]
|
mock_permitted.assert_not_called()
|
||||||
assert visible is not None
|
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
||||||
assert document.pk in visible
|
|
||||||
assert unowned.pk in visible
|
|
||||||
assert private.pk not in visible
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
def test_private_document_content_never_reaches_context_when_no_user(
|
|
||||||
self,
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No user is supplied (a workflow acting for an unowned document)
|
|
||||||
- Retrieval returns another user's private document anyway (e.g.
|
|
||||||
a stale vector store)
|
|
||||||
WHEN:
|
|
||||||
- get_taxonomy_context() is called
|
|
||||||
THEN:
|
|
||||||
- The private document's title and content are not in the RAG
|
|
||||||
context, while an unowned neighbour's are
|
|
||||||
"""
|
|
||||||
other_owner = UserFactory.create()
|
|
||||||
document = DocumentFactory.create(content="Some content", owner=None)
|
|
||||||
unowned = DocumentFactory.create(
|
|
||||||
title="Public neighbour",
|
|
||||||
content="public text",
|
|
||||||
owner=None,
|
|
||||||
)
|
|
||||||
private = DocumentFactory.create(
|
|
||||||
title="Secret neighbour",
|
|
||||||
content="secret text",
|
|
||||||
owner=other_owner,
|
|
||||||
)
|
|
||||||
mocker.patch(
|
|
||||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
|
||||||
return_value=[],
|
|
||||||
)
|
|
||||||
mocker.patch(
|
|
||||||
"paperless_ai.ai_classifier._node_document_weights",
|
|
||||||
return_value=[
|
|
||||||
SimilarDocument(document_id=private.pk, weight=0.9),
|
|
||||||
SimilarDocument(document_id=unowned.pk, weight=0.5),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
_candidates, context = get_taxonomy_context(document, None)
|
|
||||||
|
|
||||||
assert "Public neighbour" in context
|
|
||||||
assert "Secret neighbour" not in context
|
|
||||||
assert "secret text" not in context
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
@@ -719,9 +668,7 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
|
|
||||||
get_taxonomy_context(document, user)
|
get_taxonomy_context(document, user)
|
||||||
|
|
||||||
# Once for retrieval, once to re-check the documents whose text goes
|
mock_permitted.assert_called_once_with(user)
|
||||||
# into the prompt
|
|
||||||
assert {c.args for c in mock_permitted.call_args_list} == {(user,)}
|
|
||||||
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
|
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -930,34 +877,6 @@ class TestFulltextSimilarDocuments:
|
|||||||
|
|
||||||
assert [s["document_id"] for s in result] == [other.pk]
|
assert [s["document_id"] for s in result] == [other.pk]
|
||||||
|
|
||||||
def test_no_user_only_returns_unowned_documents(
|
|
||||||
self,
|
|
||||||
fulltext_backend: TantivyBackend,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An unowned source document, an unowned similar document and a
|
|
||||||
similar document privately owned by another user
|
|
||||||
WHEN:
|
|
||||||
- _fulltext_similar_documents() is called with no user (a workflow
|
|
||||||
acting for an unowned document)
|
|
||||||
THEN:
|
|
||||||
- Only the unowned similar document is returned
|
|
||||||
"""
|
|
||||||
other_owner = UserFactory.create()
|
|
||||||
source = DocumentFactory.create(content="shared content phrase", owner=None)
|
|
||||||
unowned = DocumentFactory.create(content="shared content phrase", owner=None)
|
|
||||||
private = DocumentFactory.create(
|
|
||||||
content="shared content phrase",
|
|
||||||
owner=other_owner,
|
|
||||||
)
|
|
||||||
for doc in (source, unowned, private):
|
|
||||||
fulltext_backend.add_or_update(doc)
|
|
||||||
|
|
||||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
|
||||||
|
|
||||||
assert [s["document_id"] for s in result] == [unowned.pk]
|
|
||||||
|
|
||||||
def test_excludes_stale_permitted_document_for_regular_user(
|
def test_excludes_stale_permitted_document_for_regular_user(
|
||||||
self,
|
self,
|
||||||
fulltext_backend: TantivyBackend,
|
fulltext_backend: TantivyBackend,
|
||||||
|
|||||||
@@ -154,19 +154,27 @@ class TestResolveTagIds:
|
|||||||
user = UserFactory.create()
|
user = UserFactory.create()
|
||||||
assert resolve_tag_ids([], user) == []
|
assert resolve_tag_ids([], user) == []
|
||||||
|
|
||||||
def test_user_none_only_resolves_unowned_tags(self) -> None:
|
def test_user_none_means_unrestricted_not_owner_isnull(
|
||||||
"""GIVEN a tag owned by another user, an unowned tag and user=None
|
self,
|
||||||
(a workflow acting for an unowned document)
|
mocker: pytest_mock.MockerFixture,
|
||||||
WHEN resolving both tags' ids
|
) -> None:
|
||||||
THEN only the unowned tag is returned - user=None must not expose
|
"""GIVEN a tag owned by another user and user=None
|
||||||
another user's private taxonomy.
|
WHEN resolving the tag's id
|
||||||
|
THEN the tag is returned unfiltered and permitted_object_ids is never
|
||||||
|
called - user=None means "no restriction", not the narrower
|
||||||
|
"only unowned rows" meaning permitted_object_ids(None, ...) has.
|
||||||
|
Same convention as build_taxonomy_candidates's own call site.
|
||||||
"""
|
"""
|
||||||
owned = TagFactory.create(name="Owned", owner=UserFactory.create())
|
tag = TagFactory.create(name="Owned")
|
||||||
unowned = TagFactory.create(name="Unowned", owner=None)
|
owner = UserFactory.create()
|
||||||
|
tag.owner = owner
|
||||||
|
tag.save()
|
||||||
|
spy = mocker.patch("documents.permissions.permitted_object_ids")
|
||||||
|
|
||||||
result = resolve_tag_ids([owned.pk, unowned.pk], None)
|
result = resolve_tag_ids([tag.pk], None)
|
||||||
|
|
||||||
assert result == [unowned]
|
assert result == [tag]
|
||||||
|
spy.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -271,27 +271,39 @@ class TestBuildTaxonomyCandidates:
|
|||||||
|
|
||||||
assert result["tags"] == []
|
assert result["tags"] == []
|
||||||
|
|
||||||
def test_user_none_only_includes_unowned_taxonomy(self) -> None:
|
def test_user_none_means_unrestricted_not_owner_isnull(
|
||||||
|
self,
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A similar document with a tag owned by another user and an
|
- An owned tag (owner is not None)
|
||||||
unowned tag
|
- user=None (system/superuser/no-auth classification)
|
||||||
- user=None (a workflow acting for an unowned document)
|
|
||||||
WHEN:
|
WHEN:
|
||||||
- build_taxonomy_candidates() is called
|
- build_taxonomy_candidates() is called
|
||||||
THEN:
|
THEN:
|
||||||
- Only the unowned tag is a candidate - user=None must not expose
|
- The tag is included (no permission filtering occurs)
|
||||||
another user's private taxonomy
|
- permitted_object_ids() is never called
|
||||||
"""
|
"""
|
||||||
owned = TagFactory.create(name="Owned", owner=UserFactory.create())
|
# user=None means "no restriction" throughout ai_classifier.py (the
|
||||||
unowned = TagFactory.create(name="Unowned", owner=None)
|
# same superuser/no-user fast path get_taxonomy_context uses).
|
||||||
|
# permitted_object_ids(None, ...) itself means something
|
||||||
|
# different ("only unowned rows") - it must not be called at all
|
||||||
|
# when user is None, or an owned tag like this one would be wrongly
|
||||||
|
# dropped for every unauthenticated/system-triggered classification.
|
||||||
|
tag = TagFactory.create(name="Owned")
|
||||||
|
owner = UserFactory.create()
|
||||||
|
tag.owner = owner
|
||||||
|
tag.save()
|
||||||
document = DocumentFactory.create()
|
document = DocumentFactory.create()
|
||||||
document.tags.add(owned, unowned)
|
document.tags.add(tag)
|
||||||
similar_documents = [make_similar(document.pk, 0.5)]
|
similar_documents = [make_similar(document.pk, 0.5)]
|
||||||
|
spy = mocker.patch("documents.permissions.permitted_object_ids")
|
||||||
|
|
||||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||||
|
|
||||||
assert [c["name"] for c in result["tags"]] == ["Unowned"]
|
assert result["tags"][0]["name"] == "Owned"
|
||||||
|
spy.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestFormatTaxonomyForPrompt:
|
class TestFormatTaxonomyForPrompt:
|
||||||
|
|||||||
@@ -1272,11 +1272,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "4.0.12"
|
version = "3.32.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3184,7 +3184,7 @@ requires-dist = [
|
|||||||
{ name = "drf-spectacular", specifier = "~=0.30" },
|
{ name = "drf-spectacular", specifier = "~=0.30" },
|
||||||
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
|
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
|
||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "filelock", specifier = ">=3.32,<4.1" },
|
{ name = "filelock", specifier = "~=3.32.0" },
|
||||||
{ name = "flower", specifier = ">=2.0.1,<2.3" },
|
{ name = "flower", specifier = ">=2.0.1,<2.3" },
|
||||||
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
|
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
|
||||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
|
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
|
||||||
|
|||||||
Reference in new issue
Block a user