mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Feature: Share links (#3996)
* Implement share links Basic implementation of share links Make certain share link fields not editable, automatically grant permissions on migrate Updated styling, error messages from expired / deleted links frontend code linting, reversable sharelink migration testing coverage Update translation strings No links message * Consolidate file response methods * improvements to share links on mobile devices * Refactor share links file_version * Add docs for share links * Apply suggestions from code review * When filtering share links, use the timezone aware now() * Removes extra call to setup directories for usage in testing * FIx copied badge display on some browsers * Move copy to ngx-clipboard library --------- Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
This commit is contained in:
		@@ -545,3 +545,16 @@ Paperless-ngx consists of the following components:
 | 
			
		||||
 | 
			
		||||
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
 | 
			
		||||
  and SQLite for storing its data.
 | 
			
		||||
 | 
			
		||||
## Share Links
 | 
			
		||||
 | 
			
		||||
Paperless-ngx added the abiltiy to create shareable links to files in version 2.0. You can find the button for this on the document detail screen.
 | 
			
		||||
 | 
			
		||||
- Share links do not require a user to login and thus link directly to a file.
 | 
			
		||||
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
 | 
			
		||||
- Links can optionally have an expiration time set.
 | 
			
		||||
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
 | 
			
		||||
 | 
			
		||||
!!! tip
 | 
			
		||||
 | 
			
		||||
    If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
 | 
			
		||||
 
 | 
			
		||||
@@ -319,7 +319,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">55</context>
 | 
			
		||||
          <context context-type="linenumber">65</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1241348629231510663" datatype="html">
 | 
			
		||||
@@ -1073,7 +1073,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">198</context>
 | 
			
		||||
          <context context-type="linenumber">208</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
 | 
			
		||||
@@ -1142,7 +1142,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">182</context>
 | 
			
		||||
          <context context-type="linenumber">192</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
@@ -1549,6 +1549,10 @@
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">9</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">33</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">11</context>
 | 
			
		||||
@@ -2240,6 +2244,135 @@
 | 
			
		||||
          <context context-type="linenumber">20</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="686374493515618129" datatype="html">
 | 
			
		||||
        <source>Share Links</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">6</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">25</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6617773613987957957" datatype="html">
 | 
			
		||||
        <source> No existing links </source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">10,12</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4323470180912194028" datatype="html">
 | 
			
		||||
        <source>Copy</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7419704019640008953" datatype="html">
 | 
			
		||||
        <source>Share</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">28</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5392341774767336507" datatype="html">
 | 
			
		||||
        <source>Copied!</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">36</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6811921365829755679" datatype="html">
 | 
			
		||||
        <source>Share archive version</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">42</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5674286808255988565" datatype="html">
 | 
			
		||||
        <source>Create</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">55</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4776429682428363094" datatype="html">
 | 
			
		||||
        <source>1 day</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">18</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">85</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8542568275115626925" datatype="html">
 | 
			
		||||
        <source>7 days</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">19</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7152095234138763013" datatype="html">
 | 
			
		||||
        <source>30 days</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">20</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8372007266188249803" datatype="html">
 | 
			
		||||
        <source>Never</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">21</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3429210839568770054" datatype="html">
 | 
			
		||||
        <source>Error retrieving links</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">69</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3242255798983858463" datatype="html">
 | 
			
		||||
        <source><x id="PH" equiv-text="days"/> days</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">85</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2897042887615940599" datatype="html">
 | 
			
		||||
        <source>Error deleting link</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">112</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8400747326190565173" datatype="html">
 | 
			
		||||
        <source>Error creating link</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">140</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5611592591303869712" datatype="html">
 | 
			
		||||
        <source>Status</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
@@ -2310,7 +2443,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">75</context>
 | 
			
		||||
          <context context-type="linenumber">85</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
 | 
			
		||||
@@ -2340,7 +2473,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">19</context>
 | 
			
		||||
          <context context-type="linenumber">18</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
@@ -2538,14 +2671,65 @@
 | 
			
		||||
        <source>Download original</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">25</context>
 | 
			
		||||
          <context context-type="linenumber">24</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3193976279273491157" datatype="html">
 | 
			
		||||
        <source>Actions</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">34</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">86</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">221</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">259</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">296</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">347</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">382</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">44</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8312409092917397847" datatype="html">
 | 
			
		||||
        <source>Redo OCR</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">34</context>
 | 
			
		||||
          <context context-type="linenumber">40</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
@@ -2556,7 +2740,7 @@
 | 
			
		||||
        <source>More like this</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">40</context>
 | 
			
		||||
          <context context-type="linenumber">46</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
 | 
			
		||||
@@ -2567,7 +2751,7 @@
 | 
			
		||||
        <source>Close</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">43</context>
 | 
			
		||||
          <context context-type="linenumber">53</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
 | 
			
		||||
@@ -2578,35 +2762,35 @@
 | 
			
		||||
        <source>Previous</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">50</context>
 | 
			
		||||
          <context context-type="linenumber">60</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5028777105388019087" datatype="html">
 | 
			
		||||
        <source>Details</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">72</context>
 | 
			
		||||
          <context context-type="linenumber">82</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1379170675585571971" datatype="html">
 | 
			
		||||
        <source>Archive serial number</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">76</context>
 | 
			
		||||
          <context context-type="linenumber">86</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5114742157723900905" datatype="html">
 | 
			
		||||
        <source>Date created</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">77</context>
 | 
			
		||||
          <context context-type="linenumber">87</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2691296884221415710" datatype="html">
 | 
			
		||||
        <source>Correspondent</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">79</context>
 | 
			
		||||
          <context context-type="linenumber">89</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
@@ -2629,7 +2813,7 @@
 | 
			
		||||
        <source>Document type</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">81</context>
 | 
			
		||||
          <context context-type="linenumber">91</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
@@ -2652,7 +2836,7 @@
 | 
			
		||||
        <source>Storage path</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">83</context>
 | 
			
		||||
          <context context-type="linenumber">93</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
@@ -2671,21 +2855,21 @@
 | 
			
		||||
        <source>Default</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">84</context>
 | 
			
		||||
          <context context-type="linenumber">94</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6205355627445317276" datatype="html">
 | 
			
		||||
        <source>Content</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">91</context>
 | 
			
		||||
          <context context-type="linenumber">101</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="218403386307979629" datatype="html">
 | 
			
		||||
        <source>Metadata</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">100</context>
 | 
			
		||||
          <context context-type="linenumber">110</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
 | 
			
		||||
@@ -2696,173 +2880,173 @@
 | 
			
		||||
        <source>Date modified</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">106</context>
 | 
			
		||||
          <context context-type="linenumber">116</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6392918669949841614" datatype="html">
 | 
			
		||||
        <source>Date added</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">110</context>
 | 
			
		||||
          <context context-type="linenumber">120</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="146828917013192897" datatype="html">
 | 
			
		||||
        <source>Media filename</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">114</context>
 | 
			
		||||
          <context context-type="linenumber">124</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4500855521601039868" datatype="html">
 | 
			
		||||
        <source>Original filename</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">118</context>
 | 
			
		||||
          <context context-type="linenumber">128</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7985558498848210210" datatype="html">
 | 
			
		||||
        <source>Original MD5 checksum</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">122</context>
 | 
			
		||||
          <context context-type="linenumber">132</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5888243105821763422" datatype="html">
 | 
			
		||||
        <source>Original file size</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">126</context>
 | 
			
		||||
          <context context-type="linenumber">136</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2696647325713149563" datatype="html">
 | 
			
		||||
        <source>Original mime type</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">130</context>
 | 
			
		||||
          <context context-type="linenumber">140</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="342875990758166588" datatype="html">
 | 
			
		||||
        <source>Archive MD5 checksum</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">134</context>
 | 
			
		||||
          <context context-type="linenumber">144</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6033581412811562084" datatype="html">
 | 
			
		||||
        <source>Archive file size</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">138</context>
 | 
			
		||||
          <context context-type="linenumber">148</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6992781481378431874" datatype="html">
 | 
			
		||||
        <source>Original document metadata</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">144</context>
 | 
			
		||||
          <context context-type="linenumber">154</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2846565152091361585" datatype="html">
 | 
			
		||||
        <source>Archived document metadata</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">145</context>
 | 
			
		||||
          <context context-type="linenumber">155</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1295614462098694869" datatype="html">
 | 
			
		||||
        <source>Preview</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">151</context>
 | 
			
		||||
          <context context-type="linenumber">161</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8191371354890763172" datatype="html">
 | 
			
		||||
        <source>Enter Password</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">167</context>
 | 
			
		||||
          <context context-type="linenumber">177</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">218</context>
 | 
			
		||||
          <context context-type="linenumber">228</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8460995830263484763" datatype="html">
 | 
			
		||||
        <source>Notes <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="document?.notes.length" class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</a>"/></source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">175,176</context>
 | 
			
		||||
          <context context-type="linenumber">185,186</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3823219296477075982" datatype="html">
 | 
			
		||||
        <source>Discard</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">194</context>
 | 
			
		||||
          <context context-type="linenumber">204</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5129524307369213584" datatype="html">
 | 
			
		||||
        <source>Save & next</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">196</context>
 | 
			
		||||
          <context context-type="linenumber">206</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4910102545766233758" datatype="html">
 | 
			
		||||
        <source>Save & close</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">197</context>
 | 
			
		||||
          <context context-type="linenumber">207</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2218903673684131427" datatype="html">
 | 
			
		||||
        <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">252,254</context>
 | 
			
		||||
          <context context-type="linenumber">253,255</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5758784066858623886" datatype="html">
 | 
			
		||||
        <source>Error retrieving metadata</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">397</context>
 | 
			
		||||
          <context context-type="linenumber">398</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3456881259945295697" datatype="html">
 | 
			
		||||
        <source>Error retrieving suggestions.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">418</context>
 | 
			
		||||
          <context context-type="linenumber">419</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8348337312757497317" datatype="html">
 | 
			
		||||
        <source>Document saved successfully.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">531</context>
 | 
			
		||||
          <context context-type="linenumber">532</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">539</context>
 | 
			
		||||
          <context context-type="linenumber">540</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="448882439049417053" datatype="html">
 | 
			
		||||
        <source>Error saving document</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">543</context>
 | 
			
		||||
          <context context-type="linenumber">544</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">584</context>
 | 
			
		||||
          <context context-type="linenumber">585</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="9021887951960049161" datatype="html">
 | 
			
		||||
        <source>Confirm delete</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">610</context>
 | 
			
		||||
          <context context-type="linenumber">611</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
@@ -2873,35 +3057,35 @@
 | 
			
		||||
        <source>Do you really want to delete document "<x id="PH" equiv-text="this.document.title"/>"?</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">611</context>
 | 
			
		||||
          <context context-type="linenumber">612</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6691075929777935948" datatype="html">
 | 
			
		||||
        <source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">612</context>
 | 
			
		||||
          <context context-type="linenumber">613</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="719892092227206532" datatype="html">
 | 
			
		||||
        <source>Delete document</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">614</context>
 | 
			
		||||
          <context context-type="linenumber">615</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7295637485862454066" datatype="html">
 | 
			
		||||
        <source>Error deleting document</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">633</context>
 | 
			
		||||
          <context context-type="linenumber">634</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7362691899087997122" datatype="html">
 | 
			
		||||
        <source>Redo OCR confirm</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">653</context>
 | 
			
		||||
          <context context-type="linenumber">654</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
 | 
			
		||||
@@ -2912,14 +3096,14 @@
 | 
			
		||||
        <source>This operation will permanently redo OCR for this document.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">654</context>
 | 
			
		||||
          <context context-type="linenumber">655</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5641451190833696892" datatype="html">
 | 
			
		||||
        <source>This operation cannot be undone.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">655</context>
 | 
			
		||||
          <context context-type="linenumber">656</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
 | 
			
		||||
@@ -2950,7 +3134,7 @@
 | 
			
		||||
        <source>Proceed</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">657</context>
 | 
			
		||||
          <context context-type="linenumber">658</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
 | 
			
		||||
@@ -2977,14 +3161,14 @@
 | 
			
		||||
        <source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">665</context>
 | 
			
		||||
          <context context-type="linenumber">666</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4409560272830824468" datatype="html">
 | 
			
		||||
        <source>Error executing operation</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">676</context>
 | 
			
		||||
          <context context-type="linenumber">677</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6857598786757174736" datatype="html">
 | 
			
		||||
@@ -3045,53 +3229,6 @@
 | 
			
		||||
          <context context-type="linenumber">52</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3193976279273491157" datatype="html">
 | 
			
		||||
        <source>Actions</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">86</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">23</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">221</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">259</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">296</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">347</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">382</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">44</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1015374532025907183" datatype="html">
 | 
			
		||||
        <source>Include:</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
@@ -3945,25 +4082,6 @@
 | 
			
		||||
          <context context-type="linenumber">44</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5674286808255988565" datatype="html">
 | 
			
		||||
        <source>Create</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">2</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4010735610815226758" datatype="html">
 | 
			
		||||
        <source>Filter by:</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										29
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -24,6 +24,7 @@
 | 
			
		||||
        "file-saver": "^2.0.5",
 | 
			
		||||
        "mime-names": "^1.0.0",
 | 
			
		||||
        "ng2-pdf-viewer": "^10.0.0",
 | 
			
		||||
        "ngx-clipboard": "^16.0.0",
 | 
			
		||||
        "ngx-color": "^9.0.0",
 | 
			
		||||
        "ngx-cookie-service": "^16.0.1",
 | 
			
		||||
        "ngx-file-drop": "^16.0.0",
 | 
			
		||||
@@ -14404,6 +14405,19 @@
 | 
			
		||||
        "pdfjs-dist": "~2.16.105"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ngx-clipboard": {
 | 
			
		||||
      "version": "16.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ngx-clipboard/-/ngx-clipboard-16.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-rZ/Eo1PqiKMiyF8tdjhmUkoUu68f7OzBJ7YH1YFeh2RAaNrerTaW8XfFOzppSckjFQqA1fwGSYuTTJlDhDag5w==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "ngx-window-token": ">=7.0.0",
 | 
			
		||||
        "tslib": "^2.0.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@angular/common": ">=13.0.0",
 | 
			
		||||
        "@angular/core": ">=13.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ngx-color": {
 | 
			
		||||
      "version": "9.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ngx-color/-/ngx-color-9.0.0.tgz",
 | 
			
		||||
@@ -14474,6 +14488,21 @@
 | 
			
		||||
        "@ng-bootstrap/ng-bootstrap": "^15.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ngx-window-token": {
 | 
			
		||||
      "version": "7.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ngx-window-token/-/ngx-window-token-7.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-5+XfRVSY7Dciu8xyCNMkOlH2UfwR9W2P1Pirz7caaZgOZDjFbL8aEO2stjfJJm2FFf1D6dlVHNzhLWGk9HGkqA==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "tslib": "^2.0.0"
 | 
			
		||||
      },
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": "^14.20.0 || ^16.13.0 || >=18.10.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@angular/common": ">=13.0.0",
 | 
			
		||||
        "@angular/core": ">=13.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/nice-napi": {
 | 
			
		||||
      "version": "1.0.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,7 @@
 | 
			
		||||
    "file-saver": "^2.0.5",
 | 
			
		||||
    "mime-names": "^1.0.0",
 | 
			
		||||
    "ng2-pdf-viewer": "^10.0.0",
 | 
			
		||||
    "ngx-clipboard": "^16.0.0",
 | 
			
		||||
    "ngx-color": "^9.0.0",
 | 
			
		||||
    "ngx-cookie-service": "^16.0.1",
 | 
			
		||||
    "ngx-file-drop": "^16.0.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -86,6 +86,7 @@ Object.defineProperty(navigator, 'clipboard', {
 | 
			
		||||
    writeText: async () => {},
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
Object.defineProperty(navigator, 'canShare', { value: () => true })
 | 
			
		||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
 | 
			
		||||
 | 
			
		||||
HTMLCanvasElement.prototype.getContext = <
 | 
			
		||||
 
 | 
			
		||||
@@ -94,6 +94,7 @@ import { PermissionsFilterDropdownComponent } from './components/common/permissi
 | 
			
		||||
import { UsernamePipe } from './pipes/username.pipe'
 | 
			
		||||
import { LogoComponent } from './components/common/logo/logo.component'
 | 
			
		||||
import { IsNumberPipe } from './pipes/is-number.pipe'
 | 
			
		||||
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
 | 
			
		||||
 | 
			
		||||
import localeAf from '@angular/common/locales/af'
 | 
			
		||||
import localeAr from '@angular/common/locales/ar'
 | 
			
		||||
@@ -231,6 +232,7 @@ function initializeApp(settings: SettingsService) {
 | 
			
		||||
    UsernamePipe,
 | 
			
		||||
    LogoComponent,
 | 
			
		||||
    IsNumberPipe,
 | 
			
		||||
    ShareLinksDropdownComponent,
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    BrowserModule,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import {
 | 
			
		||||
  ComponentFixture,
 | 
			
		||||
  TestBed,
 | 
			
		||||
  discardPeriodicTasks,
 | 
			
		||||
  fakeAsync,
 | 
			
		||||
  tick,
 | 
			
		||||
} from '@angular/core/testing'
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,61 @@
 | 
			
		||||
<div ngbDropdown>
 | 
			
		||||
    <button class="btn btn-sm btn-outline-primary me-2" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
 | 
			
		||||
      <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#link" />
 | 
			
		||||
      </svg>
 | 
			
		||||
      <div class="d-none d-sm-inline"> <ng-container i18n>Share Links</ng-container></div>
 | 
			
		||||
    </button>
 | 
			
		||||
    <div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown">
 | 
			
		||||
      <ul class="list-group list-group-flush">
 | 
			
		||||
        <li *ngIf="!shareLinks || shareLinks.length === 0" class="list-group-item fst-italic small text-center text-secondary" i18n>
 | 
			
		||||
          No existing links
 | 
			
		||||
        </li>
 | 
			
		||||
        <li class="list-group-item" *ngFor="let link of shareLinks">
 | 
			
		||||
          <div class="input-group input-group-sm w-100">
 | 
			
		||||
            <input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
 | 
			
		||||
            <span *ngIf="link.expiration" class="input-group-text">
 | 
			
		||||
              {{ getDaysRemaining(link) }}
 | 
			
		||||
            </span>
 | 
			
		||||
            <button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)">
 | 
			
		||||
              <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
                <use *ngIf="copied !== link.id" xlink:href="assets/bootstrap-icons.svg#clipboard-fill" />
 | 
			
		||||
                <use *ngIf="copied === link.id" xlink:href="assets/bootstrap-icons.svg#clipboard-check-fill" />
 | 
			
		||||
              </svg><span class="visually-hidden" i18n>Copy</span>
 | 
			
		||||
            </button>
 | 
			
		||||
            <button *ngIf="canShare(link)" type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)">
 | 
			
		||||
              <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#box-arrow-up" />
 | 
			
		||||
              </svg><span class="visually-hidden" i18n>Share</span>
 | 
			
		||||
            </button>
 | 
			
		||||
            <button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)">
 | 
			
		||||
                <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
                    <use xlink:href="assets/bootstrap-icons.svg#trash" />
 | 
			
		||||
                </svg><span class="visually-hidden" i18n>Delete</span>
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
          <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li class="list-group-item pt-3 pb-2">
 | 
			
		||||
          <div class="input-group input-group-sm w-100">
 | 
			
		||||
            <div class="form-check form-switch ms-auto">
 | 
			
		||||
              <input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [(ngModel)]="archiveVersion">
 | 
			
		||||
              <label class="form-check-label small" for="versionSwitch" i18n>Share archive version</label>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="input-group input-group-sm w-100 mt-2">
 | 
			
		||||
            <label class="input-group-text" for="addLink">Expires:</label>
 | 
			
		||||
            <select class="form-select form-select-sm" [(ngModel)]="expirationDays">
 | 
			
		||||
              <option *ngFor="let option of EXPIRATION_OPTIONS" [ngValue]="option.value">{{ option.label }}</option>
 | 
			
		||||
            </select>
 | 
			
		||||
            <button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
 | 
			
		||||
              <div *ngIf="loading" class="spinner-border spinner-border-sm me-2" role="status"></div>
 | 
			
		||||
              <svg *ngIf="!loading" class="buttonicon me-1" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#plus" />
 | 
			
		||||
              </svg>
 | 
			
		||||
              <ng-container i18n>Create</ng-container>
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
.share-links-dropdown {
 | 
			
		||||
    min-width: 350px;
 | 
			
		||||
 | 
			
		||||
    // correct position on mobile
 | 
			
		||||
    @media (max-width: 575.98px) {
 | 
			
		||||
        &.show {
 | 
			
		||||
            margin-left: -175px !important;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.copied-badge {
 | 
			
		||||
    right: 7.5em;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,195 @@
 | 
			
		||||
import {
 | 
			
		||||
  HttpTestingController,
 | 
			
		||||
  HttpClientTestingModule,
 | 
			
		||||
} from '@angular/common/http/testing'
 | 
			
		||||
import {
 | 
			
		||||
  ComponentFixture,
 | 
			
		||||
  TestBed,
 | 
			
		||||
  fakeAsync,
 | 
			
		||||
  tick,
 | 
			
		||||
} from '@angular/core/testing'
 | 
			
		||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
			
		||||
import { of, throwError } from 'rxjs'
 | 
			
		||||
import {
 | 
			
		||||
  PaperlessFileVersion,
 | 
			
		||||
  PaperlessShareLink,
 | 
			
		||||
} from 'src/app/data/paperless-share-link'
 | 
			
		||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
import { ShareLinksDropdownComponent } from './share-links-dropdown.component'
 | 
			
		||||
import { ClipboardService } from 'ngx-clipboard'
 | 
			
		||||
 | 
			
		||||
describe('ShareLinksDropdownComponent', () => {
 | 
			
		||||
  let component: ShareLinksDropdownComponent
 | 
			
		||||
  let fixture: ComponentFixture<ShareLinksDropdownComponent>
 | 
			
		||||
  let shareLinkService: ShareLinkService
 | 
			
		||||
  let toastService: ToastService
 | 
			
		||||
  let httpController: HttpTestingController
 | 
			
		||||
  let clipboardService: ClipboardService
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ShareLinksDropdownComponent],
 | 
			
		||||
      imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(ShareLinksDropdownComponent)
 | 
			
		||||
    shareLinkService = TestBed.inject(ShareLinkService)
 | 
			
		||||
    toastService = TestBed.inject(ToastService)
 | 
			
		||||
    httpController = TestBed.inject(HttpTestingController)
 | 
			
		||||
    clipboardService = TestBed.inject(ClipboardService)
 | 
			
		||||
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support refresh to retrieve links', () => {
 | 
			
		||||
    const getSpy = jest.spyOn(shareLinkService, 'getLinksForDocument')
 | 
			
		||||
    component.documentId = 99
 | 
			
		||||
 | 
			
		||||
    const now = new Date()
 | 
			
		||||
    const expiration7days = new Date()
 | 
			
		||||
    expiration7days.setDate(now.getDate() + 7)
 | 
			
		||||
 | 
			
		||||
    getSpy.mockReturnValue(
 | 
			
		||||
      of([
 | 
			
		||||
        {
 | 
			
		||||
          id: 1,
 | 
			
		||||
          slug: '1234slug',
 | 
			
		||||
          created: now.toISOString(),
 | 
			
		||||
          document: 99,
 | 
			
		||||
          file_version: PaperlessFileVersion.Archive,
 | 
			
		||||
          expiration: expiration7days.toISOString(),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          id: 1,
 | 
			
		||||
          slug: '1234slug',
 | 
			
		||||
          created: now.toISOString(),
 | 
			
		||||
          document: 99,
 | 
			
		||||
          file_version: PaperlessFileVersion.Original,
 | 
			
		||||
          expiration: null,
 | 
			
		||||
        },
 | 
			
		||||
      ])
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    component.refresh()
 | 
			
		||||
    expect(getSpy).toHaveBeenCalled()
 | 
			
		||||
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
 | 
			
		||||
    expect(component.shareLinks).toHaveLength(2)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should show error on refresh if needed', () => {
 | 
			
		||||
    const toastSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(shareLinkService, 'getLinksForDocument')
 | 
			
		||||
      .mockReturnValueOnce(throwError(() => new Error('Unable to get links')))
 | 
			
		||||
    component.documentId = 99
 | 
			
		||||
 | 
			
		||||
    component.refresh()
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(toastSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support link creation then refresh & copy url', fakeAsync(() => {
 | 
			
		||||
    const createSpy = jest.spyOn(shareLinkService, 'createLinkForDocument')
 | 
			
		||||
    component.documentId = 99
 | 
			
		||||
    component.expirationDays = 7
 | 
			
		||||
    component.archiveVersion = false
 | 
			
		||||
 | 
			
		||||
    const expiration = new Date()
 | 
			
		||||
    expiration.setDate(expiration.getDate() + 7)
 | 
			
		||||
 | 
			
		||||
    const copySpy = jest.spyOn(clipboardService, 'copy')
 | 
			
		||||
    const refreshSpy = jest.spyOn(component, 'refresh')
 | 
			
		||||
 | 
			
		||||
    component.createLink()
 | 
			
		||||
    expect(createSpy).toHaveBeenCalledWith(99, 'original', expiration)
 | 
			
		||||
 | 
			
		||||
    httpController.expectOne(`${environment.apiBaseUrl}share_links/`).flush({
 | 
			
		||||
      id: 1,
 | 
			
		||||
      slug: '1234slug',
 | 
			
		||||
      document: 99,
 | 
			
		||||
      expiration: expiration.toISOString(),
 | 
			
		||||
    })
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    tick(3000)
 | 
			
		||||
 | 
			
		||||
    expect(copySpy).toHaveBeenCalled()
 | 
			
		||||
    expect(refreshSpy).toHaveBeenCalled()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should show error on link creation if needed', () => {
 | 
			
		||||
    component.documentId = 99
 | 
			
		||||
    component.expirationDays = 7
 | 
			
		||||
 | 
			
		||||
    const expiration = new Date()
 | 
			
		||||
    expiration.setDate(expiration.getDate() + 7)
 | 
			
		||||
 | 
			
		||||
    const toastSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
 | 
			
		||||
    component.createLink()
 | 
			
		||||
 | 
			
		||||
    httpController
 | 
			
		||||
      .expectOne(`${environment.apiBaseUrl}share_links/`)
 | 
			
		||||
      .flush(
 | 
			
		||||
        { error: 'Share link error' },
 | 
			
		||||
        { status: 500, statusText: 'error' }
 | 
			
		||||
      )
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
 | 
			
		||||
    expect(toastSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support delete links & refresh', () => {
 | 
			
		||||
    const deleteSpy = jest.spyOn(shareLinkService, 'delete')
 | 
			
		||||
    deleteSpy.mockReturnValue(of(true))
 | 
			
		||||
    const refreshSpy = jest.spyOn(component, 'refresh')
 | 
			
		||||
 | 
			
		||||
    component.delete({ id: 12 } as PaperlessShareLink)
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(deleteSpy).toHaveBeenCalledWith({ id: 12 })
 | 
			
		||||
    expect(refreshSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should show error on delete if needed', () => {
 | 
			
		||||
    const toastSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(shareLinkService, 'delete')
 | 
			
		||||
      .mockReturnValueOnce(throwError(() => new Error('Unable to delete link')))
 | 
			
		||||
    component.delete(null)
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(toastSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should format days remaining', () => {
 | 
			
		||||
    const now = new Date()
 | 
			
		||||
    const expiration7days = new Date()
 | 
			
		||||
    expiration7days.setDate(now.getDate() + 7)
 | 
			
		||||
    const expiration1day = new Date()
 | 
			
		||||
    expiration1day.setDate(now.getDate() + 1)
 | 
			
		||||
 | 
			
		||||
    expect(
 | 
			
		||||
      component.getDaysRemaining({
 | 
			
		||||
        expiration: expiration7days.toISOString(),
 | 
			
		||||
      } as PaperlessShareLink)
 | 
			
		||||
    ).toEqual('7 days')
 | 
			
		||||
    expect(
 | 
			
		||||
      component.getDaysRemaining({
 | 
			
		||||
        expiration: expiration1day.toISOString(),
 | 
			
		||||
      } as PaperlessShareLink)
 | 
			
		||||
    ).toEqual('1 day')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // coverage
 | 
			
		||||
  it('should support share', () => {
 | 
			
		||||
    const link = { slug: '12345slug' } as PaperlessShareLink
 | 
			
		||||
    if (!('share' in navigator))
 | 
			
		||||
      Object.defineProperty(navigator, 'share', { value: (obj: any) => {} })
 | 
			
		||||
    // const navigatorSpy = jest.spyOn(navigator, 'share')
 | 
			
		||||
    component.share(link)
 | 
			
		||||
    // expect(navigatorSpy).toHaveBeenCalledWith({ url: component.getShareUrl(link) })
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
@@ -0,0 +1,149 @@
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core'
 | 
			
		||||
import { first } from 'rxjs'
 | 
			
		||||
import {
 | 
			
		||||
  PaperlessShareLink,
 | 
			
		||||
  PaperlessFileVersion,
 | 
			
		||||
} from 'src/app/data/paperless-share-link'
 | 
			
		||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
import { ClipboardService } from 'ngx-clipboard'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-share-links-dropdown',
 | 
			
		||||
  templateUrl: './share-links-dropdown.component.html',
 | 
			
		||||
  styleUrls: ['./share-links-dropdown.component.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class ShareLinksDropdownComponent implements OnInit {
 | 
			
		||||
  EXPIRATION_OPTIONS = [
 | 
			
		||||
    { label: $localize`1 day`, value: 1 },
 | 
			
		||||
    { label: $localize`7 days`, value: 7 },
 | 
			
		||||
    { label: $localize`30 days`, value: 30 },
 | 
			
		||||
    { label: $localize`Never`, value: null },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title = $localize`Share Links`
 | 
			
		||||
 | 
			
		||||
  _documentId: number
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set documentId(id: number) {
 | 
			
		||||
    if (id !== undefined) {
 | 
			
		||||
      this._documentId = id
 | 
			
		||||
      this.refresh()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled: boolean = false
 | 
			
		||||
 | 
			
		||||
  shareLinks: PaperlessShareLink[]
 | 
			
		||||
 | 
			
		||||
  loading: boolean = false
 | 
			
		||||
 | 
			
		||||
  copied: number
 | 
			
		||||
 | 
			
		||||
  expirationDays: number = 7
 | 
			
		||||
 | 
			
		||||
  archiveVersion: boolean = true
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private shareLinkService: ShareLinkService,
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    private clipboardService: ClipboardService
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    if (this._documentId !== undefined) this.refresh()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  refresh() {
 | 
			
		||||
    if (this._documentId === undefined) return
 | 
			
		||||
    this.loading = true
 | 
			
		||||
    this.shareLinkService
 | 
			
		||||
      .getLinksForDocument(this._documentId)
 | 
			
		||||
      .pipe(first())
 | 
			
		||||
      .subscribe({
 | 
			
		||||
        next: (results) => {
 | 
			
		||||
          this.loading = false
 | 
			
		||||
          this.shareLinks = results
 | 
			
		||||
        },
 | 
			
		||||
        error: (e) => {
 | 
			
		||||
          this.toastService.showError(
 | 
			
		||||
            $localize`Error retrieving links`,
 | 
			
		||||
            10000,
 | 
			
		||||
            e
 | 
			
		||||
          )
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getShareUrl(link: PaperlessShareLink): string {
 | 
			
		||||
    return `${environment.apiBaseUrl.replace('api', 'share')}${link.slug}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getDaysRemaining(link: PaperlessShareLink): string {
 | 
			
		||||
    const days: number = Math.ceil(
 | 
			
		||||
      (Date.parse(link.expiration) - Date.now()) / (1000 * 60 * 60 * 24)
 | 
			
		||||
    )
 | 
			
		||||
    return days === 1 ? $localize`1 day` : $localize`${days} days`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  copy(link: PaperlessShareLink) {
 | 
			
		||||
    this.clipboardService.copy(this.getShareUrl(link))
 | 
			
		||||
    this.copied = link.id
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.copied = null
 | 
			
		||||
    }, 3000)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  canShare(link: PaperlessShareLink): boolean {
 | 
			
		||||
    return (
 | 
			
		||||
      navigator?.canShare && navigator.canShare({ url: this.getShareUrl(link) })
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  share(link: PaperlessShareLink) {
 | 
			
		||||
    navigator.share({ url: this.getShareUrl(link) })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  delete(link: PaperlessShareLink) {
 | 
			
		||||
    this.shareLinkService.delete(link).subscribe({
 | 
			
		||||
      next: () => {
 | 
			
		||||
        this.refresh()
 | 
			
		||||
      },
 | 
			
		||||
      error: (e) => {
 | 
			
		||||
        this.toastService.showError($localize`Error deleting link`, 10000, e)
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  createLink() {
 | 
			
		||||
    let expiration
 | 
			
		||||
    if (this.expirationDays) {
 | 
			
		||||
      expiration = new Date()
 | 
			
		||||
      expiration.setDate(expiration.getDate() + this.expirationDays)
 | 
			
		||||
    }
 | 
			
		||||
    this.loading = true
 | 
			
		||||
    this.shareLinkService
 | 
			
		||||
      .createLinkForDocument(
 | 
			
		||||
        this._documentId,
 | 
			
		||||
        this.archiveVersion
 | 
			
		||||
          ? PaperlessFileVersion.Archive
 | 
			
		||||
          : PaperlessFileVersion.Original,
 | 
			
		||||
        expiration
 | 
			
		||||
      )
 | 
			
		||||
      .subscribe({
 | 
			
		||||
        next: (result) => {
 | 
			
		||||
          this.loading = false
 | 
			
		||||
          this.copy(result)
 | 
			
		||||
          this.refresh()
 | 
			
		||||
        },
 | 
			
		||||
        error: (e) => {
 | 
			
		||||
          this.loading = false
 | 
			
		||||
          this.toastService.showError($localize`Error creating link`, 10000, e)
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -10,6 +10,7 @@ import { ComponentFixture } from '@angular/core/testing'
 | 
			
		||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
 | 
			
		||||
import { of } from 'rxjs'
 | 
			
		||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { ClipboardService } from 'ngx-clipboard'
 | 
			
		||||
 | 
			
		||||
const toasts = [
 | 
			
		||||
  {
 | 
			
		||||
@@ -41,6 +42,7 @@ describe('ToastsComponent', () => {
 | 
			
		||||
  let component: ToastsComponent
 | 
			
		||||
  let fixture: ComponentFixture<ToastsComponent>
 | 
			
		||||
  let toastService: ToastService
 | 
			
		||||
  let clipboardService: ClipboardService
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
@@ -57,9 +59,10 @@ describe('ToastsComponent', () => {
 | 
			
		||||
    }).compileComponents()
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(ToastsComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
 | 
			
		||||
    toastService = TestBed.inject(ToastService)
 | 
			
		||||
    clipboardService = TestBed.inject(ClipboardService)
 | 
			
		||||
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
  })
 | 
			
		||||
@@ -114,7 +117,7 @@ describe('ToastsComponent', () => {
 | 
			
		||||
      'Error 2 message details'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    const copySpy = jest.spyOn(navigator.clipboard, 'writeText')
 | 
			
		||||
    const copySpy = jest.spyOn(clipboardService, 'copy')
 | 
			
		||||
    component.copyError(toasts[2].error)
 | 
			
		||||
    expect(copySpy).toHaveBeenCalled()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { Component, OnDestroy, OnInit } from '@angular/core'
 | 
			
		||||
import { Subscription } from 'rxjs'
 | 
			
		||||
import { Toast, ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { ClipboardService } from 'ngx-clipboard'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-toasts',
 | 
			
		||||
@@ -8,7 +9,10 @@ import { Toast, ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
  styleUrls: ['./toasts.component.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class ToastsComponent implements OnInit, OnDestroy {
 | 
			
		||||
  constructor(private toastService: ToastService) {}
 | 
			
		||||
  constructor(
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    private clipboardService: ClipboardService
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  private subscription: Subscription
 | 
			
		||||
 | 
			
		||||
@@ -45,7 +49,7 @@ export class ToastsComponent implements OnInit, OnDestroy {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public copyError(error: any) {
 | 
			
		||||
    navigator.clipboard.writeText(JSON.stringify(error))
 | 
			
		||||
    this.clipboardService.copy(JSON.stringify(error))
 | 
			
		||||
    this.copied = true
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.copied = false
 | 
			
		||||
 
 | 
			
		||||
@@ -5,16 +5,15 @@
 | 
			
		||||
      <div class="input-group-text" i18n>of {{previewNumPages}}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger me-2 ms-auto" (click)="delete()" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#trash" />
 | 
			
		||||
        </svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
 | 
			
		||||
    </button>
 | 
			
		||||
 | 
			
		||||
    <div class="btn-group me-2">
 | 
			
		||||
 | 
			
		||||
        <a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
            <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
            <svg class="buttonicon me-md-1" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#download" />
 | 
			
		||||
            </svg><span class="d-none d-lg-inline ps-1" i18n>Download</span>
 | 
			
		||||
        </a>
 | 
			
		||||
@@ -25,20 +24,31 @@
 | 
			
		||||
                <a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="redoOcr()" [disabled]="!userCanEdit">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
    <div ngbDropdown>
 | 
			
		||||
        <button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
 | 
			
		||||
          <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#three-dots" />
 | 
			
		||||
          </svg>
 | 
			
		||||
          <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
 | 
			
		||||
        </button>
 | 
			
		||||
        <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
 | 
			
		||||
          <button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
 | 
			
		||||
              <svg class="buttonicon-sm" fill="currentColor">
 | 
			
		||||
                  <use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
 | 
			
		||||
        </svg><span class="d-none d-lg-inline ps-1" i18n>Redo OCR</span>
 | 
			
		||||
              </svg><span class="ps-1" i18n>Redo OCR</span>
 | 
			
		||||
          </button>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="moreLike()">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
          <button ngbDropdownItem (click)="moreLike()">
 | 
			
		||||
              <svg class="buttonicon-sm" fill="currentColor">
 | 
			
		||||
                  <use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
 | 
			
		||||
        </svg><span class="d-none d-lg-inline ps-1" i18n>More like this</span>
 | 
			
		||||
              </svg><span class="ps-1" i18n>More like this</span>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <app-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></app-share-links-dropdown>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
 
 | 
			
		||||
@@ -66,6 +66,7 @@ import { TextComponent } from '../common/input/text/text.component'
 | 
			
		||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
 | 
			
		||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
 | 
			
		||||
import { DocumentDetailComponent } from './document-detail.component'
 | 
			
		||||
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
 | 
			
		||||
 | 
			
		||||
const doc: PaperlessDocument = {
 | 
			
		||||
  id: 3,
 | 
			
		||||
@@ -134,6 +135,7 @@ describe('DocumentDetailComponent', () => {
 | 
			
		||||
        ConfirmDialogComponent,
 | 
			
		||||
        PdfViewerComponent,
 | 
			
		||||
        SafeUrlPipe,
 | 
			
		||||
        ShareLinksDropdownComponent,
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [
 | 
			
		||||
        DocumentTitlePipe,
 | 
			
		||||
 
 | 
			
		||||
@@ -63,6 +63,7 @@ import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
 | 
			
		||||
import { ObjectWithId } from 'src/app/data/object-with-id'
 | 
			
		||||
import { FilterRule } from 'src/app/data/filter-rule'
 | 
			
		||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
 | 
			
		||||
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
 | 
			
		||||
 | 
			
		||||
enum DocumentDetailNavIDs {
 | 
			
		||||
  Details = 1,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								src-ui/src/app/data/paperless-share-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src-ui/src/app/data/paperless-share-link.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { ObjectWithPermissions } from './object-with-permissions'
 | 
			
		||||
 | 
			
		||||
export enum PaperlessFileVersion {
 | 
			
		||||
  Archive = 'archive',
 | 
			
		||||
  Original = 'original',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface PaperlessShareLink extends ObjectWithPermissions {
 | 
			
		||||
  created: string // Date
 | 
			
		||||
 | 
			
		||||
  expiration?: string // Date
 | 
			
		||||
 | 
			
		||||
  slug: string
 | 
			
		||||
 | 
			
		||||
  document: number // PaperlessDocument
 | 
			
		||||
 | 
			
		||||
  file_version: string
 | 
			
		||||
}
 | 
			
		||||
@@ -248,6 +248,10 @@ describe('PermissionsService', () => {
 | 
			
		||||
        'view_log',
 | 
			
		||||
        'view_comment',
 | 
			
		||||
        'change_frontendsettings',
 | 
			
		||||
        'add_sharelink',
 | 
			
		||||
        'view_sharelink',
 | 
			
		||||
        'change_sharelink',
 | 
			
		||||
        'delete_sharelink',
 | 
			
		||||
      ],
 | 
			
		||||
      {
 | 
			
		||||
        username: 'testuser',
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ export enum PermissionType {
 | 
			
		||||
  User = '%s_user',
 | 
			
		||||
  Group = '%s_group',
 | 
			
		||||
  Admin = '%s_logentry',
 | 
			
		||||
  ShareLink = '%s_sharelink',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Injectable({
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    protected http: HttpClient,
 | 
			
		||||
    private resourceName: string
 | 
			
		||||
    protected resourceName: string
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  protected getResourceUrl(id: number = null, action: string = null): string {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								src-ui/src/app/services/rest/share-link.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src-ui/src/app/services/rest/share-link.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
import { HttpTestingController } from '@angular/common/http/testing'
 | 
			
		||||
import { TestBed } from '@angular/core/testing'
 | 
			
		||||
import { Subscription } from 'rxjs'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
 | 
			
		||||
import { ShareLinkService } from './share-link.service'
 | 
			
		||||
 | 
			
		||||
let httpTestingController: HttpTestingController
 | 
			
		||||
let service: ShareLinkService
 | 
			
		||||
let subscription: Subscription
 | 
			
		||||
const endpoint = 'share_links'
 | 
			
		||||
 | 
			
		||||
// run common tests
 | 
			
		||||
commonAbstractPaperlessServiceTests(endpoint, ShareLinkService)
 | 
			
		||||
 | 
			
		||||
describe(`Additional service tests for ShareLinkService`, () => {
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    // Dont need to setup again
 | 
			
		||||
 | 
			
		||||
    httpTestingController = TestBed.inject(HttpTestingController)
 | 
			
		||||
    service = TestBed.inject(ShareLinkService)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  afterEach(() => {
 | 
			
		||||
    subscription?.unsubscribe()
 | 
			
		||||
    httpTestingController.verify()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support creating link for document', () => {
 | 
			
		||||
    subscription = service.createLinkForDocument(0).subscribe()
 | 
			
		||||
    httpTestingController
 | 
			
		||||
      .expectOne(`${environment.apiBaseUrl}${endpoint}/`)
 | 
			
		||||
      .flush({})
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support get links for a document', () => {
 | 
			
		||||
    subscription = service.getLinksForDocument(0).subscribe()
 | 
			
		||||
    httpTestingController
 | 
			
		||||
      .expectOne(`${environment.apiBaseUrl}documents/0/${endpoint}/`)
 | 
			
		||||
      .flush({})
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										36
									
								
								src-ui/src/app/services/rest/share-link.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src-ui/src/app/services/rest/share-link.service.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
import { Injectable } from '@angular/core'
 | 
			
		||||
import {
 | 
			
		||||
  PaperlessShareLink,
 | 
			
		||||
  PaperlessFileVersion,
 | 
			
		||||
} from 'src/app/data/paperless-share-link'
 | 
			
		||||
import { AbstractNameFilterService } from './abstract-name-filter-service'
 | 
			
		||||
import { HttpClient } from '@angular/common/http'
 | 
			
		||||
import { Observable } from 'rxjs'
 | 
			
		||||
 | 
			
		||||
@Injectable({
 | 
			
		||||
  providedIn: 'root',
 | 
			
		||||
})
 | 
			
		||||
export class ShareLinkService extends AbstractNameFilterService<PaperlessShareLink> {
 | 
			
		||||
  constructor(http: HttpClient) {
 | 
			
		||||
    super(http, 'share_links')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getLinksForDocument(documentId: number): Observable<PaperlessShareLink[]> {
 | 
			
		||||
    return this.http.get<PaperlessShareLink[]>(
 | 
			
		||||
      `${this.baseUrl}documents/${documentId}/${this.resourceName}/`
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  createLinkForDocument(
 | 
			
		||||
    documentId: number,
 | 
			
		||||
    file_version: PaperlessFileVersion = PaperlessFileVersion.Archive,
 | 
			
		||||
    expiration: Date = null
 | 
			
		||||
  ) {
 | 
			
		||||
    this.clearCache()
 | 
			
		||||
    return this.http.post<PaperlessShareLink>(this.getResourceUrl(), {
 | 
			
		||||
      document: documentId,
 | 
			
		||||
      file_version,
 | 
			
		||||
      expiration: expiration?.toISOString(),
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -8,6 +8,7 @@ from .models import Note
 | 
			
		||||
from .models import PaperlessTask
 | 
			
		||||
from .models import SavedView
 | 
			
		||||
from .models import SavedViewFilterRule
 | 
			
		||||
from .models import ShareLink
 | 
			
		||||
from .models import StoragePath
 | 
			
		||||
from .models import Tag
 | 
			
		||||
 | 
			
		||||
@@ -132,6 +133,12 @@ class NotesAdmin(GuardedModelAdmin):
 | 
			
		||||
    list_display_links = ("created",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ShareLinksAdmin(GuardedModelAdmin):
 | 
			
		||||
    list_display = ("created", "expiration", "document")
 | 
			
		||||
    list_filter = ("created", "expiration", "owner")
 | 
			
		||||
    list_display_links = ("created",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
admin.site.register(Correspondent, CorrespondentAdmin)
 | 
			
		||||
admin.site.register(Tag, TagAdmin)
 | 
			
		||||
admin.site.register(DocumentType, DocumentTypeAdmin)
 | 
			
		||||
@@ -140,3 +147,4 @@ admin.site.register(SavedView, SavedViewAdmin)
 | 
			
		||||
admin.site.register(StoragePath, StoragePathAdmin)
 | 
			
		||||
admin.site.register(PaperlessTask, TaskAdmin)
 | 
			
		||||
admin.site.register(Note, NotesAdmin)
 | 
			
		||||
admin.site.register(ShareLink, ShareLinksAdmin)
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ from .models import Correspondent
 | 
			
		||||
from .models import Document
 | 
			
		||||
from .models import DocumentType
 | 
			
		||||
from .models import Log
 | 
			
		||||
from .models import ShareLink
 | 
			
		||||
from .models import StoragePath
 | 
			
		||||
from .models import Tag
 | 
			
		||||
 | 
			
		||||
@@ -149,6 +150,15 @@ class StoragePathFilterSet(FilterSet):
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ShareLinkFilterSet(FilterSet):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = ShareLink
 | 
			
		||||
        fields = {
 | 
			
		||||
            "created": DATE_KWARGS,
 | 
			
		||||
            "expiration": DATE_KWARGS,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
 | 
			
		||||
    """
 | 
			
		||||
    A filter backend that limits results to those where the requesting user
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										126
									
								
								src/documents/migrations/1038_sharelink.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								src/documents/migrations/1038_sharelink.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,126 @@
 | 
			
		||||
# Generated by Django 4.1.10 on 2023-08-14 14:51
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
import django.utils.timezone
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.management import create_permissions
 | 
			
		||||
from django.contrib.auth.models import Group
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_sharelink_permissions(apps, schema_editor):
 | 
			
		||||
    # create permissions without waiting for post_migrate signal
 | 
			
		||||
    for app_config in apps.get_app_configs():
 | 
			
		||||
        app_config.models_module = True
 | 
			
		||||
        create_permissions(app_config, apps=apps, verbosity=0)
 | 
			
		||||
        app_config.models_module = None
 | 
			
		||||
 | 
			
		||||
    add_permission = Permission.objects.get(codename="add_document")
 | 
			
		||||
    sharelink_permissions = Permission.objects.filter(codename__contains="sharelink")
 | 
			
		||||
 | 
			
		||||
    for user in User.objects.filter(Q(user_permissions=add_permission)).distinct():
 | 
			
		||||
        user.user_permissions.add(*sharelink_permissions)
 | 
			
		||||
 | 
			
		||||
    for group in Group.objects.filter(Q(permissions=add_permission)).distinct():
 | 
			
		||||
        group.permissions.add(*sharelink_permissions)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def remove_sharelink_permissions(apps, schema_editor):
 | 
			
		||||
    sharelink_permissions = Permission.objects.filter(codename__contains="sharelink")
 | 
			
		||||
 | 
			
		||||
    for user in User.objects.all():
 | 
			
		||||
        user.user_permissions.remove(*sharelink_permissions)
 | 
			
		||||
 | 
			
		||||
    for group in Group.objects.all():
 | 
			
		||||
        group.permissions.remove(*sharelink_permissions)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ("documents", "1037_webp_encrypted_thumbnail_conversion"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="ShareLink",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.AutoField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        verbose_name="ID",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "created",
 | 
			
		||||
                    models.DateTimeField(
 | 
			
		||||
                        blank=True,
 | 
			
		||||
                        db_index=True,
 | 
			
		||||
                        default=django.utils.timezone.now,
 | 
			
		||||
                        editable=False,
 | 
			
		||||
                        verbose_name="created",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "expiration",
 | 
			
		||||
                    models.DateTimeField(
 | 
			
		||||
                        blank=True,
 | 
			
		||||
                        db_index=True,
 | 
			
		||||
                        null=True,
 | 
			
		||||
                        verbose_name="expiration",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "slug",
 | 
			
		||||
                    models.SlugField(
 | 
			
		||||
                        blank=True,
 | 
			
		||||
                        editable=False,
 | 
			
		||||
                        unique=True,
 | 
			
		||||
                        verbose_name="slug",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "file_version",
 | 
			
		||||
                    models.CharField(
 | 
			
		||||
                        choices=[("archive", "Archive"), ("original", "Original")],
 | 
			
		||||
                        default="archive",
 | 
			
		||||
                        max_length=50,
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "document",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        blank=True,
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        related_name="share_links",
 | 
			
		||||
                        to="documents.document",
 | 
			
		||||
                        verbose_name="document",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "owner",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        blank=True,
 | 
			
		||||
                        null=True,
 | 
			
		||||
                        on_delete=django.db.models.deletion.SET_NULL,
 | 
			
		||||
                        related_name="share_links",
 | 
			
		||||
                        to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                        verbose_name="owner",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "share link",
 | 
			
		||||
                "verbose_name_plural": "share links",
 | 
			
		||||
                "ordering": ("created",),
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(add_sharelink_permissions, remove_sharelink_permissions),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -675,3 +675,63 @@ class Note(models.Model):
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.note
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ShareLink(models.Model):
 | 
			
		||||
    class FileVersion(models.TextChoices):
 | 
			
		||||
        ARCHIVE = ("archive", _("Archive"))
 | 
			
		||||
        ORIGINAL = ("original", _("Original"))
 | 
			
		||||
 | 
			
		||||
    created = models.DateTimeField(
 | 
			
		||||
        _("created"),
 | 
			
		||||
        default=timezone.now,
 | 
			
		||||
        db_index=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        editable=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    expiration = models.DateTimeField(
 | 
			
		||||
        _("expiration"),
 | 
			
		||||
        blank=True,
 | 
			
		||||
        null=True,
 | 
			
		||||
        db_index=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    slug = models.SlugField(
 | 
			
		||||
        _("slug"),
 | 
			
		||||
        db_index=True,
 | 
			
		||||
        unique=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        editable=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    document = models.ForeignKey(
 | 
			
		||||
        Document,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        related_name="share_links",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        verbose_name=_("document"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    file_version = models.CharField(
 | 
			
		||||
        max_length=50,
 | 
			
		||||
        choices=FileVersion.choices,
 | 
			
		||||
        default=FileVersion.ARCHIVE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    owner = models.ForeignKey(
 | 
			
		||||
        User,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        null=True,
 | 
			
		||||
        related_name="share_links",
 | 
			
		||||
        on_delete=models.SET_NULL,
 | 
			
		||||
        verbose_name=_("owner"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = ("created",)
 | 
			
		||||
        verbose_name = _("share link")
 | 
			
		||||
        verbose_name_plural = _("share links")
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"Share Link for {self.document.title}"
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ from celery import states
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Group
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.utils.crypto import get_random_string
 | 
			
		||||
from django.utils.text import slugify
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from guardian.core import ObjectPermissionChecker
 | 
			
		||||
@@ -26,6 +27,7 @@ from .models import MatchingModel
 | 
			
		||||
from .models import PaperlessTask
 | 
			
		||||
from .models import SavedView
 | 
			
		||||
from .models import SavedViewFilterRule
 | 
			
		||||
from .models import ShareLink
 | 
			
		||||
from .models import StoragePath
 | 
			
		||||
from .models import Tag
 | 
			
		||||
from .models import UiSettings
 | 
			
		||||
@@ -941,3 +943,20 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer):
 | 
			
		||||
    def validate_tasks(self, tasks):
 | 
			
		||||
        self._validate_task_id_list(tasks)
 | 
			
		||||
        return tasks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ShareLinkSerializer(OwnedObjectSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = ShareLink
 | 
			
		||||
        fields = (
 | 
			
		||||
            "id",
 | 
			
		||||
            "created",
 | 
			
		||||
            "expiration",
 | 
			
		||||
            "slug",
 | 
			
		||||
            "document",
 | 
			
		||||
            "file_version",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create(self, validated_data):
 | 
			
		||||
        validated_data["slug"] = get_random_string(50)
 | 
			
		||||
        return super().create(validated_data)
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
    <meta name="description" content="Paperless-ngx Signed Out">
 | 
			
		||||
    <meta name="author" content="The Paperless-ngx Team">
 | 
			
		||||
    <meta name="author" content="Paperless-ngx project and contributors">
 | 
			
		||||
    <meta name="robots" content="noindex,nofollow">
 | 
			
		||||
 | 
			
		||||
    <title>{% translate "Paperless-ngx signed out" %}</title>
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
    <meta name="description" content="Paperless-ngx Sign In">
 | 
			
		||||
    <meta name="author" content="The Paperless-ngx Team">
 | 
			
		||||
    <meta name="author" content="Paperless-ngx project and contributors">
 | 
			
		||||
    <meta name="robots" content="noindex,nofollow">
 | 
			
		||||
 | 
			
		||||
    <title>{% translate "Paperless-ngx sign in" %}</title>
 | 
			
		||||
@@ -42,6 +42,14 @@
 | 
			
		||||
			{% if form.errors %}
 | 
			
		||||
        <div class="alert alert-danger" role="alert">
 | 
			
		||||
          {% translate "Your username and password didn't match. Please try again." %}
 | 
			
		||||
        </div>
 | 
			
		||||
      {% elif request.GET.sharelink_notfound %}
 | 
			
		||||
        <div class="alert alert-danger" role="alert">
 | 
			
		||||
          {% translate "Share link was not found." %}
 | 
			
		||||
        </div>
 | 
			
		||||
      {% elif request.GET.sharelink_expired %}
 | 
			
		||||
        <div class="alert alert-danger" role="alert">
 | 
			
		||||
          {% translate "Share link has expired." %}
 | 
			
		||||
        </div>
 | 
			
		||||
			{% endif %}
 | 
			
		||||
			{% translate "Username" as i18n_username %}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ from unittest.mock import MagicMock
 | 
			
		||||
 | 
			
		||||
import celery
 | 
			
		||||
import pytest
 | 
			
		||||
from dateutil import parser
 | 
			
		||||
from dateutil.relativedelta import relativedelta
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Group
 | 
			
		||||
@@ -37,6 +38,7 @@ from documents.models import MatchingModel
 | 
			
		||||
from documents.models import Note
 | 
			
		||||
from documents.models import PaperlessTask
 | 
			
		||||
from documents.models import SavedView
 | 
			
		||||
from documents.models import ShareLink
 | 
			
		||||
from documents.models import StoragePath
 | 
			
		||||
from documents.models import Tag
 | 
			
		||||
from documents.tests.utils import DirectoriesMixin
 | 
			
		||||
@@ -2558,6 +2560,119 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
 | 
			
		||||
    def test_create_share_links(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - Existing document
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - API request is made to generate a share_link
 | 
			
		||||
            - API request is made to view share_links on incorrect doc pk
 | 
			
		||||
            - Invalid method request is made to view share_links doc
 | 
			
		||||
        THEN:
 | 
			
		||||
            - Link is created with a slug and associated with document
 | 
			
		||||
            - 404
 | 
			
		||||
            - Error
 | 
			
		||||
        """
 | 
			
		||||
        doc = Document.objects.create(
 | 
			
		||||
            title="test",
 | 
			
		||||
            mime_type="application/pdf",
 | 
			
		||||
            content="this is a document which will have notes added",
 | 
			
		||||
        )
 | 
			
		||||
        # never expires
 | 
			
		||||
        resp = self.client.post(
 | 
			
		||||
            "/api/share_links/",
 | 
			
		||||
            data={
 | 
			
		||||
                "document": doc.pk,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
 | 
			
		||||
 | 
			
		||||
        resp = self.client.post(
 | 
			
		||||
            "/api/share_links/",
 | 
			
		||||
            data={
 | 
			
		||||
                "expiration": (timezone.now() + timedelta(days=7)).isoformat(),
 | 
			
		||||
                "document": doc.pk,
 | 
			
		||||
                "file_version": "original",
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"/api/documents/{doc.pk}/share_links/",
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
 | 
			
		||||
        resp_data = response.json()
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(resp_data), 2)
 | 
			
		||||
 | 
			
		||||
        self.assertGreater(len(resp_data[1]["slug"]), 0)
 | 
			
		||||
        self.assertIsNone(resp_data[1]["expiration"])
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            (parser.isoparse(resp_data[0]["expiration"]) - timezone.now()).days,
 | 
			
		||||
            6,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        sl1 = ShareLink.objects.get(slug=resp_data[1]["slug"])
 | 
			
		||||
        self.assertEqual(str(sl1), f"Share Link for {doc.title}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            f"/api/documents/{doc.pk}/share_links/",
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            "/api/documents/99/share_links/",
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 | 
			
		||||
 | 
			
		||||
    def test_share_links_permissions_aware(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - Existing document owned by user2 but with granted view perms for user1
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - API request is made by user1 to view share links
 | 
			
		||||
        THEN:
 | 
			
		||||
            - Links only shown if user has permissions
 | 
			
		||||
        """
 | 
			
		||||
        user1 = User.objects.create_user(username="test1")
 | 
			
		||||
        user1.user_permissions.add(*Permission.objects.all())
 | 
			
		||||
        user1.save()
 | 
			
		||||
 | 
			
		||||
        user2 = User.objects.create_user(username="test2")
 | 
			
		||||
        user2.save()
 | 
			
		||||
 | 
			
		||||
        doc = Document.objects.create(
 | 
			
		||||
            title="test",
 | 
			
		||||
            mime_type="application/pdf",
 | 
			
		||||
            content="this is a document which will have share links added",
 | 
			
		||||
        )
 | 
			
		||||
        doc.owner = user2
 | 
			
		||||
        doc.save()
 | 
			
		||||
 | 
			
		||||
        self.client.force_authenticate(user1)
 | 
			
		||||
 | 
			
		||||
        resp = self.client.get(
 | 
			
		||||
            f"/api/documents/{doc.pk}/share_links/",
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(resp.content, b"Insufficient permissions")
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
 | 
			
		||||
 | 
			
		||||
        assign_perm("change_document", user1, doc)
 | 
			
		||||
 | 
			
		||||
        resp = self.client.get(
 | 
			
		||||
            f"/api/documents/{doc.pk}/share_links/",
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_200_OK)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 | 
			
		||||
 | 
			
		||||
        manifest = self._do_export(use_filename_format=use_filename_format)
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(manifest), 149)
 | 
			
		||||
        self.assertEqual(len(manifest), 154)
 | 
			
		||||
 | 
			
		||||
        # dont include consumer or AnonymousUser users
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
@@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 | 
			
		||||
            self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
 | 
			
		||||
            self.assertEqual(GroupObjectPermission.objects.count(), 1)
 | 
			
		||||
            self.assertEqual(UserObjectPermission.objects.count(), 1)
 | 
			
		||||
            self.assertEqual(Permission.objects.count(), 108)
 | 
			
		||||
            self.assertEqual(Permission.objects.count(), 112)
 | 
			
		||||
            messages = check_sanity()
 | 
			
		||||
            # everything is alright after the test
 | 
			
		||||
            self.assertEqual(len(messages), 0)
 | 
			
		||||
@@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 | 
			
		||||
            os.path.join(self.dirs.media_dir, "documents"),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(ContentType.objects.count(), 27)
 | 
			
		||||
        self.assertEqual(Permission.objects.count(), 108)
 | 
			
		||||
        self.assertEqual(ContentType.objects.count(), 28)
 | 
			
		||||
        self.assertEqual(Permission.objects.count(), 112)
 | 
			
		||||
 | 
			
		||||
        manifest = self._do_export()
 | 
			
		||||
 | 
			
		||||
        with paperless_environment():
 | 
			
		||||
            self.assertEqual(
 | 
			
		||||
                len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
 | 
			
		||||
                108,
 | 
			
		||||
                112,
 | 
			
		||||
            )
 | 
			
		||||
            # add 1 more to db to show objects are not re-created by import
 | 
			
		||||
            Permission.objects.create(
 | 
			
		||||
@@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 | 
			
		||||
                codename="test_perm",
 | 
			
		||||
                content_type_id=1,
 | 
			
		||||
            )
 | 
			
		||||
            self.assertEqual(Permission.objects.count(), 109)
 | 
			
		||||
            self.assertEqual(Permission.objects.count(), 113)
 | 
			
		||||
 | 
			
		||||
            # will cause an import error
 | 
			
		||||
            self.user.delete()
 | 
			
		||||
@@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 | 
			
		||||
            with self.assertRaises(IntegrityError):
 | 
			
		||||
                call_command("document_importer", "--no-progress-bar", self.target)
 | 
			
		||||
 | 
			
		||||
            self.assertEqual(ContentType.objects.count(), 27)
 | 
			
		||||
            self.assertEqual(Permission.objects.count(), 109)
 | 
			
		||||
            self.assertEqual(ContentType.objects.count(), 28)
 | 
			
		||||
            self.assertEqual(Permission.objects.count(), 113)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +1,23 @@
 | 
			
		||||
import shutil
 | 
			
		||||
import os
 | 
			
		||||
import tempfile
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test import override_settings
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
 | 
			
		||||
from documents.models import Document
 | 
			
		||||
from documents.models import ShareLink
 | 
			
		||||
from documents.tests.utils import DirectoriesMixin
 | 
			
		||||
 | 
			
		||||
class TestViews(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpClass(cls):
 | 
			
		||||
        # Provide a dummy static dir to silence whitenoise warnings
 | 
			
		||||
        cls.static_dir = tempfile.mkdtemp()
 | 
			
		||||
 | 
			
		||||
        cls.override = override_settings(
 | 
			
		||||
            STATIC_ROOT=cls.static_dir,
 | 
			
		||||
        )
 | 
			
		||||
        cls.override.enable()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def tearDownClass(cls):
 | 
			
		||||
        shutil.rmtree(cls.static_dir, ignore_errors=True)
 | 
			
		||||
        cls.override.disable()
 | 
			
		||||
 | 
			
		||||
class TestViews(DirectoriesMixin, TestCase):
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        self.user = User.objects.create_user("testuser")
 | 
			
		||||
        super().setUp()
 | 
			
		||||
 | 
			
		||||
    def test_login_redirect(self):
 | 
			
		||||
        response = self.client.get("/")
 | 
			
		||||
@@ -74,3 +66,69 @@ class TestViews(TestCase):
 | 
			
		||||
                response.context_data["main_js"],
 | 
			
		||||
                f"frontend/{language_actual}/main.js",
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def test_share_link_views(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - Share link created
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - Valid request for share link is made
 | 
			
		||||
            - Invalid request for share link is made
 | 
			
		||||
            - Request for expired share link is made
 | 
			
		||||
        THEN:
 | 
			
		||||
            - Document is returned without need for login
 | 
			
		||||
            - User is redirected to login with error
 | 
			
		||||
            - User is redirected to login with error
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        _, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
 | 
			
		||||
 | 
			
		||||
        content = b"This is a test"
 | 
			
		||||
 | 
			
		||||
        with open(filename, "wb") as f:
 | 
			
		||||
            f.write(content)
 | 
			
		||||
 | 
			
		||||
        doc = Document.objects.create(
 | 
			
		||||
            title="none",
 | 
			
		||||
            filename=os.path.basename(filename),
 | 
			
		||||
            mime_type="application/pdf",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        sharelink_permissions = Permission.objects.filter(
 | 
			
		||||
            codename__contains="sharelink",
 | 
			
		||||
        )
 | 
			
		||||
        self.user.user_permissions.add(*sharelink_permissions)
 | 
			
		||||
        self.user.save()
 | 
			
		||||
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
        self.client.post(
 | 
			
		||||
            "/api/share_links/",
 | 
			
		||||
            {
 | 
			
		||||
                "document": doc.pk,
 | 
			
		||||
                "file_version": "original",
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        sl1 = ShareLink.objects.get(document=doc)
 | 
			
		||||
 | 
			
		||||
        self.client.logout()
 | 
			
		||||
 | 
			
		||||
        # Valid
 | 
			
		||||
        response = self.client.get(f"/share/{sl1.slug}")
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
        self.assertEqual(response.content, content)
 | 
			
		||||
 | 
			
		||||
        # Invalid
 | 
			
		||||
        response = self.client.get("/share/123notaslug", follow=True)
 | 
			
		||||
        response.render()
 | 
			
		||||
        self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
 | 
			
		||||
        self.assertContains(response, b"Share link was not found")
 | 
			
		||||
 | 
			
		||||
        # Expired
 | 
			
		||||
        sl1.expiration = timezone.now() - timedelta(days=1)
 | 
			
		||||
        sl1.save()
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(f"/share/{sl1.slug}", follow=True)
 | 
			
		||||
        response.render()
 | 
			
		||||
        self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
 | 
			
		||||
        self.assertContains(response, b"Share link has expired")
 | 
			
		||||
 
 | 
			
		||||
@@ -27,9 +27,12 @@ from django.http import Http404
 | 
			
		||||
from django.http import HttpResponse
 | 
			
		||||
from django.http import HttpResponseBadRequest
 | 
			
		||||
from django.http import HttpResponseForbidden
 | 
			
		||||
from django.http import HttpResponseRedirect
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.decorators import method_decorator
 | 
			
		||||
from django.utils.translation import get_language
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.decorators.cache import cache_control
 | 
			
		||||
from django.views.generic import TemplateView
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
@@ -75,6 +78,7 @@ from .data_models import DocumentSource
 | 
			
		||||
from .filters import CorrespondentFilterSet
 | 
			
		||||
from .filters import DocumentFilterSet
 | 
			
		||||
from .filters import DocumentTypeFilterSet
 | 
			
		||||
from .filters import ShareLinkFilterSet
 | 
			
		||||
from .filters import StoragePathFilterSet
 | 
			
		||||
from .filters import TagFilterSet
 | 
			
		||||
from .matching import match_correspondents
 | 
			
		||||
@@ -87,6 +91,7 @@ from .models import DocumentType
 | 
			
		||||
from .models import Note
 | 
			
		||||
from .models import PaperlessTask
 | 
			
		||||
from .models import SavedView
 | 
			
		||||
from .models import ShareLink
 | 
			
		||||
from .models import StoragePath
 | 
			
		||||
from .models import Tag
 | 
			
		||||
from .parsers import get_parser_class_for_mime_type
 | 
			
		||||
@@ -100,6 +105,7 @@ from .serialisers import DocumentSerializer
 | 
			
		||||
from .serialisers import DocumentTypeSerializer
 | 
			
		||||
from .serialisers import PostDocumentSerializer
 | 
			
		||||
from .serialisers import SavedViewSerializer
 | 
			
		||||
from .serialisers import ShareLinkSerializer
 | 
			
		||||
from .serialisers import StoragePathSerializer
 | 
			
		||||
from .serialisers import TagSerializer
 | 
			
		||||
from .serialisers import TagSerializerVersion1
 | 
			
		||||
@@ -312,38 +318,12 @@ class DocumentViewSet(
 | 
			
		||||
            doc,
 | 
			
		||||
        ):
 | 
			
		||||
            return HttpResponseForbidden("Insufficient permissions")
 | 
			
		||||
        if not self.original_requested(request) and doc.has_archive_version:
 | 
			
		||||
            file_handle = doc.archive_file
 | 
			
		||||
            filename = doc.get_public_filename(archive=True)
 | 
			
		||||
            mime_type = "application/pdf"
 | 
			
		||||
        else:
 | 
			
		||||
            file_handle = doc.source_file
 | 
			
		||||
            filename = doc.get_public_filename()
 | 
			
		||||
            mime_type = doc.mime_type
 | 
			
		||||
            # Support browser previewing csv files by using text mime type
 | 
			
		||||
            if mime_type in {"application/csv", "text/csv"} and disposition == "inline":
 | 
			
		||||
                mime_type = "text/plain"
 | 
			
		||||
 | 
			
		||||
        if doc.storage_type == Document.STORAGE_TYPE_GPG:
 | 
			
		||||
            file_handle = GnuPG.decrypted(file_handle)
 | 
			
		||||
 | 
			
		||||
        response = HttpResponse(file_handle, content_type=mime_type)
 | 
			
		||||
        # Firefox is not able to handle unicode characters in filename field
 | 
			
		||||
        # RFC 5987 addresses this issue
 | 
			
		||||
        # see https://datatracker.ietf.org/doc/html/rfc5987#section-4.2
 | 
			
		||||
        # Chromium cannot handle commas in the filename
 | 
			
		||||
        filename_normalized = normalize("NFKD", filename.replace(",", "_")).encode(
 | 
			
		||||
            "ascii",
 | 
			
		||||
            "ignore",
 | 
			
		||||
        return serve_file(
 | 
			
		||||
            doc=doc,
 | 
			
		||||
            use_archive=not self.original_requested(request)
 | 
			
		||||
            and doc.has_archive_version,
 | 
			
		||||
            disposition=disposition,
 | 
			
		||||
        )
 | 
			
		||||
        filename_encoded = quote(filename)
 | 
			
		||||
        content_disposition = (
 | 
			
		||||
            f"{disposition}; "
 | 
			
		||||
            f'filename="{filename_normalized}"; '
 | 
			
		||||
            f"filename*=utf-8''{filename_encoded}"
 | 
			
		||||
        )
 | 
			
		||||
        response["Content-Disposition"] = content_disposition
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    def get_metadata(self, file, mime_type):
 | 
			
		||||
        if not os.path.isfile(file):
 | 
			
		||||
@@ -574,6 +554,35 @@ class DocumentViewSet(
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @action(methods=["get"], detail=True)
 | 
			
		||||
    def share_links(self, request, pk=None):
 | 
			
		||||
        currentUser = request.user
 | 
			
		||||
        try:
 | 
			
		||||
            doc = Document.objects.get(pk=pk)
 | 
			
		||||
            if currentUser is not None and not has_perms_owner_aware(
 | 
			
		||||
                currentUser,
 | 
			
		||||
                "change_document",
 | 
			
		||||
                doc,
 | 
			
		||||
            ):
 | 
			
		||||
                return HttpResponseForbidden("Insufficient permissions")
 | 
			
		||||
        except Document.DoesNotExist:
 | 
			
		||||
            raise Http404
 | 
			
		||||
 | 
			
		||||
        if request.method == "GET":
 | 
			
		||||
            now = timezone.now()
 | 
			
		||||
            links = [
 | 
			
		||||
                {
 | 
			
		||||
                    "id": c.id,
 | 
			
		||||
                    "created": c.created,
 | 
			
		||||
                    "expiration": c.expiration,
 | 
			
		||||
                    "slug": c.slug,
 | 
			
		||||
                }
 | 
			
		||||
                for c in ShareLink.objects.filter(document=doc)
 | 
			
		||||
                .exclude(expiration__lt=now)
 | 
			
		||||
                .order_by("-created")
 | 
			
		||||
            ]
 | 
			
		||||
            return Response(links)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SearchResultSerializer(DocumentSerializer, PassUserMixin):
 | 
			
		||||
    def to_representation(self, instance):
 | 
			
		||||
@@ -1127,3 +1136,72 @@ class AcknowledgeTasksView(GenericAPIView):
 | 
			
		||||
            return Response({"result": result})
 | 
			
		||||
        except Exception:
 | 
			
		||||
            return HttpResponseBadRequest()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
 | 
			
		||||
    model = ShareLink
 | 
			
		||||
 | 
			
		||||
    queryset = ShareLink.objects.all()
 | 
			
		||||
 | 
			
		||||
    serializer_class = ShareLinkSerializer
 | 
			
		||||
    pagination_class = StandardPagination
 | 
			
		||||
    permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
 | 
			
		||||
    filter_backends = (
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        OrderingFilter,
 | 
			
		||||
        ObjectOwnedOrGrantedPermissionsFilter,
 | 
			
		||||
    )
 | 
			
		||||
    filterset_class = ShareLinkFilterSet
 | 
			
		||||
    ordering_fields = ("created", "expiration", "document")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SharedLinkView(View):
 | 
			
		||||
    authentication_classes = []
 | 
			
		||||
    permission_classes = []
 | 
			
		||||
 | 
			
		||||
    def get(self, request, slug):
 | 
			
		||||
        share_link = ShareLink.objects.filter(slug=slug).first()
 | 
			
		||||
        if share_link is None:
 | 
			
		||||
            return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
 | 
			
		||||
        if share_link.expiration is not None and share_link.expiration < timezone.now():
 | 
			
		||||
            return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
 | 
			
		||||
        return serve_file(
 | 
			
		||||
            doc=share_link.document,
 | 
			
		||||
            use_archive=share_link.file_version == "archive",
 | 
			
		||||
            disposition="inline",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def serve_file(doc: Document, use_archive: bool, disposition: str):
 | 
			
		||||
    if use_archive:
 | 
			
		||||
        file_handle = doc.archive_file
 | 
			
		||||
        filename = doc.get_public_filename(archive=True)
 | 
			
		||||
        mime_type = "application/pdf"
 | 
			
		||||
    else:
 | 
			
		||||
        file_handle = doc.source_file
 | 
			
		||||
        filename = doc.get_public_filename()
 | 
			
		||||
        mime_type = doc.mime_type
 | 
			
		||||
        # Support browser previewing csv files by using text mime type
 | 
			
		||||
        if mime_type in {"application/csv", "text/csv"} and disposition == "inline":
 | 
			
		||||
            mime_type = "text/plain"
 | 
			
		||||
 | 
			
		||||
    if doc.storage_type == Document.STORAGE_TYPE_GPG:
 | 
			
		||||
        file_handle = GnuPG.decrypted(file_handle)
 | 
			
		||||
 | 
			
		||||
    response = HttpResponse(file_handle, content_type=mime_type)
 | 
			
		||||
    # Firefox is not able to handle unicode characters in filename field
 | 
			
		||||
    # RFC 5987 addresses this issue
 | 
			
		||||
    # see https://datatracker.ietf.org/doc/html/rfc5987#section-4.2
 | 
			
		||||
    # Chromium cannot handle commas in the filename
 | 
			
		||||
    filename_normalized = normalize("NFKD", filename.replace(",", "_")).encode(
 | 
			
		||||
        "ascii",
 | 
			
		||||
        "ignore",
 | 
			
		||||
    )
 | 
			
		||||
    filename_encoded = quote(filename)
 | 
			
		||||
    content_disposition = (
 | 
			
		||||
        f"{disposition}; "
 | 
			
		||||
        f'filename="{filename_normalized}"; '
 | 
			
		||||
        f"filename*=utf-8''{filename_encoded}"
 | 
			
		||||
    )
 | 
			
		||||
    response["Content-Disposition"] = content_disposition
 | 
			
		||||
    return response
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -22,6 +22,8 @@ from documents.views import RemoteVersionView
 | 
			
		||||
from documents.views import SavedViewViewSet
 | 
			
		||||
from documents.views import SearchAutoCompleteView
 | 
			
		||||
from documents.views import SelectionDataView
 | 
			
		||||
from documents.views import SharedLinkView
 | 
			
		||||
from documents.views import ShareLinkViewSet
 | 
			
		||||
from documents.views import StatisticsView
 | 
			
		||||
from documents.views import StoragePathViewSet
 | 
			
		||||
from documents.views import TagViewSet
 | 
			
		||||
@@ -49,6 +51,7 @@ api_router.register(r"users", UserViewSet, basename="users")
 | 
			
		||||
api_router.register(r"groups", GroupViewSet, basename="groups")
 | 
			
		||||
api_router.register(r"mail_accounts", MailAccountViewSet)
 | 
			
		||||
api_router.register(r"mail_rules", MailRuleViewSet)
 | 
			
		||||
api_router.register(r"share_links", ShareLinkViewSet)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
@@ -110,6 +113,7 @@ urlpatterns = [
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ),
 | 
			
		||||
    re_path(r"share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
 | 
			
		||||
    re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"),
 | 
			
		||||
    re_path(r"admin/", admin.site.urls),
 | 
			
		||||
    re_path(
 | 
			
		||||
@@ -155,7 +159,7 @@ urlpatterns = [
 | 
			
		||||
    # TODO: with localization, this is even worse! :/
 | 
			
		||||
    # login, logout
 | 
			
		||||
    path("accounts/", include("django.contrib.auth.urls")),
 | 
			
		||||
    # Root of the Frontent
 | 
			
		||||
    # Root of the Frontend
 | 
			
		||||
    re_path(r".*", login_required(IndexView.as_view()), name="base"),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user