Compare commits

...
23 changed files with 580 additions and 317 deletions
+58 -51
View File
@@ -662,7 +662,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">449,450</context> <context context-type="linenumber">453,454</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
@@ -727,11 +727,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<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">318</context> <context context-type="linenumber">319</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<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">320</context> <context context-type="linenumber">321</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2272120016352772836" datatype="html"> <trans-unit id="2272120016352772836" datatype="html">
@@ -838,7 +838,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">468</context> <context context-type="linenumber">472</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -1048,7 +1048,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">124</context> <context context-type="linenumber">130</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2818183879511244335" datatype="html"> <trans-unit id="2818183879511244335" datatype="html">
@@ -1362,7 +1362,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">394,395</context> <context context-type="linenumber">398,399</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1611,7 +1611,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">169</context> <context context-type="linenumber">173</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2691296884221415710" datatype="html"> <trans-unit id="2691296884221415710" datatype="html">
@@ -1626,7 +1626,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">174</context> <context context-type="linenumber">178</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1661,7 +1661,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">178</context> <context context-type="linenumber">182</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -1696,7 +1696,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">186</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -3330,11 +3330,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<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">326,327</context> <context context-type="linenumber">327,328</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<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">329</context> <context context-type="linenumber">330</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="472206565520537964" datatype="html"> <trans-unit id="472206565520537964" datatype="html">
@@ -3492,49 +3492,49 @@
<source>GitHub</source> <source>GitHub</source>
<context-group purpose="location"> <context-group purpose="location">
<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">336</context> <context context-type="linenumber">337</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4112664765954374539" datatype="html"> <trans-unit id="4112664765954374539" datatype="html">
<source>is available.</source> <source>is available.</source>
<context-group purpose="location"> <context-group purpose="location">
<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">346</context> <context context-type="linenumber">347</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1175891574282637937" datatype="html"> <trans-unit id="1175891574282637937" datatype="html">
<source>Click to view.</source> <source>Click to view.</source>
<context-group purpose="location"> <context-group purpose="location">
<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">346</context> <context context-type="linenumber">347</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9811291095862612" datatype="html"> <trans-unit id="9811291095862612" datatype="html">
<source>Paperless-ngx can automatically check for updates</source> <source>Paperless-ngx can automatically check for updates</source>
<context-group purpose="location"> <context-group purpose="location">
<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">350</context> <context context-type="linenumber">351</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="894819944961861800" datatype="html"> <trans-unit id="894819944961861800" datatype="html">
<source> How does this work? </source> <source> How does this work? </source>
<context-group purpose="location"> <context-group purpose="location">
<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">358</context> <context context-type="linenumber">359</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="509090351011426949" datatype="html"> <trans-unit id="509090351011426949" datatype="html">
<source>Update available</source> <source>Update available</source>
<context-group purpose="location"> <context-group purpose="location">
<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">370</context> <context context-type="linenumber">371</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1329827712962827905" datatype="html"> <trans-unit id="1329827712962827905" datatype="html">
<source>Configure update checking</source> <source>Configure update checking</source>
<context-group purpose="location"> <context-group purpose="location">
<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">375</context> <context context-type="linenumber">376</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1542489069631984294" datatype="html"> <trans-unit id="1542489069631984294" datatype="html">
@@ -3714,22 +3714,22 @@
<source>Successfully updated object.</source> <source>Successfully updated object.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">213</context> <context context-type="linenumber">219</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">251</context> <context context-type="linenumber">257</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1801333259018423190" datatype="html"> <trans-unit id="1801333259018423190" datatype="html">
<source>Error occurred saving object.</source> <source>Error occurred saving object.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">216</context> <context context-type="linenumber">222</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">254</context> <context context-type="linenumber">260</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8193912662253833654" datatype="html"> <trans-unit id="8193912662253833654" datatype="html">
@@ -5227,7 +5227,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">360,361</context> <context context-type="linenumber">364,365</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8057014866157903311" datatype="html"> <trans-unit id="8057014866157903311" datatype="html">
@@ -6134,7 +6134,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">167</context> <context context-type="linenumber">171</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -7627,6 +7627,13 @@
<context context-type="linenumber">120</context> <context context-type="linenumber">120</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5700628356844396417" datatype="html">
<source>{VAR_PLURAL, plural, =1 {1 existing value suggested below} other {<x id="INTERPOLATION"/> existing values suggested below}}</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
<context context-type="linenumber">53</context>
</context-group>
</trans-unit>
<trans-unit id="9180110319941008393" datatype="html"> <trans-unit id="9180110319941008393" datatype="html">
<source>Environment</source> <source>Environment</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -8314,21 +8321,21 @@
<source>Details</source> <source>Details</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">164,165</context> <context context-type="linenumber">168,169</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5114742157723900905" datatype="html"> <trans-unit id="5114742157723900905" datatype="html">
<source>Date created</source> <source>Date created</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">171</context> <context context-type="linenumber">175</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5607669932062416162" datatype="html"> <trans-unit id="5607669932062416162" datatype="html">
<source>Default</source> <source>Default</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">183</context> <context context-type="linenumber">187</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context> <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
@@ -8339,14 +8346,14 @@
<source>Content</source> <source>Content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">290,291</context> <context context-type="linenumber">294,295</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="218403386307979629" datatype="html"> <trans-unit id="218403386307979629" datatype="html">
<source>Metadata</source> <source>Metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">299,300</context> <context context-type="linenumber">303,304</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
@@ -8357,147 +8364,147 @@
<source>Date modified</source> <source>Date modified</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">306,307</context> <context context-type="linenumber">310,311</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6392918669949841614" datatype="html"> <trans-unit id="6392918669949841614" datatype="html">
<source>Date added</source> <source>Date added</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">310,311</context> <context context-type="linenumber">314,315</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="146828917013192897" datatype="html"> <trans-unit id="146828917013192897" datatype="html">
<source>Media filename</source> <source>Media filename</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">314,315</context> <context context-type="linenumber">318,319</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4500855521601039868" datatype="html"> <trans-unit id="4500855521601039868" datatype="html">
<source>Original filename</source> <source>Original filename</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">318,319</context> <context context-type="linenumber">322,323</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2659735245739197634" datatype="html"> <trans-unit id="2659735245739197634" datatype="html">
<source>Original SHA256 checksum</source> <source>Original SHA256 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">322,323</context> <context context-type="linenumber">326,327</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5888243105821763422" datatype="html"> <trans-unit id="5888243105821763422" datatype="html">
<source>Original file size</source> <source>Original file size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">326,327</context> <context context-type="linenumber">330,331</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2696647325713149563" datatype="html"> <trans-unit id="2696647325713149563" datatype="html">
<source>Original mime type</source> <source>Original mime type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">330,331</context> <context context-type="linenumber">334,335</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6714358112223607756" datatype="html"> <trans-unit id="6714358112223607756" datatype="html">
<source>Archive SHA256 checksum</source> <source>Archive SHA256 checksum</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">335,336</context> <context context-type="linenumber">339,340</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6033581412811562084" datatype="html"> <trans-unit id="6033581412811562084" datatype="html">
<source>Archive file size</source> <source>Archive file size</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">341,342</context> <context context-type="linenumber">345,346</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6992781481378431874" datatype="html"> <trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source> <source>Original document metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">350</context> <context context-type="linenumber">354</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2846565152091361585" datatype="html"> <trans-unit id="2846565152091361585" datatype="html">
<source>Archived document metadata</source> <source>Archived document metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">353</context> <context context-type="linenumber">357</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7206723502037428235" datatype="html"> <trans-unit id="7206723502037428235" datatype="html">
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document()?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{document().notes.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document()?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{document().notes.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">372,375</context> <context context-type="linenumber">376,379</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="186236568870281953" datatype="html"> <trans-unit id="186236568870281953" datatype="html">
<source>History</source> <source>History</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">383,384</context> <context context-type="linenumber">387,388</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8236092845697214347" datatype="html"> <trans-unit id="8236092845697214347" datatype="html">
<source> Duplicates <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{ document().duplicate_documents.length }}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source> <source> Duplicates <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{ document().duplicate_documents.length }}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">406,409</context> <context context-type="linenumber">410,413</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6449374629822973702" datatype="html"> <trans-unit id="6449374629822973702" datatype="html">
<source>Duplicate documents detected:</source> <source>Duplicate documents detected:</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">411,412</context> <context context-type="linenumber">415,416</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="14058600336670816" datatype="html"> <trans-unit id="14058600336670816" datatype="html">
<source>In trash</source> <source>In trash</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">422,423</context> <context context-type="linenumber">426,427</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5129524307369213584" datatype="html"> <trans-unit id="5129524307369213584" datatype="html">
<source>Save &amp; next</source> <source>Save &amp; next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">451</context> <context context-type="linenumber">455</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4910102545766233758" datatype="html"> <trans-unit id="4910102545766233758" datatype="html">
<source>Save &amp; close</source> <source>Save &amp; close</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">453</context> <context context-type="linenumber">457</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3823219296477075982" datatype="html"> <trans-unit id="3823219296477075982" datatype="html">
<source>Discard</source> <source>Discard</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">455,456</context> <context context-type="linenumber">459,460</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1309556917227148591" datatype="html"> <trans-unit id="1309556917227148591" datatype="html">
<source>Document loading...</source> <source>Document loading...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">463</context> <context context-type="linenumber">467</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8191371354890763172" datatype="html"> <trans-unit id="8191371354890763172" datatype="html">
<source>Enter Password</source> <source>Enter Password</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">517</context> <context context-type="linenumber">521</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5758784066858623886" datatype="html"> <trans-unit id="5758784066858623886" datatype="html">
@@ -73,7 +73,7 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" <nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()" [ngClass]="slimSidebarEnabled ? 'slim' : 'expanded'" [class.animating]="slimSidebarAnimating()"
[ngbCollapse]="isMenuCollapsed()"> [ngbCollapse]="isMenuCollapsed()">
@if (canSaveSettings) { @if (canSaveSettings) {
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label> <button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
@@ -90,7 +90,7 @@
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span> <i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@@ -99,7 +99,7 @@
(click)="closeMenu()" (click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span> <i-bs class="me-2" name="files"></i-bs><span class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -170,7 +170,7 @@
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()" [class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span> <i-bs class="me-2" name="x"></i-bs><span class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
</button> </button>
</li> </li>
} }
@@ -189,7 +189,7 @@
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()" [routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span> <i-bs name="stack"></i-bs><span class="nav-link-label ms-2"><ng-container i18n>Attributes</ng-container></span>
</a> </a>
@if (!slimSidebarEnabled && canSaveSettings) { @if (!slimSidebarEnabled && canSaveSettings) {
<button <button
@@ -210,27 +210,27 @@
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span> <i-bs class="me-2" name="tags"></i-bs><span class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span> <i-bs class="me-2" name="person"></i-bs><span class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span> <i-bs class="me-2" name="hash"></i-bs><span class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span> <i-bs class="me-2" name="folder"></i-bs><span class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span> <i-bs class="me-2" name="ui-radios"></i-bs><span class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -241,7 +241,7 @@
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span> <i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" <li class="nav-item app-link"
@@ -250,7 +250,7 @@
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span> <i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
@@ -258,14 +258,14 @@
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span> <i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash" <a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span> <i-bs class="me-2" name="trash"></i-bs><span class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -281,21 +281,21 @@
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span> <i-bs class="me-2" name="gear"></i-bs><span class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span> <i-bs class="me-2" name="sliders2-vertical"></i-bs><span class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span> <i-bs class="me-2" name="people"></i-bs><span class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" <li class="nav-item app-link"
@@ -304,9 +304,10 @@
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) { <i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span> @if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
}</span> <span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
}
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) { @if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span> <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
} }
@@ -317,7 +318,7 @@
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span> <i-bs class="me-2" name="text-left"></i-bs><span class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
} }
@@ -388,7 +389,7 @@
</nav> </nav>
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()" <main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'"> [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
</div> </div>
@@ -11,6 +11,7 @@
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent); border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
overflow-y: auto; overflow-y: auto;
--pngx-sidebar-width: 100%; --pngx-sidebar-width: 100%;
width: var(--pngx-sidebar-width);
max-width: var(--pngx-sidebar-width); max-width: var(--pngx-sidebar-width);
transition: all .2s ease; transition: all .2s ease;
@@ -32,17 +33,10 @@
display: none !important; display: none !important;
} }
// These come from the col-* classes for non-slim sidebar, needed for animation
@media (min-width: 768px) { @media (min-width: 768px) {
--pngx-sidebar-width: 25%; &.expanded {
} --pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
}
@media (min-width: 992px) {
--pngx-sidebar-width: 16.66666667%;
}
@media (min-width: 2400px) {
--pngx-sidebar-width: 8.33333333%;
} }
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
@@ -260,6 +254,26 @@ main {
} }
} }
.sidebar .nav-link:has(> .nav-link-label) {
display: flex;
align-items: center;
min-width: 0;
> i-bs {
flex: 0 0 auto;
}
> .nav-link-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
.sidebar:not(.slim):not(.animating) .nav-link > .nav-link-label {
overflow: hidden;
}
.sidebar .nav-anchor, .sidebar .nav-label { .sidebar .nav-anchor, .sidebar .nav-label {
padding: .25rem .7rem; padding: .25rem .7rem;
} }
@@ -385,7 +399,7 @@ main {
} }
::ng-deep .navbar-official-logo { ::ng-deep .navbar-official-logo {
will-change: filter; // Safari repaints the whole navbar on filter change without this will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3)); filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
transition: filter .15s ease-in-out; transition: filter .15s ease-in-out;
@@ -398,6 +412,8 @@ main {
width: 1.65rem; width: 1.65rem;
height: 1.65rem; height: 1.65rem;
flex: 0 0 auto; flex: 0 0 auto;
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
transition: filter .15s ease-in-out; transition: filter .15s ease-in-out;
} }
@@ -430,6 +446,8 @@ main {
max-width: 5rem; max-width: 5rem;
flex: 0 0 auto; flex: 0 0 auto;
object-fit: contain; object-fit: contain;
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
transition: filter .15s ease-in-out, transform .15s ease-in-out; transition: filter .15s ease-in-out, transform .15s ease-in-out;
} }
@@ -9,7 +9,7 @@
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
[ngModel]="query()" [ngModel]="query()"
(ngModelChange)="queryDebounce.next($event)" (ngModelChange)="onQueryChange($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor> ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle"> <div class="position-absolute top-50 end-0 translate-middle">
@@ -272,6 +272,19 @@ describe('GlobalSearchComponent', () => {
expect(advancedSearchSpy).toHaveBeenCalled() expect(advancedSearchSpy).toHaveBeenCalled()
}) })
it('should set query immediately and run full search on enter without waiting for debounce', () => {
jest.useFakeTimers()
const searchSpy = jest.spyOn(searchService, 'globalSearch')
searchSpy.mockReturnValue(of({} as any))
const fullSearchSpy = jest.spyOn(component, 'runFullSearch')
component.onQueryChange('test')
expect(component.query()).toBe('test')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(fullSearchSpy).toHaveBeenCalled()
expect(searchSpy).not.toHaveBeenCalled()
jest.useRealTimers()
})
it('should search on query debounce', () => { it('should search on query debounce', () => {
jest.useFakeTimers() jest.useFakeTimers()
const query = 'test' const query = 'test'
@@ -119,6 +119,12 @@ export class GlobalSearchComponent implements OnInit {
}) })
} }
public onQueryChange(text: string) {
// set immediately so Enter / the full search button work without waiting for the debounce
this.query.set(text)
this.queryDebounce.next(text)
}
public ngOnInit() { public ngOnInit() {
this.hotkeyService this.hotkeyService
.addShortcut({ keys: '/', description: $localize`Global search` }) .addShortcut({ keys: '/', description: $localize`Global search` })
@@ -25,29 +25,34 @@
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown"> <div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0"> <div class="list-group list-group-flush small pb-0">
@if (totalSuggestions === 0) { @if (novelSuggestions === 0 && reusableSuggestions === 0) {
<div class="list-group-item text-muted fst-italic"> <div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small> <small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div> </div>
} }
@if (suggestions()?.suggested_tags.length > 0) { @if (suggestions()?.suggested_tags?.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small> <small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions().suggested_tags; track tag) { @for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button> <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
} }
} }
@if (suggestions()?.suggested_document_types.length > 0) { @if (suggestions()?.suggested_document_types?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div> <div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions().suggested_document_types; track type) { @for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button> <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
} }
} }
@if (suggestions()?.suggested_correspondents.length > 0) { @if (suggestions()?.suggested_correspondents?.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div> <div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions().suggested_correspondents; track correspondent) { @for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button> <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
} }
} }
@if (reusableSuggestions > 0) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
</div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -21,15 +21,115 @@ describe('SuggestionsDropdownComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('should calculate totalSuggestions', () => { it('should exclude suggested storage path names from totalSuggestions', () => {
fixture.componentRef.setInput('suggestions', { fixture.componentRef.setInput('suggestions', {
suggested_correspondents: ['John Doe'], suggested_correspondents: ['John Doe'],
suggested_tags: ['Tag1', 'Tag2'], suggested_tags: ['Tag1', 'Tag2'],
suggested_document_types: ['Type1'], suggested_document_types: ['Type1'],
suggested_storage_paths: ['Finance/Invoices'],
}) })
expect(component.totalSuggestions).toBe(4) expect(component.totalSuggestions).toBe(4)
}) })
it('should count suggestions when a category is absent from the response', () => {
fixture.componentRef.setInput('suggestions', {
suggested_tags: ['Tag1'],
})
expect(component.totalSuggestions).toBe(1)
})
it('should count reused values the document does not have yet', () => {
fixture.componentRef.setInput('suggestions', {
tags: [1, 2, 3],
correspondents: [10],
document_types: [20],
suggested_tags: ['NewTag'],
})
fixture.componentRef.setInput('appliedTags', [2])
fixture.componentRef.setInput('appliedDocumentType', 20)
// tags 1 and 3 are not applied yet, correspondent 10 is not set, tag 2 and
// document type 20 already are.
expect(component.reusableSuggestions).toBe(3)
expect(component.novelSuggestions).toBe(1)
expect(component.totalSuggestions).toBe(4)
})
it('should not count reused values that are already applied', () => {
fixture.componentRef.setInput('suggestions', {
tags: [1],
correspondents: [10],
document_types: [20],
})
fixture.componentRef.setInput('appliedTags', [1])
fixture.componentRef.setInput('appliedCorrespondent', 10)
fixture.componentRef.setInput('appliedDocumentType', 20)
expect(component.totalSuggestions).toBe(0)
})
it('should point at the fields when suggestions are all reused', () => {
// The dropdown lists only values to create, so a response made entirely of
// reused existing objects used to render as "No novel suggestions".
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('suggestions', {
tags: [1, 2],
suggested_tags: [],
suggested_correspondents: [],
suggested_document_types: [],
})
fixture.detectChanges()
component.clickSuggest()
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain(
'2 existing values suggested below'
)
expect(fixture.nativeElement.textContent).not.toContain(
'No novel suggestions'
)
})
it('should account for reused values alongside values to create', () => {
// The badge counts both, but only the novel names are listed here, so the
// dropdown has to say where the rest of the count came from.
fixture.componentRef.setInput('aiEnabled', true)
fixture.componentRef.setInput('suggestions', {
tags: [6, 3],
suggested_tags: ['Arbitration', 'New York'],
suggested_correspondents: [],
suggested_document_types: [],
})
fixture.detectChanges()
component.clickSuggest()
fixture.detectChanges()
expect(component.totalSuggestions).toBe(4)
expect(fixture.nativeElement.textContent).toContain('Arbitration')
expect(fixture.nativeElement.textContent).toContain(
'2 existing values suggested below'
)
})
it('should count classic (non-AI) suggestions, which are ids only', () => {
// /api/documents/{id}/suggestions/ returns only id arrays and no
// suggested_* keys at all, so every one of its suggestions is a reused
// existing object - including storage paths.
fixture.componentRef.setInput('suggestions', {
correspondents: [4],
tags: [6, 3],
document_types: [2],
storage_paths: [7],
dates: ['2005-01-01'],
})
expect(component.novelSuggestions).toBe(0)
expect(component.totalSuggestions).toBe(5)
fixture.componentRef.setInput('appliedStoragePath', 7)
expect(component.totalSuggestions).toBe(4)
})
it('should show when a completed request returned no suggestions', () => { it('should show when a completed request returned no suggestions', () => {
fixture.componentRef.setInput('suggestions', { fixture.componentRef.setInput('suggestions', {
correspondents: [], correspondents: [],
@@ -25,6 +25,11 @@ export class SuggestionsDropdownComponent {
readonly loading = input(false) readonly loading = input(false)
readonly disabled = input(false) readonly disabled = input(false)
readonly appliedTags = input<number[]>([])
readonly appliedCorrespondent = input<number>(null)
readonly appliedDocumentType = input<number>(null)
readonly appliedStoragePath = input<number>(null)
@Output() @Output()
getSuggestions: EventEmitter<SuggestionsDropdownComponent> = getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
new EventEmitter() new EventEmitter()
@@ -54,14 +59,46 @@ export class SuggestionsDropdownComponent {
} }
} }
get totalSuggestions(): number { get novelSuggestions(): number {
return ( return (
this.suggestions()?.suggested_correspondents?.length + (this.suggestions()?.suggested_correspondents?.length ?? 0) +
this.suggestions()?.suggested_tags?.length + (this.suggestions()?.suggested_tags?.length ?? 0) +
this.suggestions()?.suggested_document_types?.length || 0 (this.suggestions()?.suggested_document_types?.length ?? 0)
) )
} }
get reusableSuggestions(): number {
const correspondent = this.appliedCorrespondent()
const documentType = this.appliedDocumentType()
const storagePath = this.appliedStoragePath()
// Storage paths count here but not in novelSuggestions: an existing one
// can be applied from the field, a suggested name cannot create one.
return (
this.countUnapplied(this.suggestions()?.tags, this.appliedTags()) +
this.countUnapplied(
this.suggestions()?.correspondents,
correspondent ? [correspondent] : []
) +
this.countUnapplied(
this.suggestions()?.document_types,
documentType ? [documentType] : []
) +
this.countUnapplied(
this.suggestions()?.storage_paths,
storagePath ? [storagePath] : []
)
)
}
get totalSuggestions(): number {
return this.novelSuggestions + this.reusableSuggestions
}
private countUnapplied(suggested: number[], applied: number[]): number {
return (suggested ?? []).filter((id) => !(applied ?? []).includes(id))
.length
}
get noSuggestions(): boolean { get noSuggestions(): boolean {
const suggestions = this.suggestions() const suggestions = this.suggestions()
return ( return (
@@ -9,7 +9,7 @@
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload> <input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form> </form>
@if (getStatus().length > 0) { @if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'"> <div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll"> <div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
<div class="card shadow-sm consumer-status-card"> <div class="card shadow-sm consumer-status-card">
<div class="card-body"> <div class="card-body">
@@ -134,6 +134,10 @@
[loading]="suggestionsLoading()" [loading]="suggestionsLoading()"
[suggestions]="suggestions()" [suggestions]="suggestions()"
[aiEnabled]="aiEnabled" [aiEnabled]="aiEnabled"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
(getSuggestions)="getSuggestions()" (getSuggestions)="getSuggestions()"
(addTag)="createTag($event)" (addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)" (addDocumentType)="createDocumentType($event)"
+3 -1
View File
@@ -12,9 +12,11 @@
margin-right: 0 !important; margin-right: 0 !important;
} }
main.col-lg-10 { main {
max-width: 100%; max-width: 100%;
flex-basis: 100%; flex-basis: 100%;
width: 100% !important;
padding-left: 0 !important;
display: block; display: block;
} }
+10 -11
View File
@@ -52,20 +52,10 @@ $grid-breakpoints: (
} }
} }
@media (min-width: 2400px) {
.col-xxxl-1 {
flex: 0 0 auto;
width: 8.33333333%;
}
.col-xxxl-11 {
flex: 0 0 auto;
width:91.66666667%
}
}
// Paperless-ngx styles // Paperless-ngx styles
body { body {
--pngx-body-font-size: 0.875rem; --pngx-body-font-size: 0.875rem;
--pngx-sidebar-expanded-width: clamp(15rem, 16.66666667vw, 20rem);
font-size: var(--pngx-body-font-size); font-size: var(--pngx-body-font-size);
height: 100vh; height: 100vh;
letter-spacing: -0.005em; letter-spacing: -0.005em;
@@ -107,6 +97,15 @@ body {
.col-slim { .col-slim {
padding-left: calc(56px + $grid-gutter-width) !important; padding-left: calc(56px + $grid-gutter-width) !important;
} }
.col-sidebar-expanded {
flex: 0 0 auto;
width: calc(100% - var(--pngx-sidebar-expanded-width));
}
.offset-sidebar-expanded {
margin-left: var(--pngx-sidebar-expanded-width);
}
} }
svg.logo { svg.logo {
+2 -1
View File
@@ -7,9 +7,10 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from documents.models import Document from documents.models import Document
from documents.tests.utils import DirectoriesMixin
class TestTrashAPI(APITestCase): class TestTrashAPI(DirectoriesMixin, APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
@@ -409,6 +409,7 @@ class TestBulkDownloadPermissionChecksRootDocument:
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.usefixtures("_search_index")
class TestTrashRestorePermissionBoundary: class TestTrashRestorePermissionBoundary:
def test_restore_rejects_document_without_delete_permission( def test_restore_rejects_document_without_delete_permission(
self, self,
+9 -15
View File
@@ -1,4 +1,3 @@
import json
import logging import logging
from django.conf import settings from django.conf import settings
@@ -9,6 +8,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import TaxonomyChoiceDict from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.base_model import classification_suggestions_to_model
from paperless_ai.client import AIClient from paperless_ai.client import AIClient
from paperless_ai.db import db_connection_released from paperless_ai.db import db_connection_released
from paperless_ai.indexing import _node_document_ids from paperless_ai.indexing import _node_document_ids
@@ -124,20 +124,16 @@ def build_localization_prompt(
suggestions: ClassificationSuggestions, suggestions: ClassificationSuggestions,
output_language: str, output_language: str,
) -> str: ) -> str:
"""``suggestions`` is the full nested-shape result of parse_ai_response """Render internal suggestions in the same flat shape the model returns.
(each taxonomy field a ``{"existing_ids": [...], "new_names": [...]}`` Only the name fields and title are used from the localized response; the
dict) - passed through as-is so the model receives and returns the exact merge step always keeps the original ID fields.
DocumentClassifierSchema shape run_llm_query() always parses against.
Only each field's new_names (never existing_ids, which are plain
resolved-object IDs, not text) and title get used from the response; see
get_ai_document_classification's merge step, which always keeps the
*original* existing_ids regardless of what the model echoes back here.
""" """
language_name = get_language_name(output_language) language_name = get_language_name(output_language)
model_suggestions = classification_suggestions_to_model(suggestions)
return render_prompt( return render_prompt(
LocalizationPromptContext( LocalizationPromptContext(
language_name=language_name, language_name=language_name,
suggestions_json=json.dumps(suggestions, ensure_ascii=False), suggestions_json=model_suggestions.model_dump_json(),
), ),
) )
@@ -208,11 +204,9 @@ def get_taxonomy_context(
def parse_ai_response(raw: dict) -> ClassificationSuggestions: def parse_ai_response(raw: dict) -> ClassificationSuggestions:
"""``raw`` is AIClient.run_llm_query()'s return value - already a """``raw`` is AIClient.run_llm_query()'s validated internal-shape result.
DocumentClassifierSchema.model_dump(), so every key below is always This gives the rest of the module a named, typed boundary instead of
present with the right shape; this only exists to give the rest of the passing the client's bare dict straight through everywhere.
module a named, typed boundary instead of passing the client's bare dict
straight through everywhere.
""" """
def _choice(value: dict | None) -> TaxonomyChoiceDict: def _choice(value: dict | None) -> TaxonomyChoiceDict:
+123 -73
View File
@@ -31,38 +31,9 @@ def _truncate_to_field_limit(value: Any, field: FieldInfo) -> Any:
) )
# Docstrings and field descriptions on both models below are serialized into # This model is serialized into the schema handed to the LLM, so its docstring
# the schema handed to the LLM, so write them for the model. Code comments # and field descriptions are instructions for the model. Keep implementation
# should go here only. # details in code comments instead.
class TaxonomyChoice(BaseModel):
"""One field's suggestions: existing values to reuse, plus new ones to create."""
existing_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"IDs from the candidate list shown in the prompt that clearly "
"represent values you would suggest for this field. Never invent "
"an ID, select a weak match merely because it exists, or use an "
"ID when no candidates are shown."
),
)
new_names: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names for clearly supported values that no shown candidate "
"represents. When a candidate represents the same value, use its "
"ID instead so an existing value is not duplicated under a new name."
),
)
@field_validator("existing_ids", "new_names", mode="before")
@classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
class DocumentClassifierSchema(BaseModel): class DocumentClassifierSchema(BaseModel):
"""Classification suggestions for a single document.""" """Classification suggestions for a single document."""
@@ -73,36 +44,79 @@ class DocumentClassifierSchema(BaseModel):
f"{MAX_TITLE_LENGTH} characters." f"{MAX_TITLE_LENGTH} characters."
), ),
) )
tags: TaxonomyChoice = Field( tags: list[str] = Field(
default_factory=TaxonomyChoice, default_factory=list,
max_length=MAX_NEW_NAMES,
description=( description=(
"Topic labels describing what this document is about. A document " "Names of topic labels describing what this document is about, "
"may have several, e.g. 'Insurance', 'Car', 'Warranty'." "e.g. 'Insurance', 'Car', 'Warranty'. When an available tag "
"represents the same label, use its ID in tag_ids instead."
), ),
) )
correspondents: TaxonomyChoice = Field( tag_ids: list[int] = Field(
default_factory=TaxonomyChoice, default_factory=list,
max_length=MAX_EXISTING_IDS,
description=( description=(
"The person, institution or company this document originates " "IDs of available tags that clearly apply to this document. Only "
"from, or was sent to. Not every party merely mentioned in the " "use IDs shown in the prompt; never invent one or choose a weak match."
"text, and not the subject of the document."
), ),
) )
document_types: TaxonomyChoice = Field( correspondents: list[str] = Field(
default_factory=TaxonomyChoice, default_factory=list,
max_length=MAX_NEW_NAMES,
description=( description=(
"What kind of document this is, e.g. 'Invoice', 'Contract', " "Names of people, institutions or companies this document is from "
"'Bank Statement', 'Letter'. Never its subject matter and never " "or was sent to, not every party merely mentioned. When an "
"who sent it." "available correspondent is the same entity, use its ID in "
"correspondent_ids instead."
), ),
) )
storage_paths: TaxonomyChoice = Field( correspondent_ids: list[int] = Field(
default_factory=TaxonomyChoice, default_factory=list,
max_length=MAX_EXISTING_IDS,
description=( description=(
"A folder-style filing location for this document, e.g. " "IDs of available correspondents that clearly apply to this "
"document. Only use IDs shown in the prompt; never invent one or "
"choose a weak match."
),
)
document_types: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names describing what kind of document this is, e.g. 'Invoice', "
"'Contract', 'Bank Statement', 'Letter'. Never use its subject or "
"sender as a document type. When an available document type is the "
"same kind, use its ID in document_type_ids instead."
),
)
document_type_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"IDs of available document types that clearly apply to this "
"document. Only use IDs shown in the prompt; never invent one or "
"choose a weak match."
),
)
storage_paths: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names of folder-style filing locations, e.g. "
"'Finance/Invoices'. Leave empty unless a filing location is " "'Finance/Invoices'. Leave empty unless a filing location is "
"clearly implied - never put tags, document types or " "clearly implied - never put tags, document types or "
"correspondents here." "correspondents here. When an available storage path is the same "
"location, use its ID in storage_path_ids instead."
),
)
storage_path_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"IDs of available storage paths that clearly apply to this "
"document. Only use IDs shown in the prompt; never invent one or "
"choose a weak match."
), ),
) )
dates: list[str] = Field( dates: list[str] = Field(
@@ -115,41 +129,33 @@ class DocumentClassifierSchema(BaseModel):
), ),
) )
@field_validator("title", "dates", mode="before") @field_validator(
"title",
"tags",
"tag_ids",
"correspondents",
"correspondent_ids",
"document_types",
"document_type_ids",
"storage_paths",
"storage_path_ids",
"dates",
mode="before",
)
@classmethod @classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any: def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name]) return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
@classmethod
def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict[str, Any]:
"""Inline TaxonomyChoice for backends that reject JSON Schema refs."""
schema = super().model_json_schema(*args, **kwargs)
taxonomy_choice = schema.pop("$defs")["TaxonomyChoice"]
for field in ("tags", "correspondents", "document_types", "storage_paths"):
# Pydantic emits a field's description as a sibling of its $ref;
# those keys must survive and win over the shared definition.
siblings = {
key: value
for key, value in schema["properties"][field].items()
if key != "$ref"
}
schema["properties"][field] = taxonomy_choice | siblings
return schema
class TaxonomyChoiceDict(TypedDict): class TaxonomyChoiceDict(TypedDict):
"""Plain-dict counterpart of TaxonomyChoice - what """Internal representation of names and existing IDs for one taxonomy."""
TaxonomyChoice.model_dump() actually produces, typed for callers that
work with the dumped dict rather than the pydantic instance."""
existing_ids: list[int] existing_ids: list[int]
new_names: list[str] new_names: list[str]
class ClassificationSuggestions(TypedDict): class ClassificationSuggestions(TypedDict):
"""Plain-dict counterpart of DocumentClassifierSchema.model_dump() - """Internal shape used after the flat LLM response is validated."""
the shape threaded through parse_ai_response, build_localization_prompt,
get_ai_document_classification, and the ai_suggestions view."""
title: str title: str
tags: TaxonomyChoiceDict tags: TaxonomyChoiceDict
@@ -157,3 +163,47 @@ class ClassificationSuggestions(TypedDict):
document_types: TaxonomyChoiceDict document_types: TaxonomyChoiceDict
storage_paths: TaxonomyChoiceDict storage_paths: TaxonomyChoiceDict
dates: list[str] dates: list[str]
def model_to_classification_suggestions(
model: DocumentClassifierSchema,
) -> ClassificationSuggestions:
"""Convert the flat, model-friendly response to the internal shape."""
return ClassificationSuggestions(
title=model.title,
tags=TaxonomyChoiceDict(
existing_ids=model.tag_ids,
new_names=model.tags,
),
correspondents=TaxonomyChoiceDict(
existing_ids=model.correspondent_ids,
new_names=model.correspondents,
),
document_types=TaxonomyChoiceDict(
existing_ids=model.document_type_ids,
new_names=model.document_types,
),
storage_paths=TaxonomyChoiceDict(
existing_ids=model.storage_path_ids,
new_names=model.storage_paths,
),
dates=model.dates,
)
def classification_suggestions_to_model(
suggestions: ClassificationSuggestions,
) -> DocumentClassifierSchema:
"""Convert internal suggestions to the flat shape used for localization."""
return DocumentClassifierSchema(
title=suggestions["title"],
tags=suggestions["tags"]["new_names"],
tag_ids=suggestions["tags"]["existing_ids"],
correspondents=suggestions["correspondents"]["new_names"],
correspondent_ids=suggestions["correspondents"]["existing_ids"],
document_types=suggestions["document_types"]["new_names"],
document_type_ids=suggestions["document_types"]["existing_ids"],
storage_paths=suggestions["storage_paths"]["new_names"],
storage_path_ids=suggestions["storage_paths"]["existing_ids"],
dates=suggestions["dates"],
)
+5 -3
View File
@@ -19,7 +19,9 @@ from paperless.network import PinnedHostHTTPTransport
from paperless.network import create_pinned_async_httpx_client from paperless.network import create_pinned_async_httpx_client
from paperless.network import create_pinned_httpx_client from paperless.network import create_pinned_httpx_client
from paperless.network import validate_outbound_http_url from paperless.network import validate_outbound_http_url
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import model_to_classification_suggestions
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
logger = logging.getLogger("paperless_ai.client") logger = logging.getLogger("paperless_ai.client")
@@ -115,7 +117,7 @@ class AIClient:
else: else:
raise ValueError(f"Unsupported LLM backend: {self.settings.llm_backend}") raise ValueError(f"Unsupported LLM backend: {self.settings.llm_backend}")
def run_llm_query(self, prompt: str) -> str: def run_llm_query(self, prompt: str) -> ClassificationSuggestions:
logger.debug( logger.debug(
"Running LLM query against %s with model %s", "Running LLM query against %s with model %s",
self.settings.llm_backend, self.settings.llm_backend,
@@ -134,7 +136,7 @@ class AIClient:
) )
logger.debug("LLM query result: %s", result) logger.debug("LLM query result: %s", result)
parsed = DocumentClassifierSchema(**json.loads(result.message.content)) parsed = DocumentClassifierSchema(**json.loads(result.message.content))
return parsed.model_dump() return model_to_classification_suggestions(parsed)
from llama_index.core.program.function_program import get_function_tool from llama_index.core.program.function_program import get_function_tool
@@ -153,7 +155,7 @@ class AIClient:
) )
logger.debug("LLM query result: %s", tool_calls) logger.debug("LLM query result: %s", tool_calls)
parsed = DocumentClassifierSchema(**tool_calls[0].tool_kwargs) parsed = DocumentClassifierSchema(**tool_calls[0].tool_kwargs)
return parsed.model_dump() return model_to_classification_suggestions(parsed)
@contextmanager @contextmanager
def _normalize_timeouts(self) -> Iterator[None]: def _normalize_timeouts(self) -> Iterator[None]:
+2 -2
View File
@@ -13,10 +13,10 @@ Analyze the following document and fill in these fields:
- dates: up to 3 relevant dates in YYYY-MM-DD format - dates: up to 3 relevant dates in YYYY-MM-DD format
{% if has_candidates %} {% if has_candidates %}
For tags, correspondents, document types, and storage paths: first decide whether there is a useful, well-supported suggestion. If an available candidate clearly represents that suggestion, put its id in existing_ids instead of duplicating it in new_names. If no candidate represents the suggestion, put its name in new_names. Do not choose a weak candidate merely because it exists. For tags, correspondents, document types, and storage paths: first decide whether there is a useful, well-supported suggestion. If an available candidate clearly represents that suggestion, put its id in the matching tag_ids, correspondent_ids, document_type_ids, or storage_path_ids field instead of repeating its name. If no candidate represents the suggestion, put its name in tags, correspondents, document_types, or storage_paths. Do not choose a weak candidate merely because it exists.
{% else %} {% else %}
No candidates are shown for this document, so leave every existing_ids list empty and put each suggestion's name in new_names. No candidates are shown for this document, so leave every field ending in "_ids" empty and put suggestions in the corresponding name fields.
{% endif %} {% endif %}
Filename: Filename:
+1 -1
View File
@@ -1,6 +1,6 @@
You are localizing document classification suggestions for display in Paperless-ngx. You are localizing document classification suggestions for display in Paperless-ngx.
Rewrite only the "title" field and each taxonomy field's "new_names" list in {{ language_name }}. Leave every "existing_ids" list exactly as given - these are database identifiers, not text, and are not used from your response even if changed. Rewrite only the "title", "tags", "document_types", and "storage_paths" fields in {{ language_name }}. Leave every field ending in "_ids" exactly as given - these are database identifiers, not text, and are not used from your response even if changed.
Do not translate correspondents or dates. Do not translate correspondents or dates.
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists. Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
+11 -10
View File
@@ -245,7 +245,7 @@ def test_prompt_with_without_rag(mock_document):
THEN: THEN:
- build_prompt_without_rag() has no similar-documents section - build_prompt_without_rag() has no similar-documents section
- build_prompt_with_rag() includes the similar-documents context - build_prompt_with_rag() includes the similar-documents context
- build_localization_prompt() asks to rewrite only new_names/title and - build_localization_prompt() asks to rewrite only names/title and
not to translate correspondents or dates not to translate correspondents or dates
""" """
config = AIConfig() config = AIConfig()
@@ -264,6 +264,7 @@ def test_prompt_with_without_rag(mock_document):
prompt = build_localization_prompt(NESTED_SUGGESTIONS, output_language="de-de") prompt = build_localization_prompt(NESTED_SUGGESTIONS, output_language="de-de")
assert "Rewrite only the" in prompt assert "Rewrite only the" in prompt
assert "Do not translate correspondents or dates" in prompt assert "Do not translate correspondents or dates" in prompt
assert '"tag_ids":[]' in prompt
def test_get_language_name_falls_back_to_language_code(): def test_get_language_name_falls_back_to_language_code():
@@ -607,7 +608,7 @@ def test_build_prompt_without_rag_includes_taxonomy_block():
WHEN: WHEN:
- build_prompt_without_rag() is called with candidates and assigned metadata - build_prompt_without_rag() is called with candidates and assigned metadata
THEN: THEN:
- The candidate's id and the existing_ids/new_names instructions appear - The candidate's id and the flat name/ID instructions appear
- Candidates are presented as deduplication options, not requirements - Candidates are presented as deduplication options, not requirements
""" """
document = DocumentFactory.create(content="Some content") document = DocumentFactory.create(content="Some content")
@@ -633,8 +634,8 @@ def test_build_prompt_without_rag_includes_taxonomy_block():
) )
assert '"id": 12' in prompt assert '"id": 12' in prompt
assert "existing_ids" in prompt assert "tag_ids" in prompt
assert "new_names" in prompt assert "correspondent_ids" in prompt
assert "not requirements" in prompt assert "not requirements" in prompt
assert "weak candidate" in prompt assert "weak candidate" in prompt
@@ -651,7 +652,7 @@ def test_build_prompt_without_rag_identical_when_no_hints():
- Both prompts are identical - Both prompts are identical
- Neither carries the "Available ..." candidate block or the - Neither carries the "Available ..." candidate block or the
id-vs-name routing instruction id-vs-name routing instruction
- Both still tell the model to leave existing_ids empty - Both still tell the model to leave every ID field empty
""" """
document = DocumentFactory.create(content="Some content") document = DocumentFactory.create(content="Some content")
config = AIConfig() config = AIConfig()
@@ -678,8 +679,8 @@ def test_build_prompt_without_rag_identical_when_no_hints():
assert with_empty_hints == with_no_hints assert with_empty_hints == with_no_hints
assert "Available " not in with_no_hints assert "Available " not in with_no_hints
assert "put its id in existing_ids" not in with_no_hints assert "put its id in the matching" not in with_no_hints
assert "leave every existing_ids list empty" in with_no_hints assert 'leave every field ending in "_ids" empty' in with_no_hints
@pytest.mark.django_db @pytest.mark.django_db
@@ -691,9 +692,9 @@ def test_build_prompt_without_rag_tells_model_to_skip_ids_when_no_candidates():
- build_prompt_without_rag() is called with candidates and assigned metadata - build_prompt_without_rag() is called with candidates and assigned metadata
THEN: THEN:
- The assigned-metadata block appears (taxonomy_block is non-empty) - The assigned-metadata block appears (taxonomy_block is non-empty)
- The prompt tells the model to leave existing_ids empty - The prompt tells the model to leave every ID field empty
Staying silent about existing_ids here is not enough: the response schema Staying silent about ID fields here is not enough: the response schema
advertises the field whatever the prompt says, and models fill it with advertises the field whatever the prompt says, and models fill it with
placeholder ids that resolve to real but unrelated objects (#13831). placeholder ids that resolve to real but unrelated objects (#13831).
""" """
@@ -721,7 +722,7 @@ def test_build_prompt_without_rag_tells_model_to_skip_ids_when_no_candidates():
assert "already assigned" in prompt assert "already assigned" in prompt
assert "No candidates are shown" in prompt assert "No candidates are shown" in prompt
assert "leave every existing_ids list empty" in prompt assert 'leave every field ending in "_ids" empty' in prompt
@pytest.mark.django_db @pytest.mark.django_db
+111 -97
View File
@@ -6,8 +6,9 @@ from paperless_ai.base_model import MAX_NEW_NAMES
from paperless_ai.base_model import MAX_TITLE_LENGTH from paperless_ai.base_model import MAX_TITLE_LENGTH
from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import TaxonomyChoice
from paperless_ai.base_model import TaxonomyChoiceDict from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.base_model import classification_suggestions_to_model
from paperless_ai.base_model import model_to_classification_suggestions
def test_document_classifier_schema_declared_defaults(): def test_document_classifier_schema_declared_defaults():
@@ -18,29 +19,67 @@ def test_document_classifier_schema_declared_defaults():
WHEN: WHEN:
- The schema is dumped to a dict via model_dump() - The schema is dumped to a dict via model_dump()
THEN: THEN:
- Every taxonomy field dumps as an empty existing_ids/new_names - Every name and ID field, and dates, dump as empty lists
dict, and dates dumps as an empty list
This is the one project-owned fact worth pinning down here: which The model may omit optional fields, so the schema must provide the complete
defaults this schema declares for a partial LLM response (see empty shape expected by the conversion and matching pipeline.
client.py's DocumentClassifierSchema(**json.loads(...)) call sites,
which construct from whatever subset of fields the backend actually
returned). It deliberately hardcodes the expected literal rather than
re-deriving it from TaxonomyChoice()/[] - pydantic's own
default_factory machinery is not this project's to re-test, and a
test that recomputes the expected value from the model under test
can't ever catch a wrong default.
""" """
schema = DocumentClassifierSchema(title="Test Title") schema = DocumentClassifierSchema(title="Test Title")
dumped = schema.model_dump() dumped = schema.model_dump()
empty_choice = {"existing_ids": [], "new_names": []} assert dumped == {
assert dumped["tags"] == empty_choice "title": "Test Title",
assert dumped["correspondents"] == empty_choice "tags": [],
assert dumped["document_types"] == empty_choice "tag_ids": [],
assert dumped["storage_paths"] == empty_choice "correspondents": [],
assert dumped["dates"] == [] "correspondent_ids": [],
"document_types": [],
"document_type_ids": [],
"storage_paths": [],
"storage_path_ids": [],
"dates": [],
}
def test_flat_model_response_converts_to_internal_taxonomy_choices():
"""
GIVEN:
- A flat model response with separate name and candidate-ID fields
WHEN:
- It is converted to Paperless' internal suggestion representation
THEN:
- Names and IDs are paired under their taxonomy category
"""
parsed = DocumentClassifierSchema(
title="Electricity Bill",
tags=["Utilities", "Electricity"],
tag_ids=[12],
correspondents=["Power Company"],
correspondent_ids=[23],
document_types=["Utility Bill"],
document_type_ids=[34],
storage_paths=["Finance/Utilities"],
storage_path_ids=[45],
)
suggestions = model_to_classification_suggestions(parsed)
assert suggestions["tags"] == {
"existing_ids": [12],
"new_names": ["Utilities", "Electricity"],
}
assert suggestions["correspondents"] == {
"existing_ids": [23],
"new_names": ["Power Company"],
}
assert suggestions["document_types"] == {
"existing_ids": [34],
"new_names": ["Utility Bill"],
}
assert suggestions["storage_paths"] == {
"existing_ids": [45],
"new_names": ["Finance/Utilities"],
}
def test_document_classifier_schema_json_schema_is_self_contained(): def test_document_classifier_schema_json_schema_is_self_contained():
@@ -50,23 +89,20 @@ def test_document_classifier_schema_json_schema_is_self_contained():
WHEN: WHEN:
- Its JSON schema is generated via model_json_schema() - Its JSON schema is generated via model_json_schema()
THEN: THEN:
- No $defs section and no $ref at any depth survives in the schema - The schema contains no definitions, references, or nested objects
- Each taxonomy property carries existing_ids/new_names inline - Every response field is a scalar or flat array
Regression guard: Google's function-declaration schema rejects the $ref This keeps the function declaration compatible with backends that reject
Pydantic normally emits for the nested TaxonomyChoice model. JSON Schema references and with smaller models that struggle with nesting.
""" """
schema = DocumentClassifierSchema.model_json_schema() schema = DocumentClassifierSchema.model_json_schema()
assert "$defs" not in schema assert "$defs" not in schema
assert "$ref" not in json.dumps(schema) assert "$ref" not in json.dumps(schema)
for field in ("tags", "correspondents", "document_types", "storage_paths"): assert all(
field_schema = schema["properties"][field] field_schema.get("type") != "object"
assert "$ref" not in field_schema for field_schema in schema["properties"].values()
assert set(field_schema["properties"].keys()) == { )
"existing_ids",
"new_names",
}
def test_every_field_describes_itself_to_the_model(): def test_every_field_describes_itself_to_the_model():
@@ -76,8 +112,7 @@ def test_every_field_describes_itself_to_the_model():
WHEN: WHEN:
- Its JSON schema is generated via model_json_schema() - Its JSON schema is generated via model_json_schema()
THEN: THEN:
- Every property, and every property of each inlined TaxonomyChoice, - Every property carries a non-empty description
carries a non-empty description
In tool-calling mode the schema is most of what tells the model how to In tool-calling mode the schema is most of what tells the model how to
fill these fields; on field names alone, small models can bin tags and fill these fields; on field names alone, small models can bin tags and
@@ -86,48 +121,14 @@ def test_every_field_describes_itself_to_the_model():
schema = DocumentClassifierSchema.model_json_schema() schema = DocumentClassifierSchema.model_json_schema()
undescribed = [ undescribed = [
f"{owner}.{name}" name
for owner, definition in [ for name, prop in schema["properties"].items()
("DocumentClassifierSchema", schema),
*(
(name, prop)
for name, prop in schema["properties"].items()
if prop.get("type") == "object"
),
]
for name, prop in definition.get("properties", {}).items()
if not prop.get("description") if not prop.get("description")
] ]
assert undescribed == [] assert undescribed == []
def test_inlining_keeps_each_taxonomy_fields_own_description():
"""
GIVEN:
- The DocumentClassifierSchema pydantic model
WHEN:
- Its JSON schema is generated via model_json_schema()
THEN:
- Each taxonomy field keeps its own description, not the shared one
- The inlined TaxonomyChoice properties survive underneath it
Pydantic emits a field's description as a sibling of its $ref, so
replacing the property outright collapses all four onto TaxonomyChoice's
docstring - which still passes a "has a description" check.
"""
properties = DocumentClassifierSchema.model_json_schema()["properties"]
taxonomy_fields = ("tags", "correspondents", "document_types", "storage_paths")
descriptions = {
field: properties[field]["description"] for field in taxonomy_fields
}
assert len(set(descriptions.values())) == len(taxonomy_fields)
for field in taxonomy_fields:
assert properties[field]["properties"]["existing_ids"]["description"]
def test_every_sequence_in_the_emitted_schema_is_bounded(): def test_every_sequence_in_the_emitted_schema_is_bounded():
""" """
GIVEN: GIVEN:
@@ -135,22 +136,13 @@ def test_every_sequence_in_the_emitted_schema_is_bounded():
WHEN: WHEN:
- Its JSON schema is generated via model_json_schema() - Its JSON schema is generated via model_json_schema()
THEN: THEN:
- Every array property in the schema, including those on each - Every array property in the schema carries a maxItems
inlined TaxonomyChoice, carries a maxItems
""" """
schema = DocumentClassifierSchema.model_json_schema() schema = DocumentClassifierSchema.model_json_schema()
unbounded = [ unbounded = [
f"{owner}.{name}" name
for owner, definition in [ for name, prop in schema["properties"].items()
("DocumentClassifierSchema", schema),
*(
(name, prop)
for name, prop in schema["properties"].items()
if prop.get("type") == "object"
),
]
for name, prop in definition.get("properties", {}).items()
if prop.get("type") == "array" and "maxItems" not in prop if prop.get("type") == "array" and "maxItems" not in prop
] ]
@@ -182,17 +174,15 @@ def test_over_long_response_is_truncated_rather_than_rejected():
""" """
parsed = DocumentClassifierSchema( parsed = DocumentClassifierSchema(
title="T" * (MAX_TITLE_LENGTH + 50), title="T" * (MAX_TITLE_LENGTH + 50),
tags=TaxonomyChoice( tags=["n"] * (MAX_NEW_NAMES + 20),
existing_ids=list(range(MAX_EXISTING_IDS + 20)), tag_ids=list(range(MAX_EXISTING_IDS + 20)),
new_names=["n"] * (MAX_NEW_NAMES + 20),
),
dates=[f"2016-{month:02d}-01" for month in range(1, 13)], dates=[f"2016-{month:02d}-01" for month in range(1, 13)],
) )
assert len(parsed.title) == MAX_TITLE_LENGTH assert len(parsed.title) == MAX_TITLE_LENGTH
assert len(parsed.dates) == MAX_DATES assert len(parsed.dates) == MAX_DATES
assert len(parsed.tags.existing_ids) == MAX_EXISTING_IDS assert len(parsed.tag_ids) == MAX_EXISTING_IDS
assert len(parsed.tags.new_names) == MAX_NEW_NAMES assert len(parsed.tags) == MAX_NEW_NAMES
def test_truncation_keeps_the_earliest_entries(): def test_truncation_keeps_the_earliest_entries():
@@ -212,24 +202,48 @@ def test_truncation_keeps_the_earliest_entries():
assert parsed.dates == ["2016-10-01", "2016-09-01", "2016-08-01"] assert parsed.dates == ["2016-10-01", "2016-09-01", "2016-08-01"]
def test_model_dump_matches_typed_dict_keys(): def test_model_conversion_matches_internal_typed_dict_keys():
""" """
GIVEN: GIVEN:
- A DocumentClassifierSchema instance - A DocumentClassifierSchema instance
WHEN: WHEN:
- It is dumped to a dict via model_dump() - It is converted to ClassificationSuggestions
THEN: THEN:
- The dumped dict's keys exactly match ClassificationSuggestions' - The converted dict's keys exactly match ClassificationSuggestions'
declared keys declared keys
- The dumped tags dict's keys exactly match TaxonomyChoiceDict's - The converted tags dict's keys exactly match TaxonomyChoiceDict's
declared keys declared keys
""" """
# TaxonomyChoiceDict/ClassificationSuggestions are the static-typing schema = DocumentClassifierSchema(title="T", tags=["Tag"], tag_ids=[1])
# counterparts of TaxonomyChoice/DocumentClassifierSchema - this pins suggestions = model_to_classification_suggestions(schema)
# down that .model_dump()'s actual runtime keys are exactly what the
# TypedDicts declare, so the two don't silently drift apart.
schema = DocumentClassifierSchema(title="T", tags=TaxonomyChoice(existing_ids=[1]))
dumped = schema.model_dump()
assert set(dumped.keys()) == set(ClassificationSuggestions.__annotations__.keys()) assert set(suggestions.keys()) == set(
assert set(dumped["tags"].keys()) == set(TaxonomyChoiceDict.__annotations__.keys()) ClassificationSuggestions.__annotations__.keys(),
)
assert set(suggestions["tags"].keys()) == set(
TaxonomyChoiceDict.__annotations__.keys(),
)
def test_internal_suggestions_round_trip_through_flat_model():
suggestions = ClassificationSuggestions(
title="Electricity Bill",
tags=TaxonomyChoiceDict(existing_ids=[1], new_names=["Utilities"]),
correspondents=TaxonomyChoiceDict(
existing_ids=[2],
new_names=["Power Company"],
),
document_types=TaxonomyChoiceDict(
existing_ids=[3],
new_names=["Utility Bill"],
),
storage_paths=TaxonomyChoiceDict(
existing_ids=[4],
new_names=["Finance/Utilities"],
),
dates=["2026-08-30"],
)
model = classification_suggestions_to_model(suggestions)
assert model_to_classification_suggestions(model) == suggestions
+16 -8
View File
@@ -123,10 +123,14 @@ def test_run_llm_query_ollama_uses_structured_json(mock_ai_config, mock_ollama_l
mock_llm_instance.chat.return_value.message.content = json.dumps( mock_llm_instance.chat.return_value.message.content = json.dumps(
{ {
"title": "Test Title", "title": "Test Title",
"tags": {"existing_ids": [1], "new_names": ["document"]}, "tags": ["document"],
"correspondents": {"existing_ids": [], "new_names": ["John Doe"]}, "tag_ids": [1],
"document_types": {"existing_ids": [], "new_names": ["report"]}, "correspondents": ["John Doe"],
"storage_paths": {"existing_ids": [], "new_names": ["Reports"]}, "correspondent_ids": [],
"document_types": ["report"],
"document_type_ids": [],
"storage_paths": ["Reports"],
"storage_path_ids": [],
"dates": ["2023-01-01"], "dates": ["2023-01-01"],
}, },
) )
@@ -156,10 +160,14 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
tool_name="DocumentClassifierSchema", tool_name="DocumentClassifierSchema",
tool_kwargs={ tool_kwargs={
"title": "Test Title", "title": "Test Title",
"tags": {"existing_ids": [1], "new_names": ["document"]}, "tags": ["document"],
"correspondents": {"existing_ids": [], "new_names": ["John Doe"]}, "tag_ids": [1],
"document_types": {"existing_ids": [], "new_names": ["report"]}, "correspondents": ["John Doe"],
"storage_paths": {"existing_ids": [], "new_names": ["Reports"]}, "correspondent_ids": [],
"document_types": ["report"],
"document_type_ids": [],
"storage_paths": ["Reports"],
"storage_path_ids": [],
"dates": ["2023-01-01"], "dates": ["2023-01-01"],
}, },
) )