mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge branch 'feature-unified-search' into dev
This commit is contained in:
commit
d6a2672cab
@ -48,21 +48,21 @@
|
|||||||
<source>Documents</source>
|
<source>Documents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">49</context>
|
<context context-type="linenumber">51</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2155249406916744630" datatype="html">
|
<trans-unit id="2155249406916744630" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
<source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">115</context>
|
<context context-type="linenumber">116</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6837554170707123455" datatype="html">
|
<trans-unit id="6837554170707123455" datatype="html">
|
||||||
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
<source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
|
||||||
<context context-type="linenumber">136</context>
|
<context context-type="linenumber">138</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9ca82952a6bc860b5391d5975322d8af8ceddfa4" datatype="html">
|
<trans-unit id="9ca82952a6bc860b5391d5975322d8af8ceddfa4" datatype="html">
|
||||||
@ -146,77 +146,77 @@
|
|||||||
<source>ASN</source>
|
<source>ASN</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">105</context>
|
<context context-type="linenumber">111</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7b5c6286aaded63fb279d6deb8aa8c704e085ced" datatype="html">
|
<trans-unit id="7b5c6286aaded63fb279d6deb8aa8c704e085ced" datatype="html">
|
||||||
<source>Correspondent</source>
|
<source>Correspondent</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">111</context>
|
<context context-type="linenumber">117</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="fdf7cbdc140d0aab0f0b6c06065a0fd448ed6a2e" datatype="html">
|
<trans-unit id="fdf7cbdc140d0aab0f0b6c06065a0fd448ed6a2e" datatype="html">
|
||||||
<source>Title</source>
|
<source>Title</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">117</context>
|
<context context-type="linenumber">123</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2bd5919e8098513664a89d5b7b52d61e3063950f" datatype="html">
|
<trans-unit id="2bd5919e8098513664a89d5b7b52d61e3063950f" datatype="html">
|
||||||
<source>Document type</source>
|
<source>Document type</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">123</context>
|
<context context-type="linenumber">129</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1b051734b0ee9021991c91b3ed4e81c244322462" datatype="html">
|
<trans-unit id="1b051734b0ee9021991c91b3ed4e81c244322462" datatype="html">
|
||||||
<source>Created</source>
|
<source>Created</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">129</context>
|
<context context-type="linenumber">135</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="80e3b490720757978c99a7b5af3885faf202b955" datatype="html">
|
<trans-unit id="80e3b490720757978c99a7b5af3885faf202b955" datatype="html">
|
||||||
<source>Added</source>
|
<source>Added</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">135</context>
|
<context context-type="linenumber">141</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9021887951960049161" datatype="html">
|
<trans-unit id="9021887951960049161" datatype="html">
|
||||||
<source>Confirm delete</source>
|
<source>Confirm delete</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">203</context>
|
<context context-type="linenumber">204</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5382975254277698192" datatype="html">
|
<trans-unit id="5382975254277698192" datatype="html">
|
||||||
<source>Do you really want to delete document "<x id="PH" equiv-text="this.document.title"/>"?</source>
|
<source>Do you really want to delete document "<x id="PH" equiv-text="this.document.title"/>"?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">204</context>
|
<context context-type="linenumber">205</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6691075929777935948" datatype="html">
|
<trans-unit id="6691075929777935948" datatype="html">
|
||||||
<source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
|
<source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">205</context>
|
<context context-type="linenumber">206</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="719892092227206532" datatype="html">
|
<trans-unit id="719892092227206532" datatype="html">
|
||||||
<source>Delete document</source>
|
<source>Delete document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">207</context>
|
<context context-type="linenumber">208</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1844801255494293730" datatype="html">
|
<trans-unit id="1844801255494293730" datatype="html">
|
||||||
<source>Error deleting document: <x id="PH" equiv-text="JSON.stringify(error)"/></source>
|
<source>Error deleting document: <x id="PH" equiv-text="JSON.stringify(error)"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">214</context>
|
<context context-type="linenumber">215</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="826b25211922a1b46436589233cb6f1a163d89b7" datatype="html">
|
<trans-unit id="826b25211922a1b46436589233cb6f1a163d89b7" datatype="html">
|
||||||
@ -912,48 +912,6 @@
|
|||||||
<context context-type="linenumber">25</context>
|
<context context-type="linenumber">25</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="49c9ede51100b454f7841b24cd02355c6622bf44" datatype="html">
|
|
||||||
<source>Search results</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/search/search.component.html</context>
|
|
||||||
<context context-type="linenumber">1</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="31976d04f98e8a38098f66ac3a83ad33b576e5db" datatype="html">
|
|
||||||
<source>Invalid search query: <x id="INTERPOLATION" equiv-text="{{errorMessage}}"/></source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/search/search.component.html</context>
|
|
||||||
<context context-type="linenumber">4</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="f7f2e30106223a69bcf0f8e586e6be9dc4e72226" datatype="html">
|
|
||||||
<source>Showing documents similar to <x id="START_LINK" equiv-text="<a routerLink="/documents/{{more_like}}">{{more_like_doc?.original_file_name}}"/><x id="INTERPOLATION" equiv-text="{{more_like_doc?.original_file_name}}</a>"/><x id="CLOSE_LINK" equiv-text="</a>"/></source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/search/search.component.html</context>
|
|
||||||
<context context-type="linenumber">6</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="6e0b0a1ea16f18f2fb1586c53d99d2f22e1aee2e" datatype="html">
|
|
||||||
<source>Search query: <x id="START_ITALIC_TEXT" equiv-text="<i>{{query}}"/><x id="INTERPOLATION" equiv-text="{{query}}</i>"/><x id="CLOSE_ITALIC_TEXT" equiv-text="</i>"/></source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/search/search.component.html</context>
|
|
||||||
<context context-type="linenumber">9</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="afa760e48c97d64d19c1455d18b7834a2256e23f" datatype="html">
|
|
||||||
<source>Did you mean "<x id="START_LINK" equiv-text="<a [routerLink]="" (click)="searchCorrectedQuery()">{{correctedQuery}}"/><x id="INTERPOLATION" equiv-text="{{correctedQuery}}</a>"/><x id="CLOSE_LINK" equiv-text="</a>"/>"?</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/search/search.component.html</context>
|
|
||||||
<context context-type="linenumber">11</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="fe6ced3fcc803bba5a2e6c1a067b9ce62542500e" datatype="html">
|
|
||||||
<source>{VAR_PLURAL, plural, =0 {No results} =1 {One result} other {<x id="INTERPOLATION"/> results}}</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/search/search.component.html</context>
|
|
||||||
<context context-type="linenumber">16</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="41147374f427980a9f1a8cd5e3f4b1666e6f2418" datatype="html">
|
<trans-unit id="41147374f427980a9f1a8cd5e3f4b1666e6f2418" datatype="html">
|
||||||
<source>Paperless-ng</source>
|
<source>Paperless-ng</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@ -1039,81 +997,95 @@
|
|||||||
<context context-type="linenumber">106</context>
|
<context context-type="linenumber">106</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5701618810648052610" datatype="html">
|
|
||||||
<source>Title</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
|
||||||
<context context-type="linenumber">77</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="3100631071441658964" datatype="html">
|
|
||||||
<source>Title & content</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
|
||||||
<context context-type="linenumber">78</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="7517688192215738656" datatype="html">
|
|
||||||
<source>ASN</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
|
||||||
<context context-type="linenumber">79</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="5195932016807797291" datatype="html">
|
<trans-unit id="5195932016807797291" datatype="html">
|
||||||
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find(c => c.id == +rule.value)?.name"/></source>
|
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find(c => c.id == +rule.value)?.name"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">33</context>
|
<context context-type="linenumber">37</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8170755470576301659" datatype="html">
|
<trans-unit id="8170755470576301659" datatype="html">
|
||||||
<source>Without correspondent</source>
|
<source>Without correspondent</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">35</context>
|
<context context-type="linenumber">39</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8705701325879965907" datatype="html">
|
<trans-unit id="8705701325879965907" datatype="html">
|
||||||
<source>Type: <x id="PH" equiv-text="this.documentTypes.find(dt => dt.id == +rule.value)?.name"/></source>
|
<source>Type: <x id="PH" equiv-text="this.documentTypes.find(dt => dt.id == +rule.value)?.name"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">40</context>
|
<context context-type="linenumber">44</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4362173610367509215" datatype="html">
|
<trans-unit id="4362173610367509215" datatype="html">
|
||||||
<source>Without document type</source>
|
<source>Without document type</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">42</context>
|
<context context-type="linenumber">46</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8180755793012580465" datatype="html">
|
<trans-unit id="8180755793012580465" datatype="html">
|
||||||
<source>Tag: <x id="PH" equiv-text="this.tags.find(t => t.id == +rule.value)?.name"/></source>
|
<source>Tag: <x id="PH" equiv-text="this.tags.find(t => t.id == +rule.value)?.name"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">46</context>
|
<context context-type="linenumber">50</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6494566478302448576" datatype="html">
|
<trans-unit id="6494566478302448576" datatype="html">
|
||||||
<source>Without any tag</source>
|
<source>Without any tag</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">50</context>
|
<context context-type="linenumber">54</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6523384805359286307" datatype="html">
|
<trans-unit id="6523384805359286307" datatype="html">
|
||||||
<source>Title: <x id="PH" equiv-text="rule.value"/></source>
|
<source>Title: <x id="PH" equiv-text="rule.value"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">54</context>
|
<context context-type="linenumber">58</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1872523635812236432" datatype="html">
|
<trans-unit id="1872523635812236432" datatype="html">
|
||||||
<source>ASN: <x id="PH" equiv-text="rule.value"/></source>
|
<source>ASN: <x id="PH" equiv-text="rule.value"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
<context context-type="linenumber">57</context>
|
<context context-type="linenumber">61</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5701618810648052610" datatype="html">
|
||||||
|
<source>Title</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">85</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="3100631071441658964" datatype="html">
|
||||||
|
<source>Title & content</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">86</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="7517688192215738656" datatype="html">
|
||||||
|
<source>ASN</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">87</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="1010505078885609376" datatype="html">
|
||||||
|
<source>Advanced search</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">88</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2649431021108393503" datatype="html">
|
||||||
|
<source>More like</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">91</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="02d184c288f567825a1fcbf83bcd3099a10853d5" datatype="html">
|
<trans-unit id="02d184c288f567825a1fcbf83bcd3099a10853d5" datatype="html">
|
||||||
@ -1233,7 +1205,7 @@
|
|||||||
<source>Score:</source>
|
<source>Score:</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
|
||||||
<context context-type="linenumber">66</context>
|
<context context-type="linenumber">86</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="727d980bba2b3e0b3d8705607f1208eef046479b" datatype="html">
|
<trans-unit id="727d980bba2b3e0b3d8705607f1208eef046479b" datatype="html">
|
||||||
|
@ -10,7 +10,6 @@ import { LogsComponent } from './components/manage/logs/logs.component';
|
|||||||
import { SettingsComponent } from './components/manage/settings/settings.component';
|
import { SettingsComponent } from './components/manage/settings/settings.component';
|
||||||
import { TagListComponent } from './components/manage/tag-list/tag-list.component';
|
import { TagListComponent } from './components/manage/tag-list/tag-list.component';
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component';
|
import { NotFoundComponent } from './components/not-found/not-found.component';
|
||||||
import { SearchComponent } from './components/search/search.component';
|
|
||||||
import {DocumentAsnComponent} from "./components/document-asn/document-asn.component";
|
import {DocumentAsnComponent} from "./components/document-asn/document-asn.component";
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
@ -19,7 +18,6 @@ const routes: Routes = [
|
|||||||
{path: 'dashboard', component: DashboardComponent },
|
{path: 'dashboard', component: DashboardComponent },
|
||||||
{path: 'documents', component: DocumentListComponent },
|
{path: 'documents', component: DocumentListComponent },
|
||||||
{path: 'view/:id', component: DocumentListComponent },
|
{path: 'view/:id', component: DocumentListComponent },
|
||||||
{path: 'search', component: SearchComponent },
|
|
||||||
{path: 'documents/:id', component: DocumentDetailComponent },
|
{path: 'documents/:id', component: DocumentDetailComponent },
|
||||||
{path: 'asn/:id', component: DocumentAsnComponent },
|
{path: 'asn/:id', component: DocumentAsnComponent },
|
||||||
|
|
||||||
|
@ -21,8 +21,6 @@ import { CorrespondentEditDialogComponent } from './components/manage/correspond
|
|||||||
import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
|
import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
|
||||||
import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
|
import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
|
||||||
import { TagComponent } from './components/common/tag/tag.component';
|
import { TagComponent } from './components/common/tag/tag.component';
|
||||||
import { SearchComponent } from './components/search/search.component';
|
|
||||||
import { ResultHighlightComponent } from './components/search/result-highlight/result-highlight.component';
|
|
||||||
import { PageHeaderComponent } from './components/common/page-header/page-header.component';
|
import { PageHeaderComponent } from './components/common/page-header/page-header.component';
|
||||||
import { AppFrameComponent } from './components/app-frame/app-frame.component';
|
import { AppFrameComponent } from './components/app-frame/app-frame.component';
|
||||||
import { ToastsComponent } from './components/common/toasts/toasts.component';
|
import { ToastsComponent } from './components/common/toasts/toasts.component';
|
||||||
@ -106,8 +104,6 @@ registerLocaleData(localeEs)
|
|||||||
TagEditDialogComponent,
|
TagEditDialogComponent,
|
||||||
DocumentTypeEditDialogComponent,
|
DocumentTypeEditDialogComponent,
|
||||||
TagComponent,
|
TagComponent,
|
||||||
SearchComponent,
|
|
||||||
ResultHighlightComponent,
|
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
AppFrameComponent,
|
AppFrameComponent,
|
||||||
ToastsComponent,
|
ToastsComponent,
|
||||||
|
@ -10,6 +10,8 @@ import { SearchService } from 'src/app/services/rest/search.service';
|
|||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component';
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component';
|
||||||
import { Meta } from '@angular/platform-browser';
|
import { Meta } from '@angular/platform-browser';
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||||
|
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-app-frame',
|
selector: 'app-app-frame',
|
||||||
@ -24,6 +26,7 @@ export class AppFrameComponent implements OnInit {
|
|||||||
private openDocumentsService: OpenDocumentsService,
|
private openDocumentsService: OpenDocumentsService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
public savedViewService: SavedViewService,
|
public savedViewService: SavedViewService,
|
||||||
|
private list: DocumentListViewService,
|
||||||
private meta: Meta
|
private meta: Meta
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -74,7 +77,7 @@ export class AppFrameComponent implements OnInit {
|
|||||||
|
|
||||||
search() {
|
search() {
|
||||||
this.closeMenu()
|
this.closeMenu()
|
||||||
this.router.navigate(['search'], {queryParams: {query: this.searchField.value}})
|
this.list.quickFilter([{rule_type: FILTER_FULLTEXT_QUERY, value: this.searchField.value}])
|
||||||
}
|
}
|
||||||
|
|
||||||
closeDocument(d: PaperlessDocument) {
|
closeDocument(d: PaperlessDocument) {
|
||||||
|
@ -20,6 +20,7 @@ import { ToastService } from 'src/app/services/toast.service';
|
|||||||
import { TextComponent } from '../common/input/text/text.component';
|
import { TextComponent } from '../common/input/text/text.component';
|
||||||
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
|
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
|
||||||
import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions';
|
import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions';
|
||||||
|
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-document-detail',
|
selector: 'app-document-detail',
|
||||||
@ -219,7 +220,7 @@ export class DocumentDetailComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
moreLike() {
|
moreLike() {
|
||||||
this.router.navigate(["search"], {queryParams: {more_like:this.document.id}})
|
this.documentListViewService.quickFilter([{rule_type: FILTER_FULLTEXT_MORELIKE, value: this.documentId.toString()}])
|
||||||
}
|
}
|
||||||
|
|
||||||
hasNext() {
|
hasNext() {
|
||||||
|
@ -25,14 +25,14 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-text">
|
<p class="card-text">
|
||||||
<app-result-highlight *ngIf="getDetailsAsHighlight()" class="result-content" [highlights]="getDetailsAsHighlight()"></app-result-highlight>
|
<span *ngIf="document.__search_hit__" [innerHtml]="document.__search_hit__.highlights"></span>
|
||||||
<span *ngIf="getDetailsAsString()" class="result-content">{{getDetailsAsString()}}</span>
|
<span *ngIf="!document.__search_hit__" class="result-content">{{contentTrimmed}}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary" *ngIf="moreLikeThis">
|
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
|
<path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
|
||||||
</svg> <span class="d-block d-md-inline" i18n>More like this</span>
|
</svg> <span class="d-block d-md-inline" i18n>More like this</span>
|
||||||
@ -62,10 +62,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list-group list-group-horizontal border-0 card-info ml-md-auto mt-2 mt-md-0">
|
<div class="list-group list-group-horizontal border-0 card-info ml-md-auto mt-2 mt-md-0">
|
||||||
<div *ngIf="searchScore" class="list-group-item bg-light text-dark p-1 mr-5 border-0 d-flex search-score">
|
|
||||||
<small class="text-muted" i18n>Score:</small>
|
|
||||||
<ngb-progressbar [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
|
|
||||||
</div>
|
|
||||||
<button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 mr-2" title="Filter by document type"
|
<button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 mr-2" title="Filter by document type"
|
||||||
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
||||||
<svg class="metadata-icon mr-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor">
|
<svg class="metadata-icon mr-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor">
|
||||||
@ -86,6 +82,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<small>{{document.created | customDate:'mediumDate'}}</small>
|
<small>{{document.created | customDate:'mediumDate'}}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="document.__search_hit__" class="list-group-item bg-light text-dark border-0 d-flex search-score">
|
||||||
|
<small class="text-muted" i18n>Score:</small>
|
||||||
|
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -60,3 +60,8 @@
|
|||||||
padding-top: 0.35rem !important;
|
padding-top: 0.35rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span ::ng-deep .match {
|
||||||
|
color: black;
|
||||||
|
background-color: rgb(255, 211, 66);
|
||||||
|
}
|
@ -4,6 +4,8 @@ import { PaperlessDocument } from 'src/app/data/paperless-document';
|
|||||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||||
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
|
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
|
||||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||||
|
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-document-card-large',
|
selector: 'app-document-card-large',
|
||||||
@ -24,15 +26,9 @@ export class DocumentCardLargeComponent implements OnInit {
|
|||||||
return this.toggleSelected.observers.length > 0
|
return this.toggleSelected.observers.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@Input()
|
|
||||||
moreLikeThis: boolean = false
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
document: PaperlessDocument
|
document: PaperlessDocument
|
||||||
|
|
||||||
@Input()
|
|
||||||
details: any
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
clickTag = new EventEmitter<number>()
|
clickTag = new EventEmitter<number>()
|
||||||
|
|
||||||
@ -42,8 +38,8 @@ export class DocumentCardLargeComponent implements OnInit {
|
|||||||
@Output()
|
@Output()
|
||||||
clickDocumentType = new EventEmitter<number>()
|
clickDocumentType = new EventEmitter<number>()
|
||||||
|
|
||||||
@Input()
|
@Output()
|
||||||
searchScore: number
|
clickMoreLike= new EventEmitter()
|
||||||
|
|
||||||
@ViewChild('popover') popover: NgbPopover
|
@ViewChild('popover') popover: NgbPopover
|
||||||
|
|
||||||
@ -51,12 +47,14 @@ export class DocumentCardLargeComponent implements OnInit {
|
|||||||
popoverHidden = true
|
popoverHidden = true
|
||||||
|
|
||||||
get searchScoreClass() {
|
get searchScoreClass() {
|
||||||
if (this.searchScore > 0.7) {
|
if (this.document.__search_hit__) {
|
||||||
return "success"
|
if (this.document.__search_hit__.score > 0.7) {
|
||||||
} else if (this.searchScore > 0.3) {
|
return "success"
|
||||||
return "warning"
|
} else if (this.document.__search_hit__.score > 0.3) {
|
||||||
} else {
|
return "warning"
|
||||||
return "danger"
|
} else {
|
||||||
|
return "danger"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,19 +65,6 @@ export class DocumentCardLargeComponent implements OnInit {
|
|||||||
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
|
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
getDetailsAsString() {
|
|
||||||
if (typeof this.details === 'string') {
|
|
||||||
return this.details.substring(0, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getDetailsAsHighlight() {
|
|
||||||
//TODO: this is not an exact typecheck, can we do better
|
|
||||||
if (this.details instanceof Array) {
|
|
||||||
return this.details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getThumbUrl() {
|
getThumbUrl() {
|
||||||
return this.documentService.getThumbUrl(this.document.id)
|
return this.documentService.getThumbUrl(this.document.id)
|
||||||
}
|
}
|
||||||
@ -116,4 +101,8 @@ export class DocumentCardLargeComponent implements OnInit {
|
|||||||
mouseLeaveCard() {
|
mouseLeaveCard() {
|
||||||
this.popover.close()
|
this.popover.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get contentTrimmed() {
|
||||||
|
return this.document.content.substr(0, 500)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,7 @@
|
|||||||
</app-page-header>
|
</app-page-header>
|
||||||
|
|
||||||
<div class="w-100 mb-2 mb-sm-4">
|
<div class="w-100 mb-2 mb-sm-4">
|
||||||
<app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [rulesModified]="filterRulesModified" (filterRulesChange)="rulesChanged()" (reset)="resetFilters()" #filterEditor></app-filter-editor>
|
<app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" #filterEditor></app-filter-editor>
|
||||||
<app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor>
|
<app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -89,86 +89,95 @@
|
|||||||
[rotate]="true" aria-label="Default pagination"></ngb-pagination>
|
[rotate]="true" aria-label="Default pagination"></ngb-pagination>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="displayMode == 'largeCards'">
|
<ng-container *ngIf="list.error ; else documentListNoError">
|
||||||
<app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)">
|
<div class="alert alert-danger" role="alert">Error while loading documents: {{list.error}}</div>
|
||||||
</app-document-card-large>
|
</ng-container>
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'">
|
<ng-template #documentListNoError>
|
||||||
<thead>
|
|
||||||
<th></th>
|
|
||||||
<th class="d-none d-lg-table-cell"
|
|
||||||
sortable="archive_serial_number"
|
|
||||||
[currentSortField]="list.sortField"
|
|
||||||
[currentSortReverse]="list.sortReverse"
|
|
||||||
(sort)="onSort($event)"
|
|
||||||
i18n>ASN</th>
|
|
||||||
<th class="d-none d-md-table-cell"
|
|
||||||
sortable="correspondent__name"
|
|
||||||
[currentSortField]="list.sortField"
|
|
||||||
[currentSortReverse]="list.sortReverse"
|
|
||||||
(sort)="onSort($event)"
|
|
||||||
i18n>Correspondent</th>
|
|
||||||
<th
|
|
||||||
sortable="title"
|
|
||||||
[currentSortField]="list.sortField"
|
|
||||||
[currentSortReverse]="list.sortReverse"
|
|
||||||
(sort)="onSort($event)"
|
|
||||||
i18n>Title</th>
|
|
||||||
<th class="d-none d-xl-table-cell"
|
|
||||||
sortable="document_type__name"
|
|
||||||
[currentSortField]="list.sortField"
|
|
||||||
[currentSortReverse]="list.sortReverse"
|
|
||||||
(sort)="onSort($event)"
|
|
||||||
i18n>Document type</th>
|
|
||||||
<th
|
|
||||||
sortable="created"
|
|
||||||
[currentSortField]="list.sortField"
|
|
||||||
[currentSortReverse]="list.sortReverse"
|
|
||||||
(sort)="onSort($event)"
|
|
||||||
i18n>Created</th>
|
|
||||||
<th class="d-none d-xl-table-cell"
|
|
||||||
sortable="added"
|
|
||||||
[currentSortField]="list.sortField"
|
|
||||||
[currentSortReverse]="list.sortReverse"
|
|
||||||
(sort)="onSort($event)"
|
|
||||||
i18n>Added</th>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
|
|
||||||
<td>
|
|
||||||
<div class="custom-control custom-checkbox">
|
|
||||||
<input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event)">
|
|
||||||
<label class="custom-control-label" for="docCheck{{d.id}}"></label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="d-none d-lg-table-cell">
|
|
||||||
{{d.archive_serial_number}}
|
|
||||||
</td>
|
|
||||||
<td class="d-none d-md-table-cell">
|
|
||||||
<ng-container *ngIf="d.correspondent">
|
|
||||||
<a [routerLink]="" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
|
|
||||||
</ng-container>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
|
||||||
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
|
|
||||||
</td>
|
|
||||||
<td class="d-none d-xl-table-cell">
|
|
||||||
<ng-container *ngIf="d.document_type">
|
|
||||||
<a [routerLink]="" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
|
|
||||||
</ng-container>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{d.created | customDate}}
|
|
||||||
</td>
|
|
||||||
<td class="d-none d-xl-table-cell">
|
|
||||||
{{d.added | customDate}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'">
|
<div *ngIf="displayMode == 'largeCards'">
|
||||||
<app-document-card-small [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small>
|
<app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickMoreLike)="clickMoreLike(d.id)">
|
||||||
</div>
|
</app-document-card-large>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'">
|
||||||
|
<thead>
|
||||||
|
<th></th>
|
||||||
|
<th class="d-none d-lg-table-cell"
|
||||||
|
sortable="archive_serial_number"
|
||||||
|
[currentSortField]="list.sortField"
|
||||||
|
[currentSortReverse]="list.sortReverse"
|
||||||
|
(sort)="onSort($event)"
|
||||||
|
i18n>ASN</th>
|
||||||
|
<th class="d-none d-md-table-cell"
|
||||||
|
sortable="correspondent__name"
|
||||||
|
[currentSortField]="list.sortField"
|
||||||
|
[currentSortReverse]="list.sortReverse"
|
||||||
|
(sort)="onSort($event)"
|
||||||
|
i18n>Correspondent</th>
|
||||||
|
<th
|
||||||
|
sortable="title"
|
||||||
|
[currentSortField]="list.sortField"
|
||||||
|
[currentSortReverse]="list.sortReverse"
|
||||||
|
(sort)="onSort($event)"
|
||||||
|
i18n>Title</th>
|
||||||
|
<th class="d-none d-xl-table-cell"
|
||||||
|
sortable="document_type__name"
|
||||||
|
[currentSortField]="list.sortField"
|
||||||
|
[currentSortReverse]="list.sortReverse"
|
||||||
|
(sort)="onSort($event)"
|
||||||
|
i18n>Document type</th>
|
||||||
|
<th
|
||||||
|
sortable="created"
|
||||||
|
[currentSortField]="list.sortField"
|
||||||
|
[currentSortReverse]="list.sortReverse"
|
||||||
|
(sort)="onSort($event)"
|
||||||
|
i18n>Created</th>
|
||||||
|
<th class="d-none d-xl-table-cell"
|
||||||
|
sortable="added"
|
||||||
|
[currentSortField]="list.sortField"
|
||||||
|
[currentSortReverse]="list.sortReverse"
|
||||||
|
(sort)="onSort($event)"
|
||||||
|
i18n>Added</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
|
||||||
|
<td>
|
||||||
|
<div class="custom-control custom-checkbox">
|
||||||
|
<input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event)">
|
||||||
|
<label class="custom-control-label" for="docCheck{{d.id}}"></label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-lg-table-cell">
|
||||||
|
{{d.archive_serial_number}}
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell">
|
||||||
|
<ng-container *ngIf="d.correspondent">
|
||||||
|
<a [routerLink]="" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
|
||||||
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||||
|
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-xl-table-cell">
|
||||||
|
<ng-container *ngIf="d.document_type">
|
||||||
|
<a [routerLink]="" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
|
||||||
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{d.created | customDate}}
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-xl-table-cell">
|
||||||
|
{{d.added | customDate}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'">
|
||||||
|
<app-document-card-small [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</ng-template>
|
||||||
|
@ -2,6 +2,8 @@ import { Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
import { FilterRule } from 'src/app/data/filter-rule';
|
||||||
|
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type';
|
||||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
|
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
|
||||||
import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive';
|
import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive';
|
||||||
@ -37,7 +39,7 @@ export class DocumentListComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
displayMode = 'smallCards' // largeCards, smallCards, details
|
displayMode = 'smallCards' // largeCards, smallCards, details
|
||||||
|
|
||||||
filterRulesModified: boolean = false
|
unmodifiedFilterRules: FilterRule[] = []
|
||||||
|
|
||||||
private consumptionFinishedSubscription: Subscription
|
private consumptionFinishedSubscription: Subscription
|
||||||
|
|
||||||
@ -81,12 +83,12 @@ export class DocumentListComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.list.activateSavedView(view)
|
this.list.activateSavedView(view)
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
this.rulesChanged()
|
this.unmodifiedFilterRules = view.filter_rules
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.list.activateSavedView(null)
|
this.list.activateSavedView(null)
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
this.rulesChanged()
|
this.unmodifiedFilterRules = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -100,7 +102,6 @@ export class DocumentListComponent implements OnInit, OnDestroy {
|
|||||||
loadViewConfig(view: PaperlessSavedView) {
|
loadViewConfig(view: PaperlessSavedView) {
|
||||||
this.list.loadSavedView(view)
|
this.list.loadSavedView(view)
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
this.rulesChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveViewConfig() {
|
saveViewConfig() {
|
||||||
@ -113,6 +114,7 @@ export class DocumentListComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.savedViewService.patch(savedView).subscribe(result => {
|
this.savedViewService.patch(savedView).subscribe(result => {
|
||||||
this.toastService.showInfo($localize`View "${this.list.activeSavedViewTitle}" saved successfully.`)
|
this.toastService.showInfo($localize`View "${this.list.activeSavedViewTitle}" saved successfully.`)
|
||||||
|
this.unmodifiedFilterRules = this.list.filterRules
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,46 +143,6 @@ export class DocumentListComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFilters(): void {
|
|
||||||
this.filterRulesModified = false
|
|
||||||
if (this.list.activeSavedViewId) {
|
|
||||||
this.savedViewService.getCached(this.list.activeSavedViewId).subscribe(viewUntouched => {
|
|
||||||
this.list.filterRules = viewUntouched.filter_rules
|
|
||||||
this.list.reload()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.list.filterRules = []
|
|
||||||
this.list.reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rulesChanged() {
|
|
||||||
let modified = false
|
|
||||||
if (this.list.activeSavedViewId == null) {
|
|
||||||
modified = this.list.filterRules.length > 0 // documents list is modified if it has any filters
|
|
||||||
} else {
|
|
||||||
// compare savedView current filters vs original
|
|
||||||
this.savedViewService.getCached(this.list.activeSavedViewId).subscribe(view => {
|
|
||||||
let filterRulesInitial = view.filter_rules
|
|
||||||
|
|
||||||
if (this.list.filterRules.length !== filterRulesInitial.length) modified = true
|
|
||||||
else {
|
|
||||||
modified = this.list.filterRules.some(rule => {
|
|
||||||
return (filterRulesInitial.find(fri => fri.rule_type == rule.rule_type && fri.value == rule.value) == undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!modified) {
|
|
||||||
// only check other direction if we havent already determined is modified
|
|
||||||
modified = filterRulesInitial.some(rule => {
|
|
||||||
this.list.filterRules.find(fr => fr.rule_type == rule.rule_type && fr.value == rule.value) == undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
this.filterRulesModified = modified
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSelected(document: PaperlessDocument, event: MouseEvent): void {
|
toggleSelected(document: PaperlessDocument, event: MouseEvent): void {
|
||||||
if (!event.shiftKey) this.list.toggleSelected(document)
|
if (!event.shiftKey) this.list.toggleSelected(document)
|
||||||
else this.list.selectRangeTo(document)
|
else this.list.selectRangeTo(document)
|
||||||
@ -207,6 +169,10 @@ export class DocumentListComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clickMoreLike(documentID: number) {
|
||||||
|
this.list.quickFilter([{rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString()}])
|
||||||
|
}
|
||||||
|
|
||||||
trackByDocumentId(index, item: PaperlessDocument) {
|
trackByDocumentId(index, item: PaperlessDocument) {
|
||||||
return item.id
|
return item.id
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col mb-2 mb-xl-0">
|
<div class="col mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
<label class="text-muted mr-2 mb-0" i18n>Filter by:</label>
|
|
||||||
<div class="input-group input-group-sm flex-fill w-auto">
|
<div class="input-group input-group-sm flex-fill w-auto">
|
||||||
<div class="input-group-prepend" ngbDropdown>
|
<div class="input-group-prepend" ngbDropdown>
|
||||||
<button class="btn btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button>
|
<button class="btn btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button>
|
||||||
@ -9,7 +8,8 @@
|
|||||||
<button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button>
|
<button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input class="form-control form-control-sm" type="text" [(ngModel)]="textFilter">
|
<input class="form-control form-control-sm" type="text" [(ngModel)]="textFilter" *ngIf="textFilterTarget != 'fulltext-morelike'">
|
||||||
|
<span class="form-control form-control-sm text-truncate" *ngIf="textFilterTarget == 'fulltext-morelike'">{{_moreLikeDoc?.title}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,13 +8,17 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
|||||||
import { TagService } from 'src/app/services/rest/tag.service';
|
import { TagService } from 'src/app/services/rest/tag.service';
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
||||||
import { FilterRule } from 'src/app/data/filter-rule';
|
import { FilterRule } from 'src/app/data/filter-rule';
|
||||||
import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_ASN, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE, FILTER_TITLE_CONTENT } from 'src/app/data/filter-rule-type';
|
import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_ASN, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE, FILTER_TITLE_CONTENT } from 'src/app/data/filter-rule-type';
|
||||||
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component';
|
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component';
|
||||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
|
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||||
|
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
||||||
|
|
||||||
const TEXT_FILTER_TARGET_TITLE = "title"
|
const TEXT_FILTER_TARGET_TITLE = "title"
|
||||||
const TEXT_FILTER_TARGET_TITLE_CONTENT = "title-content"
|
const TEXT_FILTER_TARGET_TITLE_CONTENT = "title-content"
|
||||||
const TEXT_FILTER_TARGET_ASN = "asn"
|
const TEXT_FILTER_TARGET_ASN = "asn"
|
||||||
|
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = "fulltext-query"
|
||||||
|
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = "fulltext-morelike"
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-filter-editor',
|
selector: 'app-filter-editor',
|
||||||
@ -64,7 +68,8 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
constructor(
|
constructor(
|
||||||
private documentTypeService: DocumentTypeService,
|
private documentTypeService: DocumentTypeService,
|
||||||
private tagService: TagService,
|
private tagService: TagService,
|
||||||
private correspondentService: CorrespondentService
|
private correspondentService: CorrespondentService,
|
||||||
|
private documentService: DocumentService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
tags: PaperlessTag[] = []
|
tags: PaperlessTag[] = []
|
||||||
@ -72,12 +77,21 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
documentTypes: PaperlessDocumentType[] = []
|
documentTypes: PaperlessDocumentType[] = []
|
||||||
|
|
||||||
_textFilter = ""
|
_textFilter = ""
|
||||||
|
_moreLikeId: number
|
||||||
|
_moreLikeDoc: PaperlessDocument
|
||||||
|
|
||||||
textFilterTargets = [
|
get textFilterTargets() {
|
||||||
{id: TEXT_FILTER_TARGET_TITLE, name: $localize`Title`},
|
let targets = [
|
||||||
{id: TEXT_FILTER_TARGET_TITLE_CONTENT, name: $localize`Title & content`},
|
{id: TEXT_FILTER_TARGET_TITLE, name: $localize`Title`},
|
||||||
{id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN`}
|
{id: TEXT_FILTER_TARGET_TITLE_CONTENT, name: $localize`Title & content`},
|
||||||
]
|
{id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN`},
|
||||||
|
{id: TEXT_FILTER_TARGET_FULLTEXT_QUERY, name: $localize`Advanced search`}
|
||||||
|
]
|
||||||
|
if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
|
||||||
|
targets.push({id: TEXT_FILTER_TARGET_FULLTEXT_MORELIKE, name: $localize`More like`})
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
|
textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
|
||||||
|
|
||||||
@ -95,12 +109,28 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
dateAddedBefore: string
|
dateAddedBefore: string
|
||||||
dateAddedAfter: string
|
dateAddedAfter: string
|
||||||
|
|
||||||
|
_unmodifiedFilterRules: FilterRule[] = []
|
||||||
|
_filterRules: FilterRule[] = []
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set unmodifiedFilterRules(value: FilterRule[]) {
|
||||||
|
this._unmodifiedFilterRules = value
|
||||||
|
this.checkIfRulesHaveChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
get unmodifiedFilterRules(): FilterRule[] {
|
||||||
|
return this._unmodifiedFilterRules
|
||||||
|
}
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
set filterRules (value: FilterRule[]) {
|
set filterRules (value: FilterRule[]) {
|
||||||
|
this._filterRules = value
|
||||||
|
|
||||||
this.documentTypeSelectionModel.clear(false)
|
this.documentTypeSelectionModel.clear(false)
|
||||||
this.tagSelectionModel.clear(false)
|
this.tagSelectionModel.clear(false)
|
||||||
this.correspondentSelectionModel.clear(false)
|
this.correspondentSelectionModel.clear(false)
|
||||||
this._textFilter = null
|
this._textFilter = null
|
||||||
|
this._moreLikeId = null
|
||||||
this.dateAddedBefore = null
|
this.dateAddedBefore = null
|
||||||
this.dateAddedAfter = null
|
this.dateAddedAfter = null
|
||||||
this.dateCreatedBefore = null
|
this.dateCreatedBefore = null
|
||||||
@ -120,6 +150,17 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
this._textFilter = rule.value
|
this._textFilter = rule.value
|
||||||
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
|
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
|
||||||
break
|
break
|
||||||
|
case FILTER_FULLTEXT_QUERY:
|
||||||
|
this._textFilter = rule.value
|
||||||
|
this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||||
|
break
|
||||||
|
case FILTER_FULLTEXT_MORELIKE:
|
||||||
|
this._moreLikeId = +rule.value
|
||||||
|
this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
|
||||||
|
this.documentService.get(this._moreLikeId).subscribe(result => {
|
||||||
|
this._moreLikeDoc = result
|
||||||
|
})
|
||||||
|
break
|
||||||
case FILTER_CREATED_AFTER:
|
case FILTER_CREATED_AFTER:
|
||||||
this.dateCreatedAfter = rule.value
|
this.dateCreatedAfter = rule.value
|
||||||
break
|
break
|
||||||
@ -146,6 +187,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this.checkIfRulesHaveChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
get filterRules(): FilterRule[] {
|
get filterRules(): FilterRule[] {
|
||||||
@ -159,6 +201,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_ASN) {
|
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_ASN) {
|
||||||
filterRules.push({rule_type: FILTER_ASN, value: this._textFilter})
|
filterRules.push({rule_type: FILTER_ASN, value: this._textFilter})
|
||||||
}
|
}
|
||||||
|
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY) {
|
||||||
|
filterRules.push({rule_type: FILTER_FULLTEXT_QUERY, value: this._textFilter})
|
||||||
|
}
|
||||||
|
if (this._moreLikeId && this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
|
||||||
|
filterRules.push({rule_type: FILTER_FULLTEXT_MORELIKE, value: this._moreLikeId?.toString()})
|
||||||
|
}
|
||||||
if (this.tagSelectionModel.isNoneSelected()) {
|
if (this.tagSelectionModel.isNoneSelected()) {
|
||||||
filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"})
|
filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"})
|
||||||
} else {
|
} else {
|
||||||
@ -190,12 +238,27 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
@Output()
|
@Output()
|
||||||
filterRulesChange = new EventEmitter<FilterRule[]>()
|
filterRulesChange = new EventEmitter<FilterRule[]>()
|
||||||
|
|
||||||
@Output()
|
|
||||||
reset = new EventEmitter()
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
rulesModified: boolean = false
|
rulesModified: boolean = false
|
||||||
|
|
||||||
|
private checkIfRulesHaveChanged() {
|
||||||
|
let modified = false
|
||||||
|
if (this._unmodifiedFilterRules.length != this._filterRules.length) {
|
||||||
|
modified = true
|
||||||
|
} else {
|
||||||
|
modified = this._unmodifiedFilterRules.some(rule => {
|
||||||
|
return (this._filterRules.find(fri => fri.rule_type == rule.rule_type && fri.value == rule.value) == undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!modified) {
|
||||||
|
// only check other direction if we havent already determined is modified
|
||||||
|
modified = this._filterRules.some(rule => {
|
||||||
|
this._unmodifiedFilterRules.find(fr => fr.rule_type == rule.rule_type && fr.value == rule.value) == undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.rulesModified = modified
|
||||||
|
}
|
||||||
|
|
||||||
updateRules() {
|
updateRules() {
|
||||||
this.filterRulesChange.next(this.filterRules)
|
this.filterRulesChange.next(this.filterRules)
|
||||||
}
|
}
|
||||||
@ -232,7 +295,9 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetSelected() {
|
resetSelected() {
|
||||||
this.reset.next()
|
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
|
||||||
|
this.filterRules = this._unmodifiedFilterRules
|
||||||
|
this.updateRules()
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTag(tagId: number) {
|
toggleTag(tagId: number) {
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
... <span *ngFor="let fragment of highlights">
|
|
||||||
<span *ngFor="let token of fragment" [class.match]="token.highlight">{{token.text}}</span> ...
|
|
||||||
</span>
|
|
@ -1,4 +0,0 @@
|
|||||||
.match {
|
|
||||||
color: black;
|
|
||||||
background-color: rgb(255, 211, 66);
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { ResultHighlightComponent } from './result-highlight.component';
|
|
||||||
|
|
||||||
describe('ResultHighlightComponent', () => {
|
|
||||||
let component: ResultHighlightComponent;
|
|
||||||
let fixture: ComponentFixture<ResultHighlightComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ ResultHighlightComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(ResultHighlightComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,19 +0,0 @@
|
|||||||
import { Component, Input, OnInit } from '@angular/core';
|
|
||||||
import { SearchHitHighlight } from 'src/app/data/search-result';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-result-highlight',
|
|
||||||
templateUrl: './result-highlight.component.html',
|
|
||||||
styleUrls: ['./result-highlight.component.scss']
|
|
||||||
})
|
|
||||||
export class ResultHighlightComponent implements OnInit {
|
|
||||||
|
|
||||||
constructor() { }
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
highlights: SearchHitHighlight[][]
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
<app-page-header i18n-title title="Search results">
|
|
||||||
</app-page-header>
|
|
||||||
|
|
||||||
<div *ngIf="errorMessage" class="alert alert-danger" i18n>Invalid search query: {{errorMessage}}</div>
|
|
||||||
|
|
||||||
<p *ngIf="more_like" i18n>Showing documents similar to <a routerLink="/documents/{{more_like}}">{{more_like_doc?.original_file_name}}</a></p>
|
|
||||||
|
|
||||||
<p *ngIf="query">
|
|
||||||
<ng-container i18n>Search query: <i>{{query}}</i></ng-container>
|
|
||||||
<ng-container *ngIf="correctedQuery">
|
|
||||||
- <ng-container i18n>Did you mean "<a [routerLink]="" (click)="searchCorrectedQuery()">{{correctedQuery}}</a>"?</ng-container>
|
|
||||||
</ng-container>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div *ngIf="!errorMessage" [class.result-content-searching]="searching" infiniteScroll (scrolled)="onScroll()">
|
|
||||||
<p i18n>{resultCount, plural, =0 {No results} =1 {One result} other {{{resultCount}} results}}</p>
|
|
||||||
<ng-container *ngFor="let result of results">
|
|
||||||
<app-document-card-large *ngIf="result.document"
|
|
||||||
[document]="result.document"
|
|
||||||
[details]="result.highlights"
|
|
||||||
[searchScore]="result.score / maxScore"
|
|
||||||
[moreLikeThis]="true">
|
|
||||||
</app-document-card-large>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
</div>
|
|
@ -1,15 +0,0 @@
|
|||||||
.result-content {
|
|
||||||
color: darkgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-img {
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content-searching {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { SearchComponent } from './search.component';
|
|
||||||
|
|
||||||
describe('SearchComponent', () => {
|
|
||||||
let component: SearchComponent;
|
|
||||||
let fixture: ComponentFixture<SearchComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ SearchComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(SearchComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,95 +0,0 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
|
||||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
|
|
||||||
import { SearchHit } from 'src/app/data/search-result';
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
|
||||||
import { SearchService } from 'src/app/services/rest/search.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-search',
|
|
||||||
templateUrl: './search.component.html',
|
|
||||||
styleUrls: ['./search.component.scss']
|
|
||||||
})
|
|
||||||
export class SearchComponent implements OnInit {
|
|
||||||
|
|
||||||
results: SearchHit[] = []
|
|
||||||
|
|
||||||
query: string = ""
|
|
||||||
|
|
||||||
more_like: number
|
|
||||||
|
|
||||||
more_like_doc: PaperlessDocument
|
|
||||||
|
|
||||||
searching = false
|
|
||||||
|
|
||||||
currentPage = 1
|
|
||||||
|
|
||||||
pageCount = 1
|
|
||||||
|
|
||||||
resultCount
|
|
||||||
|
|
||||||
correctedQuery: string = null
|
|
||||||
|
|
||||||
errorMessage: string
|
|
||||||
|
|
||||||
get maxScore() {
|
|
||||||
return this.results?.length > 0 ? this.results[0].score : 100
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(private searchService: SearchService, private route: ActivatedRoute, private router: Router, private documentService: DocumentService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.route.queryParamMap.subscribe(paramMap => {
|
|
||||||
window.scrollTo(0, 0)
|
|
||||||
this.query = paramMap.get('query')
|
|
||||||
this.more_like = paramMap.has('more_like') ? +paramMap.get('more_like') : null
|
|
||||||
if (this.more_like) {
|
|
||||||
this.documentService.get(this.more_like).subscribe(r => {
|
|
||||||
this.more_like_doc = r
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.more_like_doc = null
|
|
||||||
}
|
|
||||||
this.searching = true
|
|
||||||
this.currentPage = 1
|
|
||||||
this.loadPage()
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
searchCorrectedQuery() {
|
|
||||||
this.router.navigate(["search"], {queryParams: {query: this.correctedQuery, more_like: this.more_like}})
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPage(append: boolean = false) {
|
|
||||||
this.errorMessage = null
|
|
||||||
this.correctedQuery = null
|
|
||||||
|
|
||||||
this.searchService.search(this.query, this.currentPage, this.more_like).subscribe(result => {
|
|
||||||
if (append) {
|
|
||||||
this.results.push(...result.results)
|
|
||||||
} else {
|
|
||||||
this.results = result.results
|
|
||||||
}
|
|
||||||
this.pageCount = result.page_count
|
|
||||||
this.searching = false
|
|
||||||
this.resultCount = result.count
|
|
||||||
this.correctedQuery = result.corrected_query
|
|
||||||
}, error => {
|
|
||||||
this.searching = false
|
|
||||||
this.resultCount = 1
|
|
||||||
this.pageCount = 1
|
|
||||||
this.results = []
|
|
||||||
this.errorMessage = error.error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onScroll() {
|
|
||||||
if (this.currentPage < this.pageCount) {
|
|
||||||
this.currentPage += 1
|
|
||||||
this.loadPage(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -22,6 +22,9 @@ export const FILTER_ASN_ISNULL = 18
|
|||||||
|
|
||||||
export const FILTER_TITLE_CONTENT = 19
|
export const FILTER_TITLE_CONTENT = 19
|
||||||
|
|
||||||
|
export const FILTER_FULLTEXT_QUERY = 20
|
||||||
|
export const FILTER_FULLTEXT_MORELIKE = 21
|
||||||
|
|
||||||
export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||||
|
|
||||||
{id: FILTER_TITLE, filtervar: "title__icontains", datatype: "string", multi: false, default: ""},
|
{id: FILTER_TITLE, filtervar: "title__icontains", datatype: "string", multi: false, default: ""},
|
||||||
@ -51,7 +54,11 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
|||||||
{id: FILTER_MODIFIED_AFTER, filtervar: "modified__date__gt", datatype: "date", multi: false},
|
{id: FILTER_MODIFIED_AFTER, filtervar: "modified__date__gt", datatype: "date", multi: false},
|
||||||
{id: FILTER_ASN_ISNULL, filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false},
|
{id: FILTER_ASN_ISNULL, filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false},
|
||||||
|
|
||||||
{id: FILTER_TITLE_CONTENT, filtervar: "title_content", datatype: "string", multi: false}
|
{id: FILTER_TITLE_CONTENT, filtervar: "title_content", datatype: "string", multi: false},
|
||||||
|
|
||||||
|
{id: FILTER_FULLTEXT_QUERY, filtervar: "query", datatype: "string", multi: false},
|
||||||
|
|
||||||
|
{id: FILTER_FULLTEXT_MORELIKE, filtervar: "more_like_id", datatype: "number", multi: false},
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface FilterRuleType {
|
export interface FilterRuleType {
|
||||||
|
@ -4,6 +4,15 @@ import { PaperlessTag } from './paperless-tag'
|
|||||||
import { PaperlessDocumentType } from './paperless-document-type'
|
import { PaperlessDocumentType } from './paperless-document-type'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
|
||||||
|
score?: number
|
||||||
|
rank?: number
|
||||||
|
|
||||||
|
highlights?: string
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaperlessDocument extends ObjectWithId {
|
export interface PaperlessDocument extends ObjectWithId {
|
||||||
|
|
||||||
correspondent$?: Observable<PaperlessCorrespondent>
|
correspondent$?: Observable<PaperlessCorrespondent>
|
||||||
@ -40,4 +49,6 @@ export interface PaperlessDocument extends ObjectWithId {
|
|||||||
|
|
||||||
archive_serial_number?: number
|
archive_serial_number?: number
|
||||||
|
|
||||||
|
__search_hit__?: SearchHit
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
import { PaperlessDocument } from './paperless-document'
|
|
||||||
|
|
||||||
export class SearchHitHighlight {
|
|
||||||
text?: string
|
|
||||||
term?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchHit {
|
|
||||||
id?: number
|
|
||||||
title?: string
|
|
||||||
score?: number
|
|
||||||
rank?: number
|
|
||||||
|
|
||||||
highlights?: SearchHitHighlight[][]
|
|
||||||
document?: PaperlessDocument
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
|
|
||||||
count?: number
|
|
||||||
page?: number
|
|
||||||
page_count?: number
|
|
||||||
|
|
||||||
corrected_query?: string
|
|
||||||
|
|
||||||
results?: SearchHit[]
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { cloneFilterRules, FilterRule } from '../data/filter-rule';
|
import { cloneFilterRules, FilterRule } from '../data/filter-rule';
|
||||||
|
import { FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY } from '../data/filter-rule-type';
|
||||||
import { PaperlessDocument } from '../data/paperless-document';
|
import { PaperlessDocument } from '../data/paperless-document';
|
||||||
import { PaperlessSavedView } from '../data/paperless-saved-view';
|
import { PaperlessSavedView } from '../data/paperless-saved-view';
|
||||||
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys';
|
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys';
|
||||||
@ -38,6 +39,7 @@ interface ListViewState {
|
|||||||
export class DocumentListViewService {
|
export class DocumentListViewService {
|
||||||
|
|
||||||
isReloading: boolean = false
|
isReloading: boolean = false
|
||||||
|
error: string = null
|
||||||
|
|
||||||
rangeSelectionAnchorIndex: number
|
rangeSelectionAnchorIndex: number
|
||||||
lastRangeSelectionToIndex: number
|
lastRangeSelectionToIndex: number
|
||||||
@ -101,6 +103,7 @@ export class DocumentListViewService {
|
|||||||
|
|
||||||
reload(onFinish?) {
|
reload(onFinish?) {
|
||||||
this.isReloading = true
|
this.isReloading = true
|
||||||
|
this.error = null
|
||||||
let activeListViewState = this.activeListViewState
|
let activeListViewState = this.activeListViewState
|
||||||
|
|
||||||
this.documentService.listFiltered(
|
this.documentService.listFiltered(
|
||||||
@ -124,12 +127,17 @@ export class DocumentListViewService {
|
|||||||
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
|
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
|
||||||
activeListViewState.currentPage = 1
|
activeListViewState.currentPage = 1
|
||||||
this.reload()
|
this.reload()
|
||||||
|
} else {
|
||||||
|
this.error = error.error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
set filterRules(filterRules: FilterRule[]) {
|
set filterRules(filterRules: FilterRule[]) {
|
||||||
this.activeListViewState.filterRules = filterRules
|
this.activeListViewState.filterRules = filterRules
|
||||||
|
if (filterRules.find(r => (r.rule_type == FILTER_FULLTEXT_QUERY || r.rule_type == FILTER_FULLTEXT_MORELIKE))) {
|
||||||
|
this.activeListViewState.currentPage = 1
|
||||||
|
}
|
||||||
this.reload()
|
this.reload()
|
||||||
this.reduceSelectionToFilter()
|
this.reduceSelectionToFilter()
|
||||||
this.saveDocumentListView()
|
this.saveDocumentListView()
|
||||||
@ -207,7 +215,11 @@ export class DocumentListViewService {
|
|||||||
this.activeListViewState.currentPage = 1
|
this.activeListViewState.currentPage = 1
|
||||||
this.reduceSelectionToFilter()
|
this.reduceSelectionToFilter()
|
||||||
this.saveDocumentListView()
|
this.saveDocumentListView()
|
||||||
this.router.navigate(["documents"])
|
if (this.router.url == "/documents") {
|
||||||
|
this.reload()
|
||||||
|
} else {
|
||||||
|
this.router.navigate(["documents"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastPage(): number {
|
getLastPage(): number {
|
||||||
@ -317,7 +329,7 @@ export class DocumentListViewService {
|
|||||||
return this.documents.map(d => d.id).indexOf(documentID)
|
return this.documents.map(d => d.id).indexOf(documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private documentService: DocumentService, private settings: SettingsService, private router: Router) {
|
constructor(private documentService: DocumentService, private settings: SettingsService, private router: Router, private route: ActivatedRoute) {
|
||||||
let documentListViewConfigJson = localStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
let documentListViewConfigJson = localStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
||||||
if (documentListViewConfigJson) {
|
if (documentListViewConfigJson) {
|
||||||
try {
|
try {
|
||||||
|
@ -2,8 +2,6 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
|
||||||
import { SearchResult } from 'src/app/data/search-result';
|
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { DocumentService } from './document.service';
|
import { DocumentService } from './document.service';
|
||||||
|
|
||||||
@ -13,30 +11,7 @@ import { DocumentService } from './document.service';
|
|||||||
})
|
})
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
|
|
||||||
constructor(private http: HttpClient, private documentService: DocumentService) { }
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
search(query: string, page?: number, more_like?: number): Observable<SearchResult> {
|
|
||||||
let httpParams = new HttpParams()
|
|
||||||
if (query) {
|
|
||||||
httpParams = httpParams.set('query', query)
|
|
||||||
}
|
|
||||||
if (page) {
|
|
||||||
httpParams = httpParams.set('page', page.toString())
|
|
||||||
}
|
|
||||||
if (more_like) {
|
|
||||||
httpParams = httpParams.set('more_like', more_like.toString())
|
|
||||||
}
|
|
||||||
return this.http.get<SearchResult>(`${environment.apiBaseUrl}search/`, {params: httpParams}).pipe(
|
|
||||||
map(result => {
|
|
||||||
result.results.forEach(hit => {
|
|
||||||
if (hit.document) {
|
|
||||||
this.documentService.addObservablesToDocument(hit.document)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
autocomplete(term: string): Observable<string[]> {
|
autocomplete(term: string): Observable<string[]> {
|
||||||
return this.http.get<string[]>(`${environment.apiBaseUrl}search/autocomplete/`, {params: new HttpParams().set('term', term)})
|
return this.http.get<string[]>(`${environment.apiBaseUrl}search/autocomplete/`, {params: new HttpParams().set('term', term)})
|
||||||
|
@ -2,75 +2,70 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dateutil.parser import isoparse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from whoosh import highlight, classify, query
|
from whoosh import highlight, classify, query
|
||||||
from whoosh.fields import Schema, TEXT, NUMERIC, KEYWORD, DATETIME
|
from whoosh.fields import Schema, TEXT, NUMERIC, KEYWORD, DATETIME, BOOLEAN
|
||||||
from whoosh.highlight import Formatter, get_text
|
from whoosh.highlight import Formatter, get_text, HtmlFormatter
|
||||||
from whoosh.index import create_in, exists_in, open_dir
|
from whoosh.index import create_in, exists_in, open_dir
|
||||||
from whoosh.qparser import MultifieldParser
|
from whoosh.qparser import MultifieldParser
|
||||||
from whoosh.qparser.dateparse import DateParserPlugin
|
from whoosh.qparser.dateparse import DateParserPlugin
|
||||||
|
from whoosh.searching import ResultsPage, Searcher
|
||||||
from whoosh.writing import AsyncWriter
|
from whoosh.writing import AsyncWriter
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.index")
|
logger = logging.getLogger("paperless.index")
|
||||||
|
|
||||||
|
|
||||||
class JsonFormatter(Formatter):
|
|
||||||
def __init__(self):
|
|
||||||
self.seen = {}
|
|
||||||
|
|
||||||
def format_token(self, text, token, replace=False):
|
|
||||||
ttext = self._text(get_text(text, token, replace))
|
|
||||||
return {'text': ttext, 'highlight': 'true'}
|
|
||||||
|
|
||||||
def format_fragment(self, fragment, replace=False):
|
|
||||||
output = []
|
|
||||||
index = fragment.startchar
|
|
||||||
text = fragment.text
|
|
||||||
amend_token = None
|
|
||||||
for t in fragment.matches:
|
|
||||||
if t.startchar is None:
|
|
||||||
continue
|
|
||||||
if t.startchar < index:
|
|
||||||
continue
|
|
||||||
if t.startchar > index:
|
|
||||||
text_inbetween = text[index:t.startchar]
|
|
||||||
if amend_token and t.startchar - index < 10:
|
|
||||||
amend_token['text'] += text_inbetween
|
|
||||||
else:
|
|
||||||
output.append({'text': text_inbetween,
|
|
||||||
'highlight': False})
|
|
||||||
amend_token = None
|
|
||||||
token = self.format_token(text, t, replace)
|
|
||||||
if amend_token:
|
|
||||||
amend_token['text'] += token['text']
|
|
||||||
else:
|
|
||||||
output.append(token)
|
|
||||||
amend_token = token
|
|
||||||
index = t.endchar
|
|
||||||
if index < fragment.endchar:
|
|
||||||
output.append({'text': text[index:fragment.endchar],
|
|
||||||
'highlight': False})
|
|
||||||
return output
|
|
||||||
|
|
||||||
def format(self, fragments, replace=False):
|
|
||||||
output = []
|
|
||||||
for fragment in fragments:
|
|
||||||
output.append(self.format_fragment(fragment, replace=replace))
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def get_schema():
|
def get_schema():
|
||||||
return Schema(
|
return Schema(
|
||||||
id=NUMERIC(stored=True, unique=True, numtype=int),
|
id=NUMERIC(
|
||||||
title=TEXT(stored=True),
|
stored=True,
|
||||||
|
unique=True
|
||||||
|
),
|
||||||
|
title=TEXT(
|
||||||
|
sortable=True
|
||||||
|
),
|
||||||
content=TEXT(),
|
content=TEXT(),
|
||||||
correspondent=TEXT(stored=True),
|
archive_serial_number=NUMERIC(
|
||||||
tag=KEYWORD(stored=True, commas=True, scorable=True, lowercase=True),
|
sortable=True
|
||||||
type=TEXT(stored=True),
|
),
|
||||||
created=DATETIME(stored=True, sortable=True),
|
|
||||||
modified=DATETIME(stored=True, sortable=True),
|
correspondent=TEXT(
|
||||||
added=DATETIME(stored=True, sortable=True),
|
sortable=True
|
||||||
|
),
|
||||||
|
correspondent_id=NUMERIC(),
|
||||||
|
has_correspondent=BOOLEAN(),
|
||||||
|
|
||||||
|
tag=KEYWORD(
|
||||||
|
commas=True,
|
||||||
|
scorable=True,
|
||||||
|
lowercase=True
|
||||||
|
),
|
||||||
|
tag_id=KEYWORD(
|
||||||
|
commas=True,
|
||||||
|
scorable=True
|
||||||
|
),
|
||||||
|
has_tag=BOOLEAN(),
|
||||||
|
|
||||||
|
type=TEXT(
|
||||||
|
sortable=True
|
||||||
|
),
|
||||||
|
type_id=NUMERIC(),
|
||||||
|
has_type=BOOLEAN(),
|
||||||
|
|
||||||
|
created=DATETIME(
|
||||||
|
sortable=True
|
||||||
|
),
|
||||||
|
modified=DATETIME(
|
||||||
|
sortable=True
|
||||||
|
),
|
||||||
|
added=DATETIME(
|
||||||
|
sortable=True
|
||||||
|
),
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -87,11 +82,8 @@ def open_index(recreate=False):
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def open_index_writer(ix=None, optimize=False):
|
def open_index_writer(optimize=False):
|
||||||
if ix:
|
writer = AsyncWriter(open_index())
|
||||||
writer = AsyncWriter(ix)
|
|
||||||
else:
|
|
||||||
writer = AsyncWriter(open_index())
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield writer
|
yield writer
|
||||||
@ -102,17 +94,35 @@ def open_index_writer(ix=None, optimize=False):
|
|||||||
writer.commit(optimize=optimize)
|
writer.commit(optimize=optimize)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def open_index_searcher():
|
||||||
|
searcher = open_index().searcher()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield searcher
|
||||||
|
finally:
|
||||||
|
searcher.close()
|
||||||
|
|
||||||
|
|
||||||
def update_document(writer, doc):
|
def update_document(writer, doc):
|
||||||
tags = ",".join([t.name for t in doc.tags.all()])
|
tags = ",".join([t.name for t in doc.tags.all()])
|
||||||
|
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
|
||||||
writer.update_document(
|
writer.update_document(
|
||||||
id=doc.pk,
|
id=doc.pk,
|
||||||
title=doc.title,
|
title=doc.title,
|
||||||
content=doc.content,
|
content=doc.content,
|
||||||
correspondent=doc.correspondent.name if doc.correspondent else None,
|
correspondent=doc.correspondent.name if doc.correspondent else None,
|
||||||
|
correspondent_id=doc.correspondent.id if doc.correspondent else None,
|
||||||
|
has_correspondent=doc.correspondent is not None,
|
||||||
tag=tags if tags else None,
|
tag=tags if tags else None,
|
||||||
|
tag_id=tags_ids if tags_ids else None,
|
||||||
|
has_tag=len(tags) > 0,
|
||||||
type=doc.document_type.name if doc.document_type else None,
|
type=doc.document_type.name if doc.document_type else None,
|
||||||
|
type_id=doc.document_type.id if doc.document_type else None,
|
||||||
|
has_type=doc.document_type is not None,
|
||||||
created=doc.created,
|
created=doc.created,
|
||||||
added=doc.added,
|
added=doc.added,
|
||||||
|
archive_serial_number=doc.archive_serial_number,
|
||||||
modified=doc.modified,
|
modified=doc.modified,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -135,50 +145,137 @@ def remove_document_from_index(document):
|
|||||||
remove_document(writer, document)
|
remove_document(writer, document)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
class DelayedQuery:
|
||||||
def query_page(ix, page, querystring, more_like_doc_id, more_like_doc_content):
|
|
||||||
searcher = ix.searcher()
|
|
||||||
try:
|
|
||||||
if querystring:
|
|
||||||
qp = MultifieldParser(
|
|
||||||
["content", "title", "correspondent", "tag", "type"],
|
|
||||||
ix.schema)
|
|
||||||
qp.add_plugin(DateParserPlugin())
|
|
||||||
str_q = qp.parse(querystring)
|
|
||||||
corrected = searcher.correct_query(str_q, querystring)
|
|
||||||
else:
|
|
||||||
str_q = None
|
|
||||||
corrected = None
|
|
||||||
|
|
||||||
if more_like_doc_id:
|
@property
|
||||||
docnum = searcher.document_number(id=more_like_doc_id)
|
def _query(self):
|
||||||
kts = searcher.key_terms_from_text(
|
raise NotImplementedError()
|
||||||
'content', more_like_doc_content, numterms=20,
|
|
||||||
model=classify.Bo1Model, normalize=False)
|
|
||||||
more_like_q = query.Or(
|
|
||||||
[query.Term('content', word, boost=weight)
|
|
||||||
for word, weight in kts])
|
|
||||||
result_page = searcher.search_page(
|
|
||||||
more_like_q, page, filter=str_q, mask={docnum})
|
|
||||||
elif str_q:
|
|
||||||
result_page = searcher.search_page(str_q, page)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
"Either querystring or more_like_doc_id is required."
|
|
||||||
)
|
|
||||||
|
|
||||||
result_page.results.fragmenter = highlight.ContextFragmenter(
|
@property
|
||||||
|
def _query_filter(self):
|
||||||
|
criterias = []
|
||||||
|
for k, v in self.query_params.items():
|
||||||
|
if k == 'correspondent__id':
|
||||||
|
criterias.append(query.Term('correspondent_id', v))
|
||||||
|
elif k == 'tags__id__all':
|
||||||
|
for tag_id in v.split(","):
|
||||||
|
criterias.append(query.Term('tag_id', tag_id))
|
||||||
|
elif k == 'document_type__id':
|
||||||
|
criterias.append(query.Term('type_id', v))
|
||||||
|
elif k == 'correspondent__isnull':
|
||||||
|
criterias.append(query.Term("has_correspondent", v == "false"))
|
||||||
|
elif k == 'is_tagged':
|
||||||
|
criterias.append(query.Term("has_tag", v == "true"))
|
||||||
|
elif k == 'document_type__isnull':
|
||||||
|
criterias.append(query.Term("has_type", v == "false"))
|
||||||
|
elif k == 'created__date__lt':
|
||||||
|
criterias.append(
|
||||||
|
query.DateRange("created", start=None, end=isoparse(v)))
|
||||||
|
elif k == 'created__date__gt':
|
||||||
|
criterias.append(
|
||||||
|
query.DateRange("created", start=isoparse(v), end=None))
|
||||||
|
elif k == 'added__date__gt':
|
||||||
|
criterias.append(
|
||||||
|
query.DateRange("added", start=isoparse(v), end=None))
|
||||||
|
elif k == 'added__date__lt':
|
||||||
|
criterias.append(
|
||||||
|
query.DateRange("added", start=None, end=isoparse(v)))
|
||||||
|
if len(criterias) > 0:
|
||||||
|
return query.And(criterias)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _query_sortedby(self):
|
||||||
|
# if not 'ordering' in self.query_params:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
# o: str = self.query_params['ordering']
|
||||||
|
# if o.startswith('-'):
|
||||||
|
# return o[1:], True
|
||||||
|
# else:
|
||||||
|
# return o, False
|
||||||
|
|
||||||
|
def __init__(self, searcher: Searcher, query_params, page_size):
|
||||||
|
self.searcher = searcher
|
||||||
|
self.query_params = query_params
|
||||||
|
self.page_size = page_size
|
||||||
|
self.saved_results = dict()
|
||||||
|
self.first_score = None
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
page = self[0:1]
|
||||||
|
return len(page)
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
if item.start in self.saved_results:
|
||||||
|
return self.saved_results[item.start]
|
||||||
|
|
||||||
|
q, mask = self._query
|
||||||
|
sortedby, reverse = self._query_sortedby
|
||||||
|
|
||||||
|
page: ResultsPage = self.searcher.search_page(
|
||||||
|
q,
|
||||||
|
mask=mask,
|
||||||
|
filter=self._query_filter,
|
||||||
|
pagenum=math.floor(item.start / self.page_size) + 1,
|
||||||
|
pagelen=self.page_size,
|
||||||
|
sortedby=sortedby,
|
||||||
|
reverse=reverse
|
||||||
|
)
|
||||||
|
page.results.fragmenter = highlight.ContextFragmenter(
|
||||||
surround=50)
|
surround=50)
|
||||||
result_page.results.formatter = JsonFormatter()
|
page.results.formatter = HtmlFormatter(tagname="span", between=" ... ")
|
||||||
|
|
||||||
if corrected and corrected.query != str_q:
|
if not self.first_score and len(page.results) > 0:
|
||||||
|
self.first_score = page.results[0].score
|
||||||
|
|
||||||
|
if self.first_score:
|
||||||
|
page.results.top_n = list(map(
|
||||||
|
lambda hit: (hit[0] / self.first_score, hit[1]),
|
||||||
|
page.results.top_n
|
||||||
|
))
|
||||||
|
|
||||||
|
self.saved_results[item.start] = page
|
||||||
|
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
class DelayedFullTextQuery(DelayedQuery):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _query(self):
|
||||||
|
q_str = self.query_params['query']
|
||||||
|
qp = MultifieldParser(
|
||||||
|
["content", "title", "correspondent", "tag", "type"],
|
||||||
|
self.searcher.ixreader.schema)
|
||||||
|
qp.add_plugin(DateParserPlugin())
|
||||||
|
q = qp.parse(q_str)
|
||||||
|
|
||||||
|
corrected = self.searcher.correct_query(q, q_str)
|
||||||
|
if corrected.query != q:
|
||||||
corrected_query = corrected.string
|
corrected_query = corrected.string
|
||||||
else:
|
|
||||||
corrected_query = None
|
|
||||||
|
|
||||||
yield result_page, corrected_query
|
return q, None
|
||||||
finally:
|
|
||||||
searcher.close()
|
|
||||||
|
class DelayedMoreLikeThisQuery(DelayedQuery):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _query(self):
|
||||||
|
more_like_doc_id = int(self.query_params['more_like_id'])
|
||||||
|
content = Document.objects.get(id=more_like_doc_id).content
|
||||||
|
|
||||||
|
docnum = self.searcher.document_number(id=more_like_doc_id)
|
||||||
|
kts = self.searcher.key_terms_from_text(
|
||||||
|
'content', content, numterms=20,
|
||||||
|
model=classify.Bo1Model, normalize=False)
|
||||||
|
q = query.Or(
|
||||||
|
[query.Term('content', word, boost=weight)
|
||||||
|
for word, weight in kts])
|
||||||
|
mask = {docnum}
|
||||||
|
|
||||||
|
return q, mask
|
||||||
|
|
||||||
|
|
||||||
def autocomplete(ix, term, limit=10):
|
def autocomplete(ix, term, limit=10):
|
||||||
|
23
src/documents/migrations/1016_auto_20210317_1351.py
Normal file
23
src/documents/migrations/1016_auto_20210317_1351.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.1.7 on 2021-03-17 12:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('documents', '1015_remove_null_characters'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='savedview',
|
||||||
|
name='sort_field',
|
||||||
|
field=models.CharField(blank=True, max_length=128, null=True, verbose_name='sort field'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='savedviewfilterrule',
|
||||||
|
name='rule_type',
|
||||||
|
field=models.PositiveIntegerField(choices=[(0, 'title contains'), (1, 'content contains'), (2, 'ASN is'), (3, 'correspondent is'), (4, 'document type is'), (5, 'is in inbox'), (6, 'has tag'), (7, 'has any tag'), (8, 'created before'), (9, 'created after'), (10, 'created year is'), (11, 'created month is'), (12, 'created day is'), (13, 'added before'), (14, 'added after'), (15, 'modified before'), (16, 'modified after'), (17, 'does not have tag'), (18, 'does not have ASN'), (19, 'title or content contains'), (20, 'fulltext query'), (21, 'more like this')], verbose_name='rule type'),
|
||||||
|
),
|
||||||
|
]
|
@ -359,7 +359,10 @@ class SavedView(models.Model):
|
|||||||
|
|
||||||
sort_field = models.CharField(
|
sort_field = models.CharField(
|
||||||
_("sort field"),
|
_("sort field"),
|
||||||
max_length=128)
|
max_length=128,
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
sort_reverse = models.BooleanField(
|
sort_reverse = models.BooleanField(
|
||||||
_("sort reverse"),
|
_("sort reverse"),
|
||||||
default=False)
|
default=False)
|
||||||
@ -387,6 +390,8 @@ class SavedViewFilterRule(models.Model):
|
|||||||
(17, _("does not have tag")),
|
(17, _("does not have tag")),
|
||||||
(18, _("does not have ASN")),
|
(18, _("does not have ASN")),
|
||||||
(19, _("title or content contains")),
|
(19, _("title or content contains")),
|
||||||
|
(20, _("fulltext query")),
|
||||||
|
(21, _("more like this"))
|
||||||
]
|
]
|
||||||
|
|
||||||
saved_view = models.ForeignKey(
|
saved_view = models.ForeignKey(
|
||||||
|
@ -27,7 +27,7 @@ class TestDocumentAdmin(DirectoriesMixin, TestCase):
|
|||||||
doc.title = "new title"
|
doc.title = "new title"
|
||||||
self.doc_admin.save_model(None, doc, None, None)
|
self.doc_admin.save_model(None, doc, None, None)
|
||||||
self.assertEqual(Document.objects.get(id=doc.id).title, "new title")
|
self.assertEqual(Document.objects.get(id=doc.id).title, "new title")
|
||||||
self.assertEqual(self.get_document_from_index(doc)['title'], "new title")
|
self.assertEqual(self.get_document_from_index(doc)['id'], doc.id)
|
||||||
|
|
||||||
def test_delete_model(self):
|
def test_delete_model(self):
|
||||||
doc = Document.objects.create(title="test")
|
doc = Document.objects.create(title="test")
|
||||||
|
@ -7,6 +7,7 @@ import tempfile
|
|||||||
import zipfile
|
import zipfile
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
@ -294,12 +295,6 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
|||||||
results = response.data['results']
|
results = response.data['results']
|
||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(len(results), 0)
|
||||||
|
|
||||||
def test_search_no_query(self):
|
|
||||||
response = self.client.get("/api/search/")
|
|
||||||
results = response.data['results']
|
|
||||||
|
|
||||||
self.assertEqual(len(results), 0)
|
|
||||||
|
|
||||||
def test_search(self):
|
def test_search(self):
|
||||||
d1=Document.objects.create(title="invoice", content="the thing i bought at a shop and paid with bank account", checksum="A", pk=1)
|
d1=Document.objects.create(title="invoice", content="the thing i bought at a shop and paid with bank account", checksum="A", pk=1)
|
||||||
d2=Document.objects.create(title="bank statement 1", content="things i paid for in august", pk=2, checksum="B")
|
d2=Document.objects.create(title="bank statement 1", content="things i paid for in august", pk=2, checksum="B")
|
||||||
@ -311,32 +306,24 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
|||||||
index.update_document(writer, d1)
|
index.update_document(writer, d1)
|
||||||
index.update_document(writer, d2)
|
index.update_document(writer, d2)
|
||||||
index.update_document(writer, d3)
|
index.update_document(writer, d3)
|
||||||
response = self.client.get("/api/search/?query=bank")
|
response = self.client.get("/api/documents/?query=bank")
|
||||||
results = response.data['results']
|
results = response.data['results']
|
||||||
self.assertEqual(response.data['count'], 3)
|
self.assertEqual(response.data['count'], 3)
|
||||||
self.assertEqual(response.data['page'], 1)
|
|
||||||
self.assertEqual(response.data['page_count'], 1)
|
|
||||||
self.assertEqual(len(results), 3)
|
self.assertEqual(len(results), 3)
|
||||||
|
|
||||||
response = self.client.get("/api/search/?query=september")
|
response = self.client.get("/api/documents/?query=september")
|
||||||
results = response.data['results']
|
results = response.data['results']
|
||||||
self.assertEqual(response.data['count'], 1)
|
self.assertEqual(response.data['count'], 1)
|
||||||
self.assertEqual(response.data['page'], 1)
|
|
||||||
self.assertEqual(response.data['page_count'], 1)
|
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
|
|
||||||
response = self.client.get("/api/search/?query=statement")
|
response = self.client.get("/api/documents/?query=statement")
|
||||||
results = response.data['results']
|
results = response.data['results']
|
||||||
self.assertEqual(response.data['count'], 2)
|
self.assertEqual(response.data['count'], 2)
|
||||||
self.assertEqual(response.data['page'], 1)
|
|
||||||
self.assertEqual(response.data['page_count'], 1)
|
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
response = self.client.get("/api/search/?query=sfegdfg")
|
response = self.client.get("/api/documents/?query=sfegdfg")
|
||||||
results = response.data['results']
|
results = response.data['results']
|
||||||
self.assertEqual(response.data['count'], 0)
|
self.assertEqual(response.data['count'], 0)
|
||||||
self.assertEqual(response.data['page'], 0)
|
|
||||||
self.assertEqual(response.data['page_count'], 0)
|
|
||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(len(results), 0)
|
||||||
|
|
||||||
def test_search_multi_page(self):
|
def test_search_multi_page(self):
|
||||||
@ -349,53 +336,34 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
|||||||
seen_ids = []
|
seen_ids = []
|
||||||
|
|
||||||
for i in range(1, 6):
|
for i in range(1, 6):
|
||||||
response = self.client.get(f"/api/search/?query=content&page={i}")
|
response = self.client.get(f"/api/documents/?query=content&page={i}&page_size=10")
|
||||||
results = response.data['results']
|
results = response.data['results']
|
||||||
self.assertEqual(response.data['count'], 55)
|
self.assertEqual(response.data['count'], 55)
|
||||||
self.assertEqual(response.data['page'], i)
|
|
||||||
self.assertEqual(response.data['page_count'], 6)
|
|
||||||
self.assertEqual(len(results), 10)
|
self.assertEqual(len(results), 10)
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
self.assertNotIn(result['id'], seen_ids)
|
self.assertNotIn(result['id'], seen_ids)
|
||||||
seen_ids.append(result['id'])
|
seen_ids.append(result['id'])
|
||||||
|
|
||||||
response = self.client.get(f"/api/search/?query=content&page=6")
|
response = self.client.get(f"/api/documents/?query=content&page=6&page_size=10")
|
||||||
results = response.data['results']
|
results = response.data['results']
|
||||||
self.assertEqual(response.data['count'], 55)
|
self.assertEqual(response.data['count'], 55)
|
||||||
self.assertEqual(response.data['page'], 6)
|
|
||||||
self.assertEqual(response.data['page_count'], 6)
|
|
||||||
self.assertEqual(len(results), 5)
|
self.assertEqual(len(results), 5)
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
self.assertNotIn(result['id'], seen_ids)
|
self.assertNotIn(result['id'], seen_ids)
|
||||||
seen_ids.append(result['id'])
|
seen_ids.append(result['id'])
|
||||||
|
|
||||||
response = self.client.get(f"/api/search/?query=content&page=7")
|
|
||||||
results = response.data['results']
|
|
||||||
self.assertEqual(response.data['count'], 55)
|
|
||||||
self.assertEqual(response.data['page'], 6)
|
|
||||||
self.assertEqual(response.data['page_count'], 6)
|
|
||||||
self.assertEqual(len(results), 5)
|
|
||||||
|
|
||||||
def test_search_invalid_page(self):
|
def test_search_invalid_page(self):
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
with AsyncWriter(index.open_index()) as writer:
|
||||||
for i in range(15):
|
for i in range(15):
|
||||||
doc = Document.objects.create(checksum=str(i), pk=i+1, title=f"Document {i+1}", content="content")
|
doc = Document.objects.create(checksum=str(i), pk=i+1, title=f"Document {i+1}", content="content")
|
||||||
index.update_document(writer, doc)
|
index.update_document(writer, doc)
|
||||||
|
|
||||||
first_page = self.client.get(f"/api/search/?query=content&page=1").data
|
response = self.client.get(f"/api/documents/?query=content&page=0&page_size=10")
|
||||||
second_page = self.client.get(f"/api/search/?query=content&page=2").data
|
self.assertEqual(response.status_code, 404)
|
||||||
should_be_first_page_1 = self.client.get(f"/api/search/?query=content&page=0").data
|
response = self.client.get(f"/api/documents/?query=content&page=3&page_size=10")
|
||||||
should_be_first_page_2 = self.client.get(f"/api/search/?query=content&page=dgfd").data
|
self.assertEqual(response.status_code, 404)
|
||||||
should_be_first_page_3 = self.client.get(f"/api/search/?query=content&page=").data
|
|
||||||
should_be_first_page_4 = self.client.get(f"/api/search/?query=content&page=-7868").data
|
|
||||||
|
|
||||||
self.assertDictEqual(first_page, should_be_first_page_1)
|
|
||||||
self.assertDictEqual(first_page, should_be_first_page_2)
|
|
||||||
self.assertDictEqual(first_page, should_be_first_page_3)
|
|
||||||
self.assertDictEqual(first_page, should_be_first_page_4)
|
|
||||||
self.assertNotEqual(len(first_page['results']), len(second_page['results']))
|
|
||||||
|
|
||||||
@mock.patch("documents.index.autocomplete")
|
@mock.patch("documents.index.autocomplete")
|
||||||
def test_search_autocomplete(self, m):
|
def test_search_autocomplete(self, m):
|
||||||
@ -419,6 +387,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(len(response.data), 10)
|
self.assertEqual(len(response.data), 10)
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Not implemented yet")
|
||||||
def test_search_spelling_correction(self):
|
def test_search_spelling_correction(self):
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
with AsyncWriter(index.open_index()) as writer:
|
||||||
for i in range(55):
|
for i in range(55):
|
||||||
@ -444,7 +413,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
|||||||
index.update_document(writer, d2)
|
index.update_document(writer, d2)
|
||||||
index.update_document(writer, d3)
|
index.update_document(writer, d3)
|
||||||
|
|
||||||
response = self.client.get(f"/api/search/?more_like={d2.id}")
|
response = self.client.get(f"/api/documents/?more_like_id={d2.id}")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -454,6 +423,54 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(results[0]['id'], d3.id)
|
self.assertEqual(results[0]['id'], d3.id)
|
||||||
self.assertEqual(results[1]['id'], d1.id)
|
self.assertEqual(results[1]['id'], d1.id)
|
||||||
|
|
||||||
|
def test_search_filtering(self):
|
||||||
|
t = Tag.objects.create(name="tag")
|
||||||
|
t2 = Tag.objects.create(name="tag2")
|
||||||
|
c = Correspondent.objects.create(name="correspondent")
|
||||||
|
dt = DocumentType.objects.create(name="type")
|
||||||
|
|
||||||
|
d1 = Document.objects.create(checksum="1", correspondent=c, content="test")
|
||||||
|
d2 = Document.objects.create(checksum="2", document_type=dt, content="test")
|
||||||
|
d3 = Document.objects.create(checksum="3", content="test")
|
||||||
|
d3.tags.add(t)
|
||||||
|
d3.tags.add(t2)
|
||||||
|
d4 = Document.objects.create(checksum="4", created=datetime.datetime(2020, 7, 13), content="test")
|
||||||
|
d4.tags.add(t2)
|
||||||
|
d5 = Document.objects.create(checksum="5", added=datetime.datetime(2020, 7, 13), content="test")
|
||||||
|
d6 = Document.objects.create(checksum="6", content="test2")
|
||||||
|
|
||||||
|
with AsyncWriter(index.open_index()) as writer:
|
||||||
|
for doc in Document.objects.all():
|
||||||
|
index.update_document(writer, doc)
|
||||||
|
|
||||||
|
def search_query(q):
|
||||||
|
r = self.client.get("/api/documents/?query=test" + q)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
return [hit['id'] for hit in r.data['results']]
|
||||||
|
|
||||||
|
self.assertCountEqual(search_query(""), [d1.id, d2.id, d3.id, d4.id, d5.id])
|
||||||
|
self.assertCountEqual(search_query("&is_tagged=true"), [d3.id, d4.id])
|
||||||
|
self.assertCountEqual(search_query("&is_tagged=false"), [d1.id, d2.id, d5.id])
|
||||||
|
self.assertCountEqual(search_query("&correspondent__id=" + str(c.id)), [d1.id])
|
||||||
|
self.assertCountEqual(search_query("&document_type__id=" + str(dt.id)), [d2.id])
|
||||||
|
self.assertCountEqual(search_query("&correspondent__isnull"), [d2.id, d3.id, d4.id, d5.id])
|
||||||
|
self.assertCountEqual(search_query("&document_type__isnull"), [d1.id, d3.id, d4.id, d5.id])
|
||||||
|
self.assertCountEqual(search_query("&tags__id__all=" + str(t.id) + "," + str(t2.id)), [d3.id])
|
||||||
|
self.assertCountEqual(search_query("&tags__id__all=" + str(t.id)), [d3.id])
|
||||||
|
self.assertCountEqual(search_query("&tags__id__all=" + str(t2.id)), [d3.id, d4.id])
|
||||||
|
|
||||||
|
self.assertIn(d4.id, search_query("&created__date__lt=" + datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d")))
|
||||||
|
self.assertNotIn(d4.id, search_query("&created__date__gt=" + datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d")))
|
||||||
|
|
||||||
|
self.assertNotIn(d4.id, search_query("&created__date__lt=" + datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d")))
|
||||||
|
self.assertIn(d4.id, search_query("&created__date__gt=" + datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d")))
|
||||||
|
|
||||||
|
self.assertIn(d5.id, search_query("&added__date__lt=" + datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d")))
|
||||||
|
self.assertNotIn(d5.id, search_query("&added__date__gt=" + datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d")))
|
||||||
|
|
||||||
|
self.assertNotIn(d5.id, search_query("&added__date__lt=" + datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d")))
|
||||||
|
self.assertIn(d5.id, search_query("&added__date__gt=" + datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d")))
|
||||||
|
|
||||||
def test_statistics(self):
|
def test_statistics(self):
|
||||||
|
|
||||||
doc1 = Document.objects.create(title="none1", checksum="A")
|
doc1 = Document.objects.create(title="none1", checksum="A")
|
||||||
@ -1375,8 +1392,7 @@ class TestApiAuth(APITestCase):
|
|||||||
self.assertEqual(self.client.get("/api/logs/").status_code, 401)
|
self.assertEqual(self.client.get("/api/logs/").status_code, 401)
|
||||||
self.assertEqual(self.client.get("/api/saved_views/").status_code, 401)
|
self.assertEqual(self.client.get("/api/saved_views/").status_code, 401)
|
||||||
|
|
||||||
self.assertEqual(self.client.get("/api/search/").status_code, 401)
|
self.assertEqual(self.client.get("/api/search/autocomplete/").status_code, 401)
|
||||||
self.assertEqual(self.client.get("/api/search/auto_complete/").status_code, 401)
|
|
||||||
self.assertEqual(self.client.get("/api/documents/bulk_edit/").status_code, 401)
|
self.assertEqual(self.client.get("/api/documents/bulk_edit/").status_code, 401)
|
||||||
self.assertEqual(self.client.get("/api/documents/bulk_download/").status_code, 401)
|
self.assertEqual(self.client.get("/api/documents/bulk_download/").status_code, 401)
|
||||||
self.assertEqual(self.client.get("/api/documents/selection_data/").status_code, 401)
|
self.assertEqual(self.client.get("/api/documents/selection_data/").status_code, 401)
|
||||||
|
@ -1,20 +1,10 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from documents import index
|
from documents import index
|
||||||
from documents.index import JsonFormatter
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
class JsonFormatterTest(TestCase):
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.formatter = JsonFormatter()
|
|
||||||
|
|
||||||
def test_empty_fragments(self):
|
|
||||||
self.assertListEqual(self.formatter.format([]), [])
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutoComplete(DirectoriesMixin, TestCase):
|
class TestAutoComplete(DirectoriesMixin, TestCase):
|
||||||
|
|
||||||
def test_auto_complete(self):
|
def test_auto_complete(self):
|
||||||
|
@ -17,6 +17,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
from django_q.tasks import async_task
|
from django_q.tasks import async_task
|
||||||
from rest_framework import parsers
|
from rest_framework import parsers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.mixins import (
|
from rest_framework.mixins import (
|
||||||
@ -327,6 +328,70 @@ class DocumentViewSet(RetrieveModelMixin,
|
|||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultSerializer(DocumentSerializer):
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
doc = Document.objects.get(id=instance['id'])
|
||||||
|
r = super(SearchResultSerializer, self).to_representation(doc)
|
||||||
|
r['__search_hit__'] = {
|
||||||
|
"score": instance.score,
|
||||||
|
"highlights": instance.highlights("content",
|
||||||
|
text=doc.content) if doc else None, # NOQA: E501
|
||||||
|
"rank": instance.rank
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class UnifiedSearchViewSet(DocumentViewSet):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(UnifiedSearchViewSet, self).__init__(*args, **kwargs)
|
||||||
|
self.searcher = None
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self._is_search_request():
|
||||||
|
return SearchResultSerializer
|
||||||
|
else:
|
||||||
|
return DocumentSerializer
|
||||||
|
|
||||||
|
def _is_search_request(self):
|
||||||
|
return ("query" in self.request.query_params or
|
||||||
|
"more_like_id" in self.request.query_params)
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
if self._is_search_request():
|
||||||
|
from documents import index
|
||||||
|
|
||||||
|
if "query" in self.request.query_params:
|
||||||
|
query_class = index.DelayedFullTextQuery
|
||||||
|
elif "more_like_id" in self.request.query_params:
|
||||||
|
query_class = index.DelayedMoreLikeThisQuery
|
||||||
|
else:
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
return query_class(
|
||||||
|
self.searcher,
|
||||||
|
self.request.query_params,
|
||||||
|
self.paginator.get_page_size(self.request))
|
||||||
|
else:
|
||||||
|
return super(UnifiedSearchViewSet, self).filter_queryset(queryset)
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
if self._is_search_request():
|
||||||
|
from documents import index
|
||||||
|
try:
|
||||||
|
with index.open_index_searcher() as s:
|
||||||
|
self.searcher = s
|
||||||
|
return super(UnifiedSearchViewSet, self).list(request)
|
||||||
|
except NotFound:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponseBadRequest(str(e))
|
||||||
|
else:
|
||||||
|
return super(UnifiedSearchViewSet, self).list(request)
|
||||||
|
|
||||||
|
|
||||||
class LogViewSet(ViewSet):
|
class LogViewSet(ViewSet):
|
||||||
|
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
@ -478,74 +543,6 @@ class SelectionDataView(GenericAPIView):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
class SearchView(APIView):
|
|
||||||
|
|
||||||
permission_classes = (IsAuthenticated,)
|
|
||||||
|
|
||||||
def add_infos_to_hit(self, r):
|
|
||||||
try:
|
|
||||||
doc = Document.objects.get(id=r['id'])
|
|
||||||
except Document.DoesNotExist:
|
|
||||||
logger.warning(
|
|
||||||
f"Search index returned a non-existing document: "
|
|
||||||
f"id: {r['id']}, title: {r['title']}. "
|
|
||||||
f"Search index needs reindex."
|
|
||||||
)
|
|
||||||
doc = None
|
|
||||||
|
|
||||||
return {'id': r['id'],
|
|
||||||
'highlights': r.highlights("content", text=doc.content) if doc else None, # NOQA: E501
|
|
||||||
'score': r.score,
|
|
||||||
'rank': r.rank,
|
|
||||||
'document': DocumentSerializer(doc).data if doc else None,
|
|
||||||
'title': r['title']
|
|
||||||
}
|
|
||||||
|
|
||||||
def get(self, request, format=None):
|
|
||||||
from documents import index
|
|
||||||
|
|
||||||
if 'query' in request.query_params:
|
|
||||||
query = request.query_params['query']
|
|
||||||
else:
|
|
||||||
query = None
|
|
||||||
|
|
||||||
if 'more_like' in request.query_params:
|
|
||||||
more_like_id = request.query_params['more_like']
|
|
||||||
more_like_content = Document.objects.get(id=more_like_id).content
|
|
||||||
else:
|
|
||||||
more_like_id = None
|
|
||||||
more_like_content = None
|
|
||||||
|
|
||||||
if not query and not more_like_id:
|
|
||||||
return Response({
|
|
||||||
'count': 0,
|
|
||||||
'page': 0,
|
|
||||||
'page_count': 0,
|
|
||||||
'corrected_query': None,
|
|
||||||
'results': []})
|
|
||||||
|
|
||||||
try:
|
|
||||||
page = int(request.query_params.get('page', 1))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
page = 1
|
|
||||||
|
|
||||||
if page < 1:
|
|
||||||
page = 1
|
|
||||||
|
|
||||||
ix = index.open_index()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with index.query_page(ix, page, query, more_like_id, more_like_content) as (result_page, corrected_query): # NOQA: E501
|
|
||||||
return Response(
|
|
||||||
{'count': len(result_page),
|
|
||||||
'page': result_page.pagenum,
|
|
||||||
'page_count': result_page.pagecount,
|
|
||||||
'corrected_query': corrected_query,
|
|
||||||
'results': list(map(self.add_infos_to_hit, result_page))})
|
|
||||||
except Exception as e:
|
|
||||||
return HttpResponseBadRequest(str(e))
|
|
||||||
|
|
||||||
|
|
||||||
class SearchAutoCompleteView(APIView):
|
class SearchAutoCompleteView(APIView):
|
||||||
|
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
|
@ -12,11 +12,10 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from paperless.consumers import StatusConsumer
|
from paperless.consumers import StatusConsumer
|
||||||
from documents.views import (
|
from documents.views import (
|
||||||
CorrespondentViewSet,
|
CorrespondentViewSet,
|
||||||
DocumentViewSet,
|
UnifiedSearchViewSet,
|
||||||
LogViewSet,
|
LogViewSet,
|
||||||
TagViewSet,
|
TagViewSet,
|
||||||
DocumentTypeViewSet,
|
DocumentTypeViewSet,
|
||||||
SearchView,
|
|
||||||
IndexView,
|
IndexView,
|
||||||
SearchAutoCompleteView,
|
SearchAutoCompleteView,
|
||||||
StatisticsView,
|
StatisticsView,
|
||||||
@ -31,7 +30,7 @@ from paperless.views import FaviconView
|
|||||||
api_router = DefaultRouter()
|
api_router = DefaultRouter()
|
||||||
api_router.register(r"correspondents", CorrespondentViewSet)
|
api_router.register(r"correspondents", CorrespondentViewSet)
|
||||||
api_router.register(r"document_types", DocumentTypeViewSet)
|
api_router.register(r"document_types", DocumentTypeViewSet)
|
||||||
api_router.register(r"documents", DocumentViewSet)
|
api_router.register(r"documents", UnifiedSearchViewSet)
|
||||||
api_router.register(r"logs", LogViewSet, basename="logs")
|
api_router.register(r"logs", LogViewSet, basename="logs")
|
||||||
api_router.register(r"tags", TagViewSet)
|
api_router.register(r"tags", TagViewSet)
|
||||||
api_router.register(r"saved_views", SavedViewViewSet)
|
api_router.register(r"saved_views", SavedViewViewSet)
|
||||||
@ -47,10 +46,6 @@ urlpatterns = [
|
|||||||
SearchAutoCompleteView.as_view(),
|
SearchAutoCompleteView.as_view(),
|
||||||
name="autocomplete"),
|
name="autocomplete"),
|
||||||
|
|
||||||
re_path(r"^search/",
|
|
||||||
SearchView.as_view(),
|
|
||||||
name="search"),
|
|
||||||
|
|
||||||
re_path(r"^statistics/",
|
re_path(r"^statistics/",
|
||||||
StatisticsView.as_view(),
|
StatisticsView.as_view(),
|
||||||
name="statistics"),
|
name="statistics"),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user