mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-17 05:18:12 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf3e6ed91 | ||
|
|
0cb2cdbfdd | ||
|
|
1f3d451a3a | ||
|
|
183cfd2da4 | ||
|
|
30a24ee02e | ||
|
|
1943c99b27 | ||
|
|
8b26ecafb6 | ||
|
|
d4a57204eb | ||
|
|
c50c6f5517 |
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.0",
|
"filelock>=3.32,<4.1",
|
||||||
"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,6 +15,8 @@ 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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
|
<target state="translated"> スライド <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> / <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="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>
|
<target state="translated">タグ、取引先、ドキュメントの種類、保存先パス、カスタムフィールドなどの属性をここで管理できます。これらはドキュメントの編集画面から作成することもできます。</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="needs-translation">Tasks helps you track background work, what needs attention, and what recently completed.</target>
|
<target state="translated">タスクでは、バックグラウンド処理の進行状況、対応が必要な項目、最近完了した処理を確認できます。</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="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target>
|
<target state="translated">この値は、Paperless の外で設定されている <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>を上書きします。</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="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target>
|
<target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> はPaperlessの外部で設定されています。ここに値を入力すると、その設定を上書きできます。</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="needs-translation">Use the externally configured value</target>
|
<target state="translated">外部で設定された値を使用</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="needs-translation">Reset to external</target>
|
<target state="translated">外部設定に戻す</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="needs-translation">lines</target>
|
<target state="translated">行</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="needs-translation">Jump to bottom</target>
|
<target state="translated">一番下へ移動</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="needs-translation">Sidebar items to show:</target>
|
<target state="translated">サイドバーに表示するアイテム:</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="needs-translation">Show document counts in sidebar saved views</target>
|
<target state="translated">サイドバーの保存済みビューにドキュメント数を表示</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="needs-translation">Suggestions default to</target>
|
<target state="translated">提案機能のデフォルト設定</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="needs-translation">Both</target>
|
<target state="translated">両方</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="needs-translation">AI only</target>
|
<target state="translated">AIのみ</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="needs-translation">Automatically request suggestions for inbox documents</target>
|
<target state="translated">受信トレイ内のドキュメントに対して自動的に提案をリクエストする</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="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target>
|
<target state="translated">チェックを外した場合、提案を取得するには「提案」ボタンを押す必要があります。</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="needs-translation">Uncheck fields to hide them on the document details page.</target>
|
<target state="translated">ドキュメントの詳細ページで非表示にするフィールドは、チェックを外してください。</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="needs-translation">PDF Editor</target>
|
<target state="translated">PDF エディター</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="needs-translation">Default editing mode</target>
|
<target state="translated">デフォルトの編集モード</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="needs-translation">Create new document(s)</target>
|
<target state="translated">新しいドキュメントを作成</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="needs-translation">Add document version</target>
|
<target state="translated">ドキュメントのバージョンを追加</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="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>
|
<target state="translated">これらの設定は、Web UIから作成されたオブジェクト(タグ、メールルールなど)に対して、このユーザーアカウントに適用されます。ドキュメントには適用されません。</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="needs-translation">Tasks</target>
|
<target state="translated">タスク</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="needs-translation">Tasks shows detailed information about document consumption and system tasks.</target>
|
<target state="translated">タスクでは、ドキュメントの取り込み処理やシステムタスクに関する詳細情報を確認できます。</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="needs-translation">Dismiss all</target>
|
<target state="translated">すべて閉じる</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="needs-translation">All types</target>
|
<target state="translated">すべての種類</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="needs-translation">All sources</target>
|
<target state="translated">すべてのソース</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="needs-translation">Search tasks</target>
|
<target state="translated">タスクを検索</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="needs-translation">{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</target>
|
<target state="translated">{VAR_PLURAL, plural, =1 {1件のタスク} other {<x id="INTERPOLATION"/>件のタスク}}</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="needs-translation">View task details</target>
|
<target state="translated">タスクの詳細を表示</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="needs-translation">Result message</target>
|
<target state="translated">結果メッセージ</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="needs-translation">Duplicate</target>
|
<target state="translated">重複</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="needs-translation">Input data</target>
|
<target state="translated">入力データ</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="needs-translation">Result data</target>
|
<target state="translated">結果データ</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="needs-translation">No tasks match the current filters.</target>
|
<target state="translated">現在のフィルター条件に一致するタスクはありません。</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="needs-translation">Needs attention</target>
|
<target state="translated">注意が必要</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="needs-translation">In progress</target>
|
<target state="translated">進行中</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="needs-translation">Recently completed</target>
|
<target state="translated">最近完了したタスク</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="needs-translation">Consume File</target>
|
<target state="translated">ファイルの取り込み</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="needs-translation">Sanity Check</target>
|
<target state="translated">整合性チェック</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="needs-translation">Empty Trash</target>
|
<target state="translated">ゴミ箱を空にする</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="needs-translation">Check Workflows</target>
|
<target state="translated">ワークフローの確認</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="needs-translation">Bulk Update</target>
|
<target state="translated">一括更新</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="needs-translation">Reprocess Document</target>
|
<target state="translated">ドキュメントの再処理</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="needs-translation">Build Share Link</target>
|
<target state="translated">共有リンクの生成</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="needs-translation">Bulk Delete</target>
|
<target state="translated">一括削除</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="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
|
<target state="translated"> Shlid <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> i <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="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target>
|
<target state="translated">Ky vlerë mbizotëron <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, i cili është vendosur jashtë 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="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target>
|
<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>
|
||||||
</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="needs-translation">Use the externally configured value</target>
|
<target state="translated">Përdorni vlerën e konfiguruar nga jashtë</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="needs-translation">Reset to external</target>
|
<target state="translated">Rinisni në të jashtëm</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="needs-translation">Sidebar items to show:</target>
|
<target state="translated">Elementët e shiritit anësor për t'u shfaqur:</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="needs-translation">Suggestions default to</target>
|
<target state="translated">Sugjerimet parazgjedhore janë</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="needs-translation">Both</target>
|
<target state="translated">Të dyja</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="needs-translation">ML only</target>
|
<target state="translated">Vetëm ML</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="needs-translation">AI only</target>
|
<target state="translated">Vetëm AI</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="needs-translation">Automatically request suggestions for inbox documents</target>
|
<target state="translated">Kërkoni automatikisht sugjerime për dokumentet në kutinë e postës elektronike</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="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target>
|
<target state="translated">Nëse nuk është shënuar, sugjerimet duhet të kërkohen përmes butonit Sugjero.</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="needs-translation">Clear search</target>
|
<target state="translated">Pastro kërkimin</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="needs-translation">View task details</target>
|
<target state="translated">Shiko detajet e detyrës</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="needs-translation">Apply AI Suggestions</target>
|
<target state="translated">Apliko sugjerimet e AI</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="needs-translation">User menu</target>
|
<target state="translated">Menuja e përdoruesit</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="needs-translation">Share links</target>
|
<target state="translated">Ndani lidhjet</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="needs-translation">Configure update checking</target>
|
<target state="translated">Konfiguro kontrollin e përditësimeve</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="needs-translation">Chat</target>
|
<target state="translated">Bisedë</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="needs-translation">Root document:</target>
|
<target state="translated">Dokumenti rrënjësor:</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,6 +54,10 @@ 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,12 +130,14 @@ 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,6 +3,7 @@ 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
|
||||||
@@ -70,6 +71,15 @@ 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]",
|
||||||
@@ -512,7 +522,10 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
counter=filename_counter,
|
counter=filename_counter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
base_name = document.get_public_filename(counter=filename_counter)
|
base_name = document.get_public_filename(
|
||||||
|
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
|
||||||
@@ -541,7 +554,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.webp")
|
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
|
||||||
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()
|
||||||
@@ -550,7 +563,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.pdf")
|
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
|
||||||
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()
|
||||||
|
|||||||
+23
-12
@@ -157,6 +157,8 @@ 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,
|
||||||
@@ -484,28 +486,37 @@ 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(self, *, archive=False, counter=0, suffix=None) -> str:
|
def get_public_filename(
|
||||||
|
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)
|
|
||||||
|
|
||||||
if counter:
|
extension = ".pdf" if archive else self.file_type
|
||||||
result += f"_{counter:02}"
|
tail = f"_{counter:02}{extension}" if counter else extension
|
||||||
|
|
||||||
if suffix:
|
head_bytes = max(0, max_bytes - len(tail.encode()))
|
||||||
result += suffix
|
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
|
||||||
|
|
||||||
if archive:
|
return pathvalidate.sanitize_filename(
|
||||||
result += ".pdf"
|
head + tail,
|
||||||
else:
|
replacement_text="-",
|
||||||
result += self.file_type
|
max_len=max_bytes,
|
||||||
|
)
|
||||||
return pathvalidate.sanitize_filename(result, replacement_text="-")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_type(self):
|
def file_type(self):
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
from collections.abc import Mapping
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import ClassVar
|
|
||||||
from typing import Literal
|
|
||||||
from typing import TypeGuard
|
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -68,23 +62,28 @@ class PaperlessAdminPermissions(BasePermission):
|
|||||||
return request.user.is_active and request.user.is_staff
|
return request.user.is_active and request.user.is_staff
|
||||||
|
|
||||||
|
|
||||||
def _is_active_and_authenticated(user: User | None) -> TypeGuard[User]:
|
|
||||||
return (
|
|
||||||
user is not None
|
|
||||||
and getattr(user, "is_active", False)
|
|
||||||
and getattr(user, "is_authenticated", False)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def has_global_statistics_permission(user: User | None) -> bool:
|
def has_global_statistics_permission(user: User | None) -> bool:
|
||||||
return _is_active_and_authenticated(user) and (
|
if (
|
||||||
getattr(user, "is_superuser", False)
|
user is None
|
||||||
or user.has_perm("paperless.view_global_statistics")
|
or not getattr(user, "is_active", False)
|
||||||
|
or not getattr(user, "is_authenticated", False)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return getattr(user, "is_superuser", False) or user.has_perm(
|
||||||
|
"paperless.view_global_statistics",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_system_status_permission(user: User | None) -> bool:
|
def has_system_status_permission(user: User | None) -> bool:
|
||||||
return _is_active_and_authenticated(user) and (
|
if (
|
||||||
|
user is None
|
||||||
|
or not getattr(user, "is_active", False)
|
||||||
|
or not getattr(user, "is_authenticated", False)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
getattr(user, "is_superuser", False)
|
getattr(user, "is_superuser", False)
|
||||||
or getattr(user, "is_staff", False)
|
or getattr(user, "is_staff", False)
|
||||||
or user.has_perm("paperless.view_system_monitoring")
|
or user.has_perm("paperless.view_system_monitoring")
|
||||||
@@ -512,6 +511,28 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -706,32 +727,7 @@ def has_perms_owner_aware(user, perms, obj):
|
|||||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||||
|
|
||||||
|
|
||||||
# HEAD is deliberately absent: GlobalPermissionsByMethod checks it against GET.
|
class ViewDocumentsPermissions(BasePermission):
|
||||||
MappedMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalPermissionsByMethod(BasePermission):
|
|
||||||
"""
|
|
||||||
Requires the global (model-level) permissions listed in ``perms_map`` for
|
|
||||||
the request's HTTP method. HEAD is checked against the ``"GET"`` entry,
|
|
||||||
since DRF runs the GET handler for it. A method missing from
|
|
||||||
``perms_map`` is denied.
|
|
||||||
"""
|
|
||||||
|
|
||||||
perms_map: ClassVar[Mapping[MappedMethod, Sequence[str]]]
|
|
||||||
|
|
||||||
def has_permission(self, request: Any, view: Any) -> bool:
|
|
||||||
if not request.user or not request.user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
method = "GET" if request.method == "HEAD" else request.method
|
|
||||||
# An unknown method is not a MappedMethod, but .get() returns None for
|
|
||||||
# it, so it is still denied.
|
|
||||||
perms = self.perms_map.get(cast("MappedMethod", method))
|
|
||||||
return perms is not None and request.user.has_perms(perms)
|
|
||||||
|
|
||||||
|
|
||||||
class ViewDocumentsPermissions(GlobalPermissionsByMethod):
|
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for only viewing Documents.
|
Permissions class that checks for model permissions for only viewing Documents.
|
||||||
"""
|
"""
|
||||||
@@ -742,18 +738,31 @@ class ViewDocumentsPermissions(GlobalPermissionsByMethod):
|
|||||||
"POST": ["documents.view_document"],
|
"POST": ["documents.view_document"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or (not request.user.is_authenticated): # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
class TrashPermissions(GlobalPermissionsByMethod):
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
|
class TrashPermissions(BasePermission):
|
||||||
"""Check the global document permission for each trash operation."""
|
"""Check the global document permission for each trash operation."""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.view_document"],
|
"OPTIONS": ["documents.view_document"],
|
||||||
|
"HEAD": ["documents.view_document"],
|
||||||
"GET": ["documents.view_document"],
|
"GET": ["documents.view_document"],
|
||||||
"POST": ["documents.delete_document"],
|
"POST": ["documents.delete_document"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
class PaperlessNotePermissions(GlobalPermissionsByMethod):
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
|
class PaperlessNotePermissions(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for Notes.
|
Permissions class that checks for model permissions for Notes.
|
||||||
"""
|
"""
|
||||||
@@ -773,13 +782,28 @@ class PaperlessNotePermissions(GlobalPermissionsByMethod):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or (not request.user.is_authenticated): # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
class AcknowledgeTasksPermissions(GlobalPermissionsByMethod):
|
perms = self.perms_map[request.method]
|
||||||
|
|
||||||
|
return request.user.has_perms(perms)
|
||||||
|
|
||||||
|
|
||||||
|
class AcknowledgeTasksPermissions(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for acknowledging tasks.
|
Permissions class that checks for model permissions for acknowledging tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.change_paperlesstask"],
|
|
||||||
"POST": ["documents.change_paperlesstask"],
|
"POST": ["documents.change_paperlesstask"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request: Any, view: Any) -> bool:
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
perms = self.perms_map.get(request.method, [])
|
||||||
|
|
||||||
|
return request.user.has_perms(perms)
|
||||||
@@ -3653,34 +3653,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_notes_head_and_unsupported_methods(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A user allowed to view notes on their own document
|
|
||||||
WHEN:
|
|
||||||
- The notes endpoint is requested with HEAD, PUT and PATCH
|
|
||||||
THEN:
|
|
||||||
- HEAD is allowed like GET
|
|
||||||
- PUT and PATCH are rejected with 403 rather than a server error
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
grant_global(user, "view_note", "view_document")
|
|
||||||
doc = DocumentFactory(owner=user)
|
|
||||||
self.client.force_authenticate(user)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
self.client.head(f"/api/documents/{doc.pk}/notes/").status_code,
|
|
||||||
status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
self.client.put(f"/api/documents/{doc.pk}/notes/").status_code,
|
|
||||||
status.HTTP_403_FORBIDDEN,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
self.client.patch(f"/api/documents/{doc.pk}/notes/").status_code,
|
|
||||||
status.HTTP_403_FORBIDDEN,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_delete_note(self) -> None:
|
def test_delete_note(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1001,21 +1001,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 10)
|
self.assertEqual(len(response.data), 10)
|
||||||
|
|
||||||
def test_search_autocomplete_head_requires_view_document(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A user without the global view document permission
|
|
||||||
WHEN:
|
|
||||||
- A HEAD request is made for autocomplete
|
|
||||||
THEN:
|
|
||||||
- It is rejected like the equivalent GET request
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=UserFactory())
|
|
||||||
|
|
||||||
response = self.client.head("/api/search/autocomplete/?term=test")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
def test_search_autocomplete_respect_permissions(self) -> None:
|
def test_search_autocomplete_respect_permissions(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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
|
||||||
@@ -1036,6 +1037,49 @@ 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,8 +34,10 @@ 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:
|
||||||
@@ -1683,6 +1685,32 @@ 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,13 +4,16 @@ 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):
|
||||||
@@ -257,3 +260,72 @@ 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,3 +1537,70 @@ 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,6 +6176,50 @@ 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:
|
||||||
|
|||||||
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_unrestricted
|
from documents.permissions import user_is_active_superuser
|
||||||
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,16 +50,17 @@ 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 unrestricted user (none, or an
|
similarity magnitude that doesn't exist. An active superuser is
|
||||||
active superuser - see user_is_unrestricted) is normalized to ``None``
|
normalized to ``None`` (no filtering) before calling, since the backend's
|
||||||
before calling, since the backend's permission filter has no superuser
|
permission filter has no superuser short-circuit of its own. ``user=None``
|
||||||
short-circuit of its own. Results are re-checked with
|
means the document is unowned, so results are limited to unowned documents
|
||||||
|
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_unrestricted(user)
|
unrestricted = user_is_active_superuser(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(
|
||||||
@@ -187,13 +188,16 @@ 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
|
# None means "no restriction" to retrieve_similar_nodes. An active
|
||||||
# unrestricted user (no user at all, or an active superuser -- see
|
# superuser can see every document, so skip
|
||||||
# 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
|
||||||
@@ -204,7 +208,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_unrestricted(user)
|
if user_is_active_superuser(user)
|
||||||
else list(permitted_document_ids(user))
|
else list(permitted_document_ids(user))
|
||||||
)
|
)
|
||||||
nodes = retrieve_similar_nodes(
|
nodes = retrieve_similar_nodes(
|
||||||
@@ -226,8 +230,14 @@ 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(
|
||||||
Document.objects.all(),
|
similar_queryset,
|
||||||
).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_visible
|
from documents.permissions import restrict_queryset_to_owner_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_visible(
|
queryset = restrict_queryset_to_owner_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_visible
|
from documents.permissions import restrict_queryset_to_owner_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_visible(
|
visible_queryset = restrict_queryset_to_owner_visible(
|
||||||
model.objects.filter(pk__in=weighted_ids),
|
model.objects.filter(pk__in=weighted_ids),
|
||||||
user,
|
user,
|
||||||
perm,
|
perm,
|
||||||
|
|||||||
@@ -613,32 +613,83 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
def test_skips_permission_lookup_when_no_user(
|
def test_restricts_to_unowned_documents_when_no_user(
|
||||||
self,
|
self,
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- No user is supplied
|
- No user is supplied (a workflow acting for an unowned document)
|
||||||
|
- Another user's private document
|
||||||
WHEN:
|
WHEN:
|
||||||
- get_taxonomy_context() is called
|
- get_taxonomy_context() is called
|
||||||
THEN:
|
THEN:
|
||||||
- Permission lookup is skipped and no document_ids restriction is
|
- Only unowned documents are passed to retrieve_similar_nodes(),
|
||||||
passed to retrieve_similar_nodes()
|
never the whole library
|
||||||
"""
|
"""
|
||||||
document = DocumentFactory.create(content="Some content")
|
other_owner = UserFactory.create()
|
||||||
|
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)
|
||||||
|
|
||||||
mock_permitted.assert_not_called()
|
visible = mock_retrieve.call_args.kwargs["document_ids"]
|
||||||
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
assert visible is not 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")
|
||||||
@@ -668,7 +719,9 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
|
|
||||||
get_taxonomy_context(document, user)
|
get_taxonomy_context(document, user)
|
||||||
|
|
||||||
mock_permitted.assert_called_once_with(user)
|
# Once for retrieval, once to re-check the documents whose text goes
|
||||||
|
# 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
|
||||||
@@ -877,6 +930,34 @@ 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,27 +154,19 @@ class TestResolveTagIds:
|
|||||||
user = UserFactory.create()
|
user = UserFactory.create()
|
||||||
assert resolve_tag_ids([], user) == []
|
assert resolve_tag_ids([], user) == []
|
||||||
|
|
||||||
def test_user_none_means_unrestricted_not_owner_isnull(
|
def test_user_none_only_resolves_unowned_tags(self) -> None:
|
||||||
self,
|
"""GIVEN a tag owned by another user, an unowned tag and user=None
|
||||||
mocker: pytest_mock.MockerFixture,
|
(a workflow acting for an unowned document)
|
||||||
) -> None:
|
WHEN resolving both tags' ids
|
||||||
"""GIVEN a tag owned by another user and user=None
|
THEN only the unowned tag is returned - user=None must not expose
|
||||||
WHEN resolving the tag's id
|
another user's private taxonomy.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
tag = TagFactory.create(name="Owned")
|
owned = TagFactory.create(name="Owned", owner=UserFactory.create())
|
||||||
owner = UserFactory.create()
|
unowned = TagFactory.create(name="Unowned", owner=None)
|
||||||
tag.owner = owner
|
|
||||||
tag.save()
|
|
||||||
spy = mocker.patch("documents.permissions.permitted_object_ids")
|
|
||||||
|
|
||||||
result = resolve_tag_ids([tag.pk], None)
|
result = resolve_tag_ids([owned.pk, unowned.pk], None)
|
||||||
|
|
||||||
assert result == [tag]
|
assert result == [unowned]
|
||||||
spy.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -271,39 +271,27 @@ class TestBuildTaxonomyCandidates:
|
|||||||
|
|
||||||
assert result["tags"] == []
|
assert result["tags"] == []
|
||||||
|
|
||||||
def test_user_none_means_unrestricted_not_owner_isnull(
|
def test_user_none_only_includes_unowned_taxonomy(self) -> None:
|
||||||
self,
|
|
||||||
mocker: pytest_mock.MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- An owned tag (owner is not None)
|
- A similar document with a tag owned by another user and an
|
||||||
- user=None (system/superuser/no-auth classification)
|
unowned tag
|
||||||
|
- user=None (a workflow acting for an unowned document)
|
||||||
WHEN:
|
WHEN:
|
||||||
- build_taxonomy_candidates() is called
|
- build_taxonomy_candidates() is called
|
||||||
THEN:
|
THEN:
|
||||||
- The tag is included (no permission filtering occurs)
|
- Only the unowned tag is a candidate - user=None must not expose
|
||||||
- permitted_object_ids() is never called
|
another user's private taxonomy
|
||||||
"""
|
"""
|
||||||
# user=None means "no restriction" throughout ai_classifier.py (the
|
owned = TagFactory.create(name="Owned", owner=UserFactory.create())
|
||||||
# same superuser/no-user fast path get_taxonomy_context uses).
|
unowned = TagFactory.create(name="Unowned", owner=None)
|
||||||
# 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(tag)
|
document.tags.add(owned, unowned)
|
||||||
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 result["tags"][0]["name"] == "Owned"
|
assert [c["name"] for c in result["tags"]] == ["Unowned"]
|
||||||
spy.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormatTaxonomyForPrompt:
|
class TestFormatTaxonomyForPrompt:
|
||||||
|
|||||||
@@ -1272,11 +1272,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.32.6"
|
version = "4.0.12"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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.0" },
|
{ name = "filelock", specifier = ">=3.32,<4.1" },
|
||||||
{ 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