]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: global search, keyboard shortcuts / hotkey support (#6449)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Thu, 2 May 2024 16:15:56 +0000 (09:15 -0700)
committerGitHub <noreply@github.com>
Thu, 2 May 2024 16:15:56 +0000 (16:15 +0000)
51 files changed:
docs/api.md
docs/usage.md
src-ui/e2e/document-list/document-list.spec.ts
src-ui/messages.xlf
src-ui/setup-jest.ts
src-ui/src/app/app.component.spec.ts
src-ui/src/app/app.component.ts
src-ui/src/app/app.module.ts
src-ui/src/app/components/admin/settings/settings.component.html
src-ui/src/app/components/admin/settings/settings.component.spec.ts
src-ui/src/app/components/admin/settings/settings.component.ts
src-ui/src/app/components/app-frame/app-frame.component.html
src-ui/src/app/components/app-frame/app-frame.component.scss
src-ui/src/app/components/app-frame/app-frame.component.spec.ts
src-ui/src/app/components/app-frame/app-frame.component.ts
src-ui/src/app/components/app-frame/global-search/global-search.component.html [new file with mode: 0644]
src-ui/src/app/components/app-frame/global-search/global-search.component.scss [new file with mode: 0644]
src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/app-frame/global-search/global-search.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.html [new file with mode: 0644]
src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts [new file with mode: 0644]
src-ui/src/app/components/document-detail/document-detail.component.spec.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
src-ui/src/app/components/document-list/document-list.component.html
src-ui/src/app/components/document-list/document-list.component.spec.ts
src-ui/src/app/components/document-list/document-list.component.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
src-ui/src/app/data/datatype.ts [new file with mode: 0644]
src-ui/src/app/data/filter-rule-type.ts
src-ui/src/app/data/ui-settings.ts
src-ui/src/app/services/hot-key.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/hot-key.service.ts [new file with mode: 0644]
src-ui/src/app/services/open-documents.service.spec.ts
src-ui/src/app/services/open-documents.service.ts
src-ui/src/app/services/rest/search.service.spec.ts
src-ui/src/app/services/rest/search.service.ts
src-ui/src/styles.scss
src-ui/src/theme.scss
src/documents/serialisers.py
src/documents/tests/test_api_search.py
src/documents/views.py
src/locale/en_US/LC_MESSAGES/django.po
src/paperless/urls.py

index 6a275be619a7734db25d5cbe0a75cdfb23f7b66b..83193f0257d6a5a52df57827fc04594006d2f2a8 100644 (file)
@@ -11,7 +11,7 @@ The API provides the following main endpoints:
 - `/api/correspondents/`: Full CRUD support.
 - `/api/custom_fields/`: Full CRUD support.
 - `/api/documents/`: Full CRUD support, except POSTing new documents.
-  See below.
+  See [below](#posting-documents-file-uploads).
 - `/api/document_types/`: Full CRUD support.
 - `/api/groups/`: Full CRUD support.
 - `/api/logs/`: Read-Only.
@@ -24,6 +24,7 @@ The API provides the following main endpoints:
 - `/api/tasks/`: Read-only.
 - `/api/users/`: Full CRUD support.
 - `/api/workflows/`: Full CRUD support.
+- `/api/search/` GET, see [below](#global-search).
 
 All of these endpoints except for the logging endpoint allow you to
 fetch (and edit and delete where appropriate) individual objects by
@@ -188,6 +189,38 @@ The REST api provides four different forms of authentication.
     [configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
     you can authenticate against the API using Remote User auth.
 
+## Global search
+
+A global search endpoint is available at `/api/search/` and requires a search term
+of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
+across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
+Results are only included if the requesting user has the appropriate permissions.
+
+Results are returned in the following format:
+
+```json
+{
+  total: number
+  documents: []
+  saved_views: []
+  correspondents: []
+  document_types: []
+  storage_paths: []
+  tags: []
+  users: []
+  groups: []
+  mail_accounts: []
+  mail_rules: []
+  custom_fields: []
+  workflows: []
+}
+```
+
+Global search first searches objects by name (or title for documents) matching the query.
+If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
+if the amount of documents returned by a simple title string search is < 3, results from the
+search index will also be included.
+
 ## Searching for documents
 
 Full text searching is available on the `/api/documents/` endpoint. Two
index c9003d35d2bf5ccbfc49588cc408f8997a9e7274..f11d50a03cf73b38da9f2a4e12459b98dc4ba28d 100644 (file)
@@ -550,6 +550,16 @@ collection.
 
 ## Searching {#basic-usage_searching}
 
+### Global search
+
+The top search bar in the web UI performs a "global" search of the various
+objects Paperless-ngx uses, including documents, tags, workflows, etc. Only
+objects for which the user has appropriate permissions are returned. For
+documents, if there are < 3 results, "advanced" search results (which use
+the document index) will also be included. This can be disabled under settings.
+
+### Document searches
+
 Paperless offers an extensive searching mechanism that is designed to
 allow you to quickly find a document you're looking for (for example,
 that thing that just broke and you bought a couple months ago, that
@@ -605,6 +615,12 @@ language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
 details on what date parsing utilities are available, see [Date
 parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
 
+## Keyboard shortcuts / hotkeys
+
+A list of available hotkeys can be shown on any page using <kbd>Shift</kbd> +
+<kbd>?</kbd>. The help dialog shows only the keys that are currently available
+based on which area of Paperless-ngx you are using.
+
 ## The recommended workflow {#usage-recommended-workflow}
 
 Once you have familiarized yourself with paperless and are ready to use
index da2454e7f6b02baa0802dec61cb17d89815b19e0..5dea9985e92d64811a6a659111cfdde35f50ab0f 100644 (file)
@@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
 test('text filtering', async ({ page }) => {
   await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
   await page.goto('/documents')
-  await page.getByRole('textbox').click()
-  await page.getByRole('textbox').fill('test')
+  await page.getByRole('main').getByRole('combobox').click()
+  await page.getByRole('main').getByRole('combobox').fill('test')
   await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
   await expect(page).toHaveURL(/title_content=test/)
   await page.getByRole('button', { name: 'Title & content' }).click()
@@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => {
   await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
   await page.getByRole('button', { name: 'Advanced search' }).click()
   await page.getByRole('button', { name: 'ASN' }).click()
-  await page.getByRole('textbox').fill('1123')
+  await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
   await expect(page).toHaveURL(/archive_serial_number=1123/)
   await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
   await page.locator('select').selectOption('greater')
-  await page.getByRole('textbox').click()
-  await page.getByRole('textbox').fill('1123')
+  await page.getByRole('main').getByRole('combobox').nth(1).click()
+  await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
   await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
   await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
   await page.locator('select').selectOption('less')
index bdc80313236e56317d923197f5f29586ea7a3099..81087ab3e5c2f7648f5214f60fc1bf65d586a310 100644 (file)
         <source>Document <x id="PH" equiv-text="status.filename"/> was added to Paperless-ngx.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">81</context>
+          <context context-type="linenumber">83</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">90</context>
+          <context context-type="linenumber">92</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1931214133925051574" datatype="html">
         <source>Open document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">83</context>
+          <context context-type="linenumber">85</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
         <source>Could not add <x id="PH" equiv-text="status.filename"/>: <x id="PH_1" equiv-text="status.message"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">107</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1218124467712564468" datatype="html">
         <source>Document <x id="PH" equiv-text="status.filename"/> is being processed by Paperless-ngx.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">120</context>
+          <context context-type="linenumber">122</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6570363013146073520" datatype="html">
+        <source>Dashboard</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/app.component.ts</context>
+          <context context-type="linenumber">129</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">81</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">83</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4733307402565258070" datatype="html">
+        <source>Documents</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/app.component.ts</context>
+          <context context-type="linenumber">140</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">88</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">90</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
+          <context context-type="linenumber">128</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">90</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">90</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">90</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">90</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4930506384627295710" datatype="html">
+        <source>Settings</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/app.component.ts</context>
+          <context context-type="linenumber">152</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">323</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">50</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">228</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">230</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2501522447884928778" datatype="html">
         <source>Prev</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">126</context>
+          <context context-type="linenumber">158</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3885497195825665706" datatype="html">
         <source>Next</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">127</context>
+          <context context-type="linenumber">159</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
         <source>End</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">128</context>
+          <context context-type="linenumber">160</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3909462337752654810" datatype="html">
         <source>The dashboard can be used to show saved views, such as an &apos;Inbox&apos;. Those settings are found under Settings &gt; Saved Views once you have created some.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">134</context>
+          <context context-type="linenumber">166</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9075755296812854717" datatype="html">
         <source>Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">141</context>
+          <context context-type="linenumber">173</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7495498057594070122" datatype="html">
         <source>The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">146</context>
+          <context context-type="linenumber">178</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1334220418719920556" datatype="html">
         <source>The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">153</context>
+          <context context-type="linenumber">185</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5427326625898532358" datatype="html">
         <source>Any combination of filters can be saved as a &apos;view&apos; which can then be displayed on the dashboard and / or sidebar.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">159</context>
+          <context context-type="linenumber">191</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2804886236408698479" datatype="html">
         <source>Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">164</context>
+          <context context-type="linenumber">196</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7851939076947092983" datatype="html">
         <source>Manage e-mail accounts and rules for automatically importing documents.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">172</context>
+          <context context-type="linenumber">204</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
         <source>Workflows give you more control over the document pipeline.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">180</context>
+          <context context-type="linenumber">212</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4680387114119209483" datatype="html">
         <source>File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">188</context>
+          <context context-type="linenumber">220</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
         <source>Check out the settings for various tweaks to the web app and toggle settings for saved views.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">196</context>
+          <context context-type="linenumber">228</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7172877665285340082" datatype="html">
         <source>Thank you! 🙏</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">236</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7354947513482088740" datatype="html">
         <source>There are &lt;em&gt;tons&lt;/em&gt; more features and info we didn&apos;t cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">206</context>
+          <context context-type="linenumber">238</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4270528545616947218" datatype="html">
         <source>Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/app.component.ts</context>
-          <context context-type="linenumber">208</context>
+          <context context-type="linenumber">240</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9063918187161876141" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">395</context>
+          <context context-type="linenumber">403</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">272</context>
+          <context context-type="linenumber">263</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">275</context>
+          <context context-type="linenumber">266</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2272120016352772836" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">383</context>
+          <context context-type="linenumber">391</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
           <context context-type="linenumber">51</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="4930506384627295710" datatype="html">
-        <source>Settings</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">2</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">315</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">59</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">237</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">239</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="4999473193657330663" datatype="html">
         <source>Options to customize appearance, notifications, saved views and more. Settings apply to the &lt;strong&gt;current user only&lt;/strong&gt;.</source>
         <context-group purpose="location">
           <context context-type="linenumber">196</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="6760166989231109310" datatype="html">
+        <source>Global search</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">200</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
+          <context context-type="linenumber">92</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1926290004382723170" datatype="html">
+        <source>Search database only (do not include advanced search results)</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">204</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="8104421162933956065" datatype="html">
         <source>Notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">200</context>
+          <context context-type="linenumber">208</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         <source>Enable notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">212</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7314814725704332646" datatype="html">
         <source>Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">212</context>
+          <context context-type="linenumber">220</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</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">96</context>
+          <context context-type="linenumber">100</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">96</context>
+          <context context-type="linenumber">110</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
         <source>Default Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">215</context>
+          <context context-type="linenumber">223</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8222269449891326545" datatype="html">
         <source> Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI </source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">219,221</context>
+          <context context-type="linenumber">227,229</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4292903881380648974" datatype="html">
         <source>Default Owner</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">226</context>
+          <context context-type="linenumber">234</context>
         </context-group>
       </trans-unit>
       <trans-unit id="734147282056744882" datatype="html">
         <source>Objects without an owner can be viewed and edited by all users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">230</context>
+          <context context-type="linenumber">238</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
         <source>Default View Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">235</context>
+          <context context-type="linenumber">243</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2191775412581217688" datatype="html">
         <source>Users:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">240</context>
+          <context context-type="linenumber">248</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">267</context>
+          <context context-type="linenumber">275</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         <source>Groups:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">250</context>
+          <context context-type="linenumber">258</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">277</context>
+          <context context-type="linenumber">285</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         <source>Default Edit Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">262</context>
+          <context context-type="linenumber">270</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3728984448750213892" datatype="html">
         <source>Edit permissions also grant viewing permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">286</context>
+          <context context-type="linenumber">294</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         <source>Notifications</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">294</context>
+          <context context-type="linenumber">302</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8545554728558600606" datatype="html">
         <source>Document processing</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">297</context>
+          <context context-type="linenumber">305</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3656786776644872398" datatype="html">
         <source>Show notifications when new documents are detected</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">301</context>
+          <context context-type="linenumber">309</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6057053428592387613" datatype="html">
         <source>Show notifications when document processing completes successfully</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">302</context>
+          <context context-type="linenumber">310</context>
         </context-group>
       </trans-unit>
       <trans-unit id="370315664367425513" datatype="html">
         <source>Show notifications when document processing fails</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">303</context>
+          <context context-type="linenumber">311</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6838309441164918531" datatype="html">
         <source>Suppress notifications on dashboard</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">304</context>
+          <context context-type="linenumber">312</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2741919327232918179" datatype="html">
         <source>This will suppress all messages about document processing status on the dashboard.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">304</context>
+          <context context-type="linenumber">312</context>
         </context-group>
       </trans-unit>
       <trans-unit id="472206565520537964" datatype="html">
         <source>Saved views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">312</context>
+          <context context-type="linenumber">320</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">107</context>
+          <context context-type="linenumber">98</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1595668988802980095" datatype="html">
         <source>Show warning when closing saved views with unsaved changes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">318</context>
+          <context context-type="linenumber">326</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2123659921722214537" datatype="html">
         <source>Views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">322</context>
+          <context context-type="linenumber">330</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         <source>Show on dashboard</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">335</context>
+          <context context-type="linenumber">343</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>
         <source>Show in sidebar</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">339</context>
+          <context context-type="linenumber">347</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>
         <source>Actions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">343</context>
+          <context context-type="linenumber">351</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</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">102</context>
+          <context context-type="linenumber">106</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
         <source>Delete</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">345</context>
+          <context context-type="linenumber">353</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</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">156</context>
+          <context context-type="linenumber">160</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
         <source>Documents page size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">356</context>
+          <context context-type="linenumber">364</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4729108320774167624" datatype="html">
         <source>Display as</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">359</context>
+          <context context-type="linenumber">367</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1358239534403218079" datatype="html">
         <source>Table</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">361</context>
+          <context context-type="linenumber">369</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4236040382842528005" datatype="html">
         <source>Small Cards</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">362</context>
+          <context context-type="linenumber">370</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8569593958139569111" datatype="html">
         <source>Large Cards</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">363</context>
+          <context context-type="linenumber">371</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8461842260159597706" datatype="html">
         <source>Show</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">367</context>
+          <context context-type="linenumber">375</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         <source>Default</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">367</context>
+          <context context-type="linenumber">375</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
         <source>No saved views defined.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">376</context>
+          <context context-type="linenumber">384</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2159130950882492111" datatype="html">
         <source>Cancel</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">396</context>
+          <context context-type="linenumber">404</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
         <source>Error retrieving users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">188</context>
+          <context context-type="linenumber">189</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Error retrieving groups</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">207</context>
+          <context context-type="linenumber">208</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Saved view &quot;<x id="PH" equiv-text="savedView.name"/>&quot; deleted.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">421</context>
+          <context context-type="linenumber">423</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7217000812750597833" datatype="html">
         <source>Settings were saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">547</context>
+          <context context-type="linenumber">553</context>
         </context-group>
       </trans-unit>
       <trans-unit id="525012668859298131" datatype="html">
         <source>Settings were saved successfully. Reload is required to apply some changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">551</context>
+          <context context-type="linenumber">557</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8491974984518503778" datatype="html">
         <source>Reload now</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">552</context>
+          <context context-type="linenumber">558</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3011185103048412841" datatype="html">
         <source>An error occurred while saving settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">562</context>
+          <context context-type="linenumber">568</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">140</context>
+          <context context-type="linenumber">126</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5260584511980773458" datatype="html">
         <source>Error while storing settings on server.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">596</context>
+          <context context-type="linenumber">602</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2991443309752293110" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">260</context>
+          <context context-type="linenumber">251</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">262</context>
+          <context context-type="linenumber">253</context>
         </context-group>
       </trans-unit>
       <trans-unit id="103921551219467537" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">251</context>
+          <context context-type="linenumber">242</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">253</context>
+          <context context-type="linenumber">244</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4569276013106377105" datatype="html">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
           <context context-type="linenumber">73</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">50</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">66</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
           <context context-type="linenumber">53</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">773</context>
+          <context context-type="linenumber">809</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">711</context>
+          <context context-type="linenumber">714</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">750</context>
+          <context context-type="linenumber">753</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</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">775</context>
+          <context context-type="linenumber">811</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">1068</context>
+          <context context-type="linenumber">1104</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">1106</context>
+          <context context-type="linenumber">1142</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">752</context>
+          <context context-type="linenumber">755</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">785</context>
+          <context context-type="linenumber">788</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">804</context>
+          <context context-type="linenumber">807</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
           <context context-type="linenumber">20</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="7100953725264790651" datatype="html">
-        <source>Search documents</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">31</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="2448391510242468907" datatype="html">
         <source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">51</context>
+          <context context-type="linenumber">42</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2127032578120864096" datatype="html">
         <source>My Profile</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">55</context>
+          <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3797778920049399855" datatype="html">
         <source>Logout</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">62</context>
+          <context context-type="linenumber">53</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4895326106573044490" datatype="html">
         <source>Documentation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">67</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">281</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">284</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="6570363013146073520" datatype="html">
-        <source>Dashboard</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">90</context>
+          <context context-type="linenumber">58</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">92</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="4733307402565258070" datatype="html">
-        <source>Documents</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">97</context>
+          <context context-type="linenumber">272</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">99</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">126</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">90</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">90</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">90</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">90</context>
+          <context context-type="linenumber">275</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6988090220128974198" datatype="html">
         <source>Open documents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">137</context>
+          <context context-type="linenumber">128</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5687256342387781369" datatype="html">
         <source>Close all</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">157</context>
+          <context context-type="linenumber">148</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">159</context>
+          <context context-type="linenumber">150</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3897348120591552265" datatype="html">
         <source>Manage</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">168</context>
+          <context context-type="linenumber">159</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7437910965833684826" datatype="html">
         <source>Correspondents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">174</context>
+          <context context-type="linenumber">165</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">176</context>
+          <context context-type="linenumber">167</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
         <source>Tags</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">181</context>
+          <context context-type="linenumber">172</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">184</context>
+          <context context-type="linenumber">175</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">33</context>
+          <context context-type="linenumber">39</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
         <source>Document Types</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">190</context>
+          <context context-type="linenumber">181</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">192</context>
+          <context context-type="linenumber">183</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
         <source>Storage Paths</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">197</context>
+          <context context-type="linenumber">188</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">199</context>
+          <context context-type="linenumber">190</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
         <source>Custom Fields</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">195</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">206</context>
+          <context context-type="linenumber">197</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
         <source>Workflows</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">204</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">215</context>
+          <context context-type="linenumber">206</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
         <source>Mail</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">220</context>
+          <context context-type="linenumber">211</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">223</context>
+          <context context-type="linenumber">214</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7844706011418789951" datatype="html">
         <source>Administration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">231</context>
+          <context context-type="linenumber">222</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3008420115644088420" datatype="html">
         <source>Configuration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">244</context>
+          <context context-type="linenumber">235</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">246</context>
+          <context context-type="linenumber">237</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1534029177398918729" datatype="html">
         <source>GitHub</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">291</context>
+          <context context-type="linenumber">282</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4112664765954374539" datatype="html">
         <source>is available.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">300,301</context>
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">291,292</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1175891574282637937" datatype="html">
+        <source>Click to view.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">292</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="9811291095862612" datatype="html">
+        <source>Paperless-ngx can automatically check for updates</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">296</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="894819944961861800" datatype="html">
+        <source> How does this work? </source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">303,305</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="509090351011426949" datatype="html">
+        <source>Update available</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">316</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1542489069631984294" datatype="html">
+        <source>Sidebar views updated</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
+          <context context-type="linenumber">209</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3547923076537026828" datatype="html">
+        <source>Error updating sidebar views</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
+          <context context-type="linenumber">212</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2526035785704676448" datatype="html">
+        <source>An error occurred while saving update checking settings.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
+          <context context-type="linenumber">233</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4580988005648117665" datatype="html">
+        <source>Search</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1010505078885609376" datatype="html">
+        <source>Advanced search</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">19</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
+          <context context-type="linenumber">143</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7593555694782789615" datatype="html">
+        <source>Open</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">44</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">47</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6329940072345709724" datatype="html">
+        <source>Filter documents</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">53</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3099741642167775297" datatype="html">
+        <source>Download</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">63</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
+          <context context-type="linenumber">79</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">29</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">132</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>
+          <context context-type="linenumber">68</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
+          <context context-type="linenumber">131</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.noResults" datatype="html">
+        <source>No results</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">76</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.documents" datatype="html">
+        <source>Documents</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">79</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.saved_views" datatype="html">
+        <source>Saved Views</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">85</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.tags" datatype="html">
+        <source>Tags</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">92</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.correspondents" datatype="html">
+        <source>Correspondents</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">99</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.documentTypes" datatype="html">
+        <source>Document types</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">106</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.storagePaths" datatype="html">
+        <source>Storage paths</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">113</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.users" datatype="html">
+        <source>Users</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">120</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.groups" datatype="html">
+        <source>Groups</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">127</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="searchResults.customFields" datatype="html">
+        <source>Custom fields</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">134</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="1175891574282637937" datatype="html">
-        <source>Click to view.</source>
+      <trans-unit id="searchResults.mailAccounts" datatype="html">
+        <source>Mail accounts</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">301</context>
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">141</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="9811291095862612" datatype="html">
-        <source>Paperless-ngx can automatically check for updates</source>
+      <trans-unit id="searchResults.mailRules" datatype="html">
+        <source>Mail rules</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">305</context>
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">148</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="894819944961861800" datatype="html">
-        <source> How does this work? </source>
+      <trans-unit id="searchResults.workflows" datatype="html">
+        <source>Workflows</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">312,314</context>
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
+          <context context-type="linenumber">155</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="509090351011426949" datatype="html">
-        <source>Update available</source>
+      <trans-unit id="83507137894716798" datatype="html">
+        <source>Successfully updated object.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">325</context>
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
+          <context context-type="linenumber">168</context>
         </context-group>
-      </trans-unit>
-      <trans-unit id="1542489069631984294" datatype="html">
-        <source>Sidebar views updated</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">282</context>
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
+          <context context-type="linenumber">206</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3547923076537026828" datatype="html">
-        <source>Error updating sidebar views</source>
+      <trans-unit id="1801333259018423190" datatype="html">
+        <source>Error occurred saving object.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">285</context>
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
+          <context context-type="linenumber">171</context>
         </context-group>
-      </trans-unit>
-      <trans-unit id="2526035785704676448" datatype="html">
-        <source>An error occurred while saving update checking settings.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">306</context>
+          <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
+          <context context-type="linenumber">209</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8700121026680200191" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">398</context>
+          <context context-type="linenumber">401</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">438</context>
+          <context context-type="linenumber">441</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">476</context>
+          <context context-type="linenumber">479</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">514</context>
+          <context context-type="linenumber">517</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">576</context>
+          <context context-type="linenumber">579</context>
         </context-group>
       </trans-unit>
       <trans-unit id="994016933065248559" datatype="html">
         <source>Not assigned</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
-          <context context-type="linenumber">337</context>
+          <context context-type="linenumber">340</context>
         </context-group>
         <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
       </trans-unit>
+      <trans-unit id="552910443698134724" datatype="html">
+        <source>Open <x id="PH" equiv-text="this.title"/> filter</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
+          <context context-type="linenumber">452</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7005745151564974365" datatype="html">
+        <source>Keyboard shortcuts</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="4814285799071780083" datatype="html">
         <source>Remove</source>
         <context-group purpose="location">
           <context context-type="linenumber">71</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3099741642167775297" datatype="html">
-        <source>Download</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">79</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">29</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">128</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>
-          <context context-type="linenumber">68</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">131</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="872092479747931526" datatype="html">
         <source>No documents</source>
         <context-group purpose="location">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">343</context>
+          <context context-type="linenumber">346</context>
         </context-group>
         <note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
       </trans-unit>
         </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">106</context>
+          <context context-type="linenumber">110</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1418444397960583910" datatype="html">
         </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">109</context>
+          <context context-type="linenumber">113</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7819314041543176992" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1124</context>
+          <context context-type="linenumber">1160</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">121</context>
+          <context context-type="linenumber">131</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</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">36</context>
+          <context context-type="linenumber">37</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">44</context>
+          <context context-type="linenumber">52</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</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">50</context>
+          <context context-type="linenumber">52</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">54</context>
+          <context context-type="linenumber">64</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</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">64</context>
+          <context context-type="linenumber">67</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">64</context>
+          <context context-type="linenumber">76</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
         <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">328,330</context>
+          <context context-type="linenumber">330,332</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3200733026060976258" datatype="html">
         <source>Document changes detected</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">351</context>
+          <context context-type="linenumber">353</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2887155916749964" datatype="html">
         <source>The version of this document in your browser session appears older than the existing version.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">352</context>
+          <context context-type="linenumber">354</context>
         </context-group>
       </trans-unit>
       <trans-unit id="237142428785956348" datatype="html">
         <source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">353</context>
+          <context context-type="linenumber">355</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8720977247725652816" datatype="html">
         <source>Ok</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">355</context>
+          <context context-type="linenumber">357</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6142395741265832184" datatype="html">
+        <source>Next document</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">464</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="651985345816518480" datatype="html">
+        <source>Previous document</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">474</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2885986061416655600" datatype="html">
+        <source>Close document</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">482</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
+          <context context-type="linenumber">116</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8229691481345614469" datatype="html">
+        <source>Save document</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
+          <context context-type="linenumber">489</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">495</context>
+          <context context-type="linenumber">531</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">520</context>
+          <context context-type="linenumber">556</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">642</context>
+          <context context-type="linenumber">678</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">656</context>
+          <context context-type="linenumber">692</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">660</context>
+          <context context-type="linenumber">696</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">701</context>
+          <context context-type="linenumber">737</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">728</context>
+          <context context-type="linenumber">764</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
         <source>Do you really want to delete document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">729</context>
+          <context context-type="linenumber">765</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">730</context>
+          <context context-type="linenumber">766</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">732</context>
+          <context context-type="linenumber">768</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">751</context>
+          <context context-type="linenumber">787</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">771</context>
+          <context context-type="linenumber">807</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">748</context>
+          <context context-type="linenumber">751</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9197453786953646058" datatype="html">
         <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">772</context>
+          <context context-type="linenumber">808</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5729001209753056399" datatype="html">
         <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">783</context>
+          <context context-type="linenumber">819</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">794</context>
+          <context context-type="linenumber">830</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4458954481601077369" datatype="html">
         <source>Page Fit</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">863</context>
+          <context context-type="linenumber">899</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1217563727923422413" datatype="html">
         <source>Split confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1066</context>
+          <context context-type="linenumber">1102</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2805304563009985503" datatype="html">
         <source>This operation will split the selected document(s) into new documents.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1067</context>
+          <context context-type="linenumber">1103</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4158171846914923744" datatype="html">
         <source>Split operation will begin in the background.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1082</context>
+          <context context-type="linenumber">1118</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3235014591864339926" datatype="html">
         <source>Error executing split operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1091</context>
+          <context context-type="linenumber">1127</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6555329262222566158" datatype="html">
         <source>Rotate confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1103</context>
+          <context context-type="linenumber">1139</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">781</context>
+          <context context-type="linenumber">784</context>
         </context-group>
       </trans-unit>
       <trans-unit id="857641176955257111" datatype="html">
         <source>This operation will permanently rotate the original version of the current document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1104</context>
+          <context context-type="linenumber">1140</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4233432423256408453" datatype="html">
         <source>This will alter the original copy.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1105</context>
+          <context context-type="linenumber">1141</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">783</context>
+          <context context-type="linenumber">786</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4069543875319587651" datatype="html">
         <source>Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1121</context>
+          <context context-type="linenumber">1157</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2962674215361798818" datatype="html">
         <source>Error executing rotate operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">1133</context>
+          <context context-type="linenumber">1169</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4958946940233632319" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">34</context>
+          <context context-type="linenumber">40</context>
         </context-group>
       </trans-unit>
       <trans-unit id="184185893993764098" datatype="html">
         <source>Filter correspondents</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">37</context>
+          <context context-type="linenumber">38</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">45</context>
+          <context context-type="linenumber">53</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2947613869920454977" datatype="html">
         <source>Filter document types</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">51</context>
+          <context context-type="linenumber">53</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">55</context>
+          <context context-type="linenumber">65</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8816999377397522522" datatype="html">
         <source>Filter storage paths</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">65</context>
+          <context context-type="linenumber">68</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">65</context>
+          <context context-type="linenumber">77</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9149498548977462220" datatype="html">
         <source>Custom fields</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">78</context>
+          <context context-type="linenumber">82</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">75</context>
+          <context context-type="linenumber">89</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">129</context>
+          <context context-type="linenumber">139</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6475890479659129881" datatype="html">
         <source>Filter custom fields</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">79</context>
+          <context context-type="linenumber">83</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">76</context>
+          <context context-type="linenumber">90</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3206542606001340679" datatype="html">
         <source>Merge</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">112</context>
+          <context context-type="linenumber">116</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1015374532025907183" datatype="html">
         <source>Include:</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">134</context>
+          <context context-type="linenumber">138</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1537670659786159738" datatype="html">
         <source>Archived files</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">138</context>
+          <context context-type="linenumber">142</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2520291319362448498" datatype="html">
         <source>Original files</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">142</context>
+          <context context-type="linenumber">146</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8009862506882713059" datatype="html">
         <source>Use formatted filename</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">147</context>
+          <context context-type="linenumber">151</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1215215387232313677" datatype="html">
         <source>Error executing bulk operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">247</context>
+          <context context-type="linenumber">250</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7894972847287473517" datatype="html">
         <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">335</context>
+          <context context-type="linenumber">338</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">341</context>
+          <context context-type="linenumber">344</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8639884465898458690" datatype="html">
         <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">337</context>
+          <context context-type="linenumber">340</context>
         </context-group>
         <note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
       </trans-unit>
         <source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">345,347</context>
+          <context context-type="linenumber">348,350</context>
         </context-group>
         <note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
       </trans-unit>
         <source>Confirm tags assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">362</context>
+          <context context-type="linenumber">365</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6619516195038467207" datatype="html">
         <source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">368</context>
+          <context context-type="linenumber">371</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1894412783609570695" datatype="html">
         )"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">373,375</context>
+          <context context-type="linenumber">376,378</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7181166515756808573" datatype="html">
         <source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">381</context>
+          <context context-type="linenumber">384</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3819792277998068944" datatype="html">
         )"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">386,388</context>
+          <context context-type="linenumber">389,391</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2739066218579571288" datatype="html">
         )"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">390,394</context>
+          <context context-type="linenumber">393,397</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2996713129519325161" datatype="html">
         <source>Confirm correspondent assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">431</context>
+          <context context-type="linenumber">434</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6900893559485781849" datatype="html">
         <source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">433</context>
+          <context context-type="linenumber">436</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1257522660364398440" datatype="html">
         <source>This operation will remove the correspondent from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">435</context>
+          <context context-type="linenumber">438</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5393409374423140648" datatype="html">
         <source>Confirm document type assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">469</context>
+          <context context-type="linenumber">472</context>
         </context-group>
       </trans-unit>
       <trans-unit id="332180123895325027" datatype="html">
         <source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">471</context>
+          <context context-type="linenumber">474</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2236642492594872779" datatype="html">
         <source>This operation will remove the document type from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">473</context>
+          <context context-type="linenumber">476</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6386555513013840736" datatype="html">
         <source>Confirm storage path assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">507</context>
+          <context context-type="linenumber">510</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8750527458618415924" datatype="html">
         <source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">509</context>
+          <context context-type="linenumber">512</context>
         </context-group>
       </trans-unit>
       <trans-unit id="60728365335056946" datatype="html">
         <source>This operation will remove the storage path from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">511</context>
+          <context context-type="linenumber">514</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4187352575310415704" datatype="html">
         <source>Confirm custom field assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">540</context>
+          <context context-type="linenumber">543</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7966494636326273856" datatype="html">
         <source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">546</context>
+          <context context-type="linenumber">549</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5789455969634598553" datatype="html">
         )"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">551,553</context>
+          <context context-type="linenumber">554,556</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5648572354333199245" datatype="html">
         <source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">559</context>
+          <context context-type="linenumber">562</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6666899594015948817" datatype="html">
         )"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">564,566</context>
+          <context context-type="linenumber">567,569</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8050047262594964176" datatype="html">
         )"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">568,572</context>
+          <context context-type="linenumber">571,575</context>
         </context-group>
       </trans-unit>
       <trans-unit id="749430623564850405" datatype="html">
         <source>Delete confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">709</context>
+          <context context-type="linenumber">712</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4303174930844518780" datatype="html">
         <source>This operation will permanently delete <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">710</context>
+          <context context-type="linenumber">713</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6734339521247847366" datatype="html">
         <source>Delete document(s)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">713</context>
+          <context context-type="linenumber">716</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8968869182645922415" datatype="html">
         <source>This operation will permanently redo OCR for <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">749</context>
+          <context context-type="linenumber">752</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6390006284731990222" datatype="html">
         <source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">782</context>
+          <context context-type="linenumber">785</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7910756456450124185" datatype="html">
         <source>Merge confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">802</context>
+          <context context-type="linenumber">805</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7643543647233874431" datatype="html">
         <source>This operation will merge <x id="PH" equiv-text="this.list.selected.size"/> selected documents into a new document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">803</context>
+          <context context-type="linenumber">806</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7869008840945899895" datatype="html">
         <source>Merged document will be queued for consumption.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">816</context>
+          <context context-type="linenumber">819</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8076495233090006322" datatype="html">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
           <context context-type="linenumber">10</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
+          <context context-type="linenumber">243</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="1494518490116523821" datatype="html">
         <source>Select all</source>
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
           <context context-type="linenumber">11</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
+          <context context-type="linenumber">236</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="5146398958364876914" datatype="html">
         <source>Sort</source>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">116</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1559883523769732271" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">126</context>
+          <context context-type="linenumber">136</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
           <context context-type="linenumber">8</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4512084577073831437" datatype="html">
+        <source>Reset filters / selection</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
+          <context context-type="linenumber">224</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4135055128446167640" datatype="html">
+        <source>Open first [selected] document</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
+          <context context-type="linenumber">252</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="2155249406916744630" datatype="html">
         <source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">242</context>
+          <context context-type="linenumber">288</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6837554170707123455" datatype="html">
         <source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">285</context>
+          <context context-type="linenumber">331</context>
         </context-group>
       </trans-unit>
       <trans-unit id="739880801667335279" datatype="html">
         <source>Dates</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">86</context>
+          <context context-type="linenumber">100</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3100631071441658964" datatype="html">
         <source>Title &amp; content</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">124</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="1010505078885609376" datatype="html">
-        <source>Advanced search</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">133</context>
+          <context context-type="linenumber">134</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2649431021108393503" datatype="html">
         <source>More like</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">139</context>
+          <context context-type="linenumber">149</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3697582909018473071" datatype="html">
         <source>equals</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">145</context>
+          <context context-type="linenumber">155</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5325481293405718739" datatype="html">
         <source>is empty</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">149</context>
+          <context context-type="linenumber">159</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6166785695326182482" datatype="html">
         <source>is not empty</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">153</context>
+          <context context-type="linenumber">163</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4686622206659266699" datatype="html">
         <source>greater than</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">157</context>
+          <context context-type="linenumber">167</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8014012170270529279" datatype="html">
         <source>less than</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">161</context>
+          <context context-type="linenumber">171</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5195932016807797291" datatype="html">
             )?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">181,183</context>
+          <context context-type="linenumber">191,193</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8170755470576301659" datatype="html">
         <source>Without correspondent</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">185</context>
+          <context context-type="linenumber">195</context>
         </context-group>
       </trans-unit>
       <trans-unit id="317796810569008208" datatype="html">
             )?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">191,193</context>
+          <context context-type="linenumber">201,203</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4362173610367509215" datatype="html">
         <source>Without document type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">195</context>
+          <context context-type="linenumber">205</context>
         </context-group>
       </trans-unit>
       <trans-unit id="232202047340644471" datatype="html">
             )?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">201,203</context>
+          <context context-type="linenumber">211,213</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1562820715074533164" datatype="html">
         <source>Without storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">205</context>
+          <context context-type="linenumber">215</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8180755793012580465" datatype="html">
             ?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">209,210</context>
+          <context context-type="linenumber">219,220</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6494566478302448576" datatype="html">
         <source>Without any tag</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">214</context>
+          <context context-type="linenumber">224</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6370692707013694620" datatype="html">
           )?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">218,220</context>
+          <context context-type="linenumber">228,230</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5297600960590041873" datatype="html">
         <source>Without any custom field</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">224</context>
+          <context context-type="linenumber">234</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6523384805359286307" datatype="html">
         <source>Title: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">228</context>
+          <context context-type="linenumber">238</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1872523635812236432" datatype="html">
         <source>ASN: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">231</context>
+          <context context-type="linenumber">241</context>
         </context-group>
       </trans-unit>
       <trans-unit id="102674688969746976" datatype="html">
         <source>Owner: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">234</context>
+          <context context-type="linenumber">244</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3550877650686009106" datatype="html">
         <source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">237</context>
+          <context context-type="linenumber">247</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1082034558646673343" datatype="html">
         <source>Without an owner</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">240</context>
+          <context context-type="linenumber">250</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7210076240260527720" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">104</context>
+          <context context-type="linenumber">108</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">131</context>
+          <context context-type="linenumber">135</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2573823578527613511" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">132</context>
+          <context context-type="linenumber">136</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3305084982600522070" datatype="html">
         <source>You have unsaved changes to the document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">106</context>
+          <context context-type="linenumber">110</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2089045849587358256" datatype="html">
         <source>Are you sure you want to close this document?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">110</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="2885986061416655600" datatype="html">
-        <source>Close document</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">112</context>
+          <context context-type="linenumber">114</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6755718693176327396" datatype="html">
         <source>Are you sure you want to close all documents?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">133</context>
+          <context context-type="linenumber">137</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4215561719980781894" datatype="html">
         <source>Close documents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
-          <context context-type="linenumber">135</context>
+          <context context-type="linenumber">139</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1206520795340730278" datatype="html">
index 8e754589b614da18869ba78a8b3f581c5d7e448a..3486d17fc2ef993bac7bcd62c2550fe476897423 100644 (file)
@@ -85,6 +85,7 @@ const mock = () => {
   }
 }
 
+Object.defineProperty(window, 'open', { value: jest.fn() })
 Object.defineProperty(window, 'localStorage', { value: mock() })
 Object.defineProperty(window, 'sessionStorage', { value: mock() })
 Object.defineProperty(window, 'getComputedStyle', {
index 80fbdfa5ff6d33eba38b5e3064140872acb34b36..e5fac4cc53ba27e7b2092e89b73743320e24cffa 100644 (file)
@@ -5,8 +5,7 @@ import {
   fakeAsync,
   tick,
 } from '@angular/core/testing'
-import { Router } from '@angular/router'
-import { RouterTestingModule } from '@angular/router/testing'
+import { Router, RouterModule } from '@angular/router'
 import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
 import { Subject } from 'rxjs'
 import { routes } from './app-routing.module'
@@ -21,6 +20,10 @@ import { ToastService, Toast } from './services/toast.service'
 import { SettingsService } from './services/settings.service'
 import { FileDropComponent } from './components/file-drop/file-drop.component'
 import { NgxFileDropModule } from 'ngx-file-drop'
+import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
+import { HotKeyService } from './services/hot-key.service'
+import { PermissionsGuard } from './guards/permissions.guard'
+import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
 
 describe('AppComponent', () => {
   let component: AppComponent
@@ -31,16 +34,18 @@ describe('AppComponent', () => {
   let toastService: ToastService
   let router: Router
   let settingsService: SettingsService
+  let hotKeyService: HotKeyService
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
       declarations: [AppComponent, ToastsComponent, FileDropComponent],
-      providers: [],
+      providers: [PermissionsGuard, DirtySavedViewGuard],
       imports: [
         HttpClientTestingModule,
         TourNgBootstrapModule,
-        RouterTestingModule.withRoutes(routes),
+        RouterModule.forRoot(routes),
         NgxFileDropModule,
+        NgbModalModule,
       ],
     }).compileComponents()
 
@@ -50,6 +55,7 @@ describe('AppComponent', () => {
     settingsService = TestBed.inject(SettingsService)
     toastService = TestBed.inject(ToastService)
     router = TestBed.inject(Router)
+    hotKeyService = TestBed.inject(HotKeyService)
     fixture = TestBed.createComponent(AppComponent)
     component = fixture.componentInstance
   })
@@ -139,4 +145,20 @@ describe('AppComponent', () => {
     fileStatusSubject.next(new FileStatus())
     expect(toastSpy).toHaveBeenCalled()
   })
+
+  it('should support hotkeys', () => {
+    const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut')
+    const routerSpy = jest.spyOn(router, 'navigate')
+    // prevent actual navigation
+    routerSpy.mockReturnValue(new Promise(() => {}))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    component.ngOnInit()
+    expect(addShortcutSpy).toHaveBeenCalled()
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
+    expect(routerSpy).toHaveBeenCalledWith(['/dashboard'])
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
+    expect(routerSpy).toHaveBeenCalledWith(['/documents'])
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
+    expect(routerSpy).toHaveBeenCalledWith(['/settings'])
+  })
 })
index e93fde30c8c3bf4c7890ea2c0344d25545699f5c..7e8abdf3495fdb9bd2a260107dd66b09920b32c1 100644 (file)
@@ -12,6 +12,7 @@ import {
   PermissionsService,
   PermissionType,
 } from './services/permissions.service'
+import { HotKeyService } from './services/hot-key.service'
 
 @Component({
   selector: 'pngx-root',
@@ -31,7 +32,8 @@ export class AppComponent implements OnInit, OnDestroy {
     private tasksService: TasksService,
     public tourService: TourService,
     private renderer: Renderer2,
-    private permissionsService: PermissionsService
+    private permissionsService: PermissionsService,
+    private hotKeyService: HotKeyService
   ) {
     this.settings.updateAppearanceSettings()
   }
@@ -123,6 +125,36 @@ export class AppComponent implements OnInit, OnDestroy {
         }
       })
 
+    this.hotKeyService
+      .addShortcut({ keys: 'h', description: $localize`Dashboard` })
+      .subscribe(() => {
+        this.router.navigate(['/dashboard'])
+      })
+    if (
+      this.permissionsService.currentUserCan(
+        PermissionAction.View,
+        PermissionType.Document
+      )
+    ) {
+      this.hotKeyService
+        .addShortcut({ keys: 'd', description: $localize`Documents` })
+        .subscribe(() => {
+          this.router.navigate(['/documents'])
+        })
+    }
+    if (
+      this.permissionsService.currentUserCan(
+        PermissionAction.Change,
+        PermissionType.UISettings
+      )
+    ) {
+      this.hotKeyService
+        .addShortcut({ keys: 's', description: $localize`Settings` })
+        .subscribe(() => {
+          this.router.navigate(['/settings'])
+        })
+    }
+
     const prevBtnTitle = $localize`Prev`
     const nextBtnTitle = $localize`Next`
     const endBtnTitle = $localize`End`
index 416cfd129b795e51ef87cb5333715d46adb7acea..24d63ed116e2b9f29b372537da6757b01213d936 100644 (file)
@@ -122,6 +122,8 @@ import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/
 import { DocumentHistoryComponent } from './components/document-history/document-history.component'
 import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component'
 import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
+import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
+import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
 import {
   airplane,
   archive,
@@ -163,6 +165,7 @@ import {
   doorOpen,
   download,
   envelope,
+  envelopeAt,
   exclamationCircleFill,
   exclamationTriangle,
   exclamationTriangleFill,
@@ -196,6 +199,7 @@ import {
   personFill,
   personFillLock,
   personLock,
+  personSquare,
   plus,
   plusCircle,
   questionCircle,
@@ -206,6 +210,7 @@ import {
   sortAlphaDown,
   sortAlphaUpAlt,
   tagFill,
+  tag,
   tags,
   textIndentLeft,
   textLeft,
@@ -259,6 +264,7 @@ const icons = {
   doorOpen,
   download,
   envelope,
+  envelopeAt,
   exclamationCircleFill,
   exclamationTriangle,
   exclamationTriangleFill,
@@ -292,6 +298,7 @@ const icons = {
   personFill,
   personFillLock,
   personLock,
+  personSquare,
   plus,
   plusCircle,
   questionCircle,
@@ -302,6 +309,7 @@ const icons = {
   sortAlphaDown,
   sortAlphaUpAlt,
   tagFill,
+  tag,
   tags,
   textIndentLeft,
   textLeft,
@@ -482,6 +490,8 @@ function initializeApp(settings: SettingsService) {
     DocumentHistoryComponent,
     DragDropSelectComponent,
     CustomFieldDisplayComponent,
+    GlobalSearchComponent,
+    HotkeyDialogComponent,
   ],
   imports: [
     BrowserModule,
index b5c6ca6b44da213e3f91e233db9da8e4d302bcf6..87d7ba68a7f9037f4bc20a61eacd331d257d20ac 100644 (file)
           </div>
         </div>
 
+        <h4 class="mt-4" i18n>Global search</h4>
+
+        <div class="row mb-3">
+          <div class="offset-md-3 col">
+            <pngx-input-check i18n-title title="Search database only (do not include advanced search results)" formControlName="searchDbOnly"></pngx-input-check>
+          </div>
+        </div>
+
         <h4 class="mt-4" i18n>Notes</h4>
 
         <div class="row mb-3">
index 7b23edc2184a3ed07430fbed60e196ce774e3ede..71778d394ba7529e524a95d0816f9c0981f21b3e 100644 (file)
@@ -309,7 +309,7 @@ describe('SettingsComponent', () => {
     expect(toastErrorSpy).toHaveBeenCalled()
     expect(storeSpy).toHaveBeenCalled()
     expect(appearanceSettingsSpy).not.toHaveBeenCalled()
-    expect(setSpy).toHaveBeenCalledTimes(25)
+    expect(setSpy).toHaveBeenCalledTimes(26)
 
     // succeed
     storeSpy.mockReturnValueOnce(of(true))
index 7df90e3de62b3830ca74cae935f42be238072f44..036f27f4836021d2c2d4500320991899e852cb8f 100644 (file)
@@ -100,6 +100,7 @@ export class SettingsComponent
     defaultPermsEditUsers: new FormControl(null),
     defaultPermsEditGroups: new FormControl(null),
     documentEditingRemoveInboxTags: new FormControl(null),
+    searchDbOnly: new FormControl(null),
 
     notificationsConsumerNewDocument: new FormControl(null),
     notificationsConsumerSuccess: new FormControl(null),
@@ -304,6 +305,7 @@ export class SettingsComponent
       documentEditingRemoveInboxTags: this.settings.get(
         SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
       ),
+      searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
       savedViews: {},
     }
   }
@@ -533,6 +535,10 @@ export class SettingsComponent
       SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
       this.settingsForm.value.documentEditingRemoveInboxTags
     )
+    this.settings.set(
+      SETTINGS_KEYS.SEARCH_DB_ONLY,
+      this.settingsForm.value.searchDbOnly
+    )
     this.settings.setLanguage(this.settingsForm.value.displayLanguage)
     this.settings
       .storeSettings()
index 1e4080c48cd995a71259873638d23973d5754018..ab5759ec0a377f5e8340a24eb3edef560992262d 100644 (file)
       }
     </div>
   </a>
-  <div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
-    *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
-    <form (ngSubmit)="search()" class="form-inline flex-grow-1">
-      <i-bs width="1em" height="1em" name="search"></i-bs>
-      <input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
-        [formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
-        (selectItem)="itemSelected($event)" i18n-placeholder>
-      @if (!searchFieldEmpty) {
-        <button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
-          <i-bs width="1em" height="1em" name="x"></i-bs>
-        </button>
-      }
-    </form>
+  <div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
+    <div class="col-12 col-md-7">
+      <pngx-global-search></pngx-global-search>
+    </div>
   </div>
   <ul ngbNav class="order-sm-3">
     <li ngbDropdown class="nav-item dropdown">
index cd6c41111c35e69bdcd75d93f85219a52451f18f..cdb6e3be509e30992e0d18e382bee87a05497db5 100644 (file)
@@ -257,59 +257,6 @@ main {
   }
 }
 
-.navbar .search-form-container {
-  max-width: 550px;
-
-  form {
-    position: relative;
-
-    > i-bs {
-      position: absolute;
-      left: 0.6rem;
-      top: .35rem;
-      color: rgba(255, 255, 255, 0.6);
-
-      @media screen and (min-width: 768px) {
-        // adjust for smaller font size on non-mobile
-        top: 0.25rem;
-      }
-    }
-
-  }
-
-
-  &:focus-within {
-    form > i-bs {
-      display: none;
-    }
-
-    .form-control::placeholder {
-      color: rgba(255, 255, 255, 0);
-    }
-  }
-
-  .form-control {
-    color: rgba(255, 255, 255, 0.3);
-    background-color: rgba(0, 0, 0, 0.15);
-    padding-left: 1.8rem;
-    border-color: rgba(255, 255, 255, 0.2);
-    transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
-    max-width: 600px;
-    min-width: 300px; // 1/2 max
-
-    &::placeholder {
-      color: rgba(255, 255, 255, 0.4);
-    }
-
-    &:focus {
-      background-color: rgba(0, 0, 0, 0.3);
-      color: var(--bs-light);
-      flex-grow: 1;
-      padding-left: 0.5rem;
-    }
-  }
-}
-
 .version-check {
   animation: pulse 2s ease-in-out 0s 1;
 }
index e1a553047ea962fbb50cf1463d16c50c4c12c32e..1091e67f4aa182fec39d0ce52191369488eea416 100644 (file)
@@ -30,14 +30,13 @@ import { OpenDocumentsService } from 'src/app/services/open-documents.service'
 import { ActivatedRoute, Router } from '@angular/router'
 import { DocumentDetailComponent } from '../document-detail/document-detail.component'
 import { SearchService } from 'src/app/services/rest/search.service'
-import { DocumentListViewService } from 'src/app/services/document-list-view.service'
-import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
 import { routes } from 'src/app/app-routing.module'
 import { PermissionsGuard } from 'src/app/guards/permissions.guard'
 import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
 import { SavedView } from 'src/app/data/saved-view'
 import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { GlobalSearchComponent } from './global-search/global-search.component'
 
 const saved_views = [
   {
@@ -89,15 +88,17 @@ describe('AppFrameComponent', () => {
   let toastService: ToastService
   let messagesService: DjangoMessagesService
   let openDocumentsService: OpenDocumentsService
-  let searchService: SearchService
-  let documentListViewService: DocumentListViewService
   let router: Router
   let savedViewSpy
   let modalService: NgbModal
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
-      declarations: [AppFrameComponent, IfPermissionsDirective],
+      declarations: [
+        AppFrameComponent,
+        IfPermissionsDirective,
+        GlobalSearchComponent,
+      ],
       imports: [
         HttpClientTestingModule,
         BrowserModule,
@@ -159,8 +160,6 @@ describe('AppFrameComponent', () => {
     toastService = TestBed.inject(ToastService)
     messagesService = TestBed.inject(DjangoMessagesService)
     openDocumentsService = TestBed.inject(OpenDocumentsService)
-    searchService = TestBed.inject(SearchService)
-    documentListViewService = TestBed.inject(DocumentListViewService)
     modalService = TestBed.inject(NgbModal)
     router = TestBed.inject(Router)
 
@@ -296,62 +295,6 @@ describe('AppFrameComponent', () => {
     expect(component.canDeactivate()).toBeFalsy()
   })
 
-  it('should call autocomplete endpoint on input', fakeAsync(() => {
-    const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
-    component.searchAutoComplete(of('hello')).subscribe()
-    tick(250)
-    expect(autocompleteSpy).toHaveBeenCalled()
-
-    component.searchAutoComplete(of('hello world 1')).subscribe()
-    tick(250)
-    expect(autocompleteSpy).toHaveBeenCalled()
-  }))
-
-  it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
-    const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
-    serviceAutocompleteSpy.mockReturnValue(
-      throwError(() => new Error('autcomplete failed'))
-    )
-    // serviceAutocompleteSpy.mockReturnValue(of([' world']))
-    let result
-    component.searchAutoComplete(of('hello')).subscribe((res) => {
-      result = res
-    })
-    tick(250)
-    expect(serviceAutocompleteSpy).toHaveBeenCalled()
-    expect(result).toEqual([])
-  }))
-
-  it('should support reset search field', () => {
-    const resetSpy = jest.spyOn(component, 'resetSearchField')
-    const input = (fixture.nativeElement as HTMLDivElement).querySelector(
-      'input'
-    ) as HTMLInputElement
-    input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
-    expect(resetSpy).toHaveBeenCalled()
-  })
-
-  it('should support choosing a search item', () => {
-    expect(component.searchField.value).toEqual('')
-    component.itemSelected({ item: 'hello', preventDefault: () => true })
-    expect(component.searchField.value).toEqual('hello ')
-    component.itemSelected({ item: 'world', preventDefault: () => true })
-    expect(component.searchField.value).toEqual('hello world ')
-  })
-
-  it('should navigate via quickFilter on search', () => {
-    const str = 'hello world '
-    component.searchField.patchValue(str)
-    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
-    component.search()
-    expect(qfSpy).toHaveBeenCalledWith([
-      {
-        rule_type: FILTER_FULLTEXT_QUERY,
-        value: str.trim(),
-      },
-    ])
-  })
-
   it('should disable global dropzone on start drag + drop, re-enable after', () => {
     expect(settingsService.globalDropzoneEnabled).toBeTruthy()
     component.onDragStart(null)
index ab9322380556a0246456efbdeec7f51a6ca3535f..7d6c2531cc775d64848aa7eb4d04ed677989b33d 100644 (file)
@@ -1,15 +1,7 @@
 import { Component, HostListener, OnInit } from '@angular/core'
-import { FormControl } from '@angular/forms'
 import { ActivatedRoute, Router } from '@angular/router'
-import { from, Observable } from 'rxjs'
-import {
-  debounceTime,
-  distinctUntilChanged,
-  map,
-  switchMap,
-  first,
-  catchError,
-} from 'rxjs/operators'
+import { Observable } from 'rxjs'
+import { first } from 'rxjs/operators'
 import { Document } from 'src/app/data/document'
 import { OpenDocumentsService } from 'src/app/services/open-documents.service'
 import {
@@ -17,11 +9,8 @@ import {
   DjangoMessagesService,
 } from 'src/app/services/django-messages.service'
 import { SavedViewService } from 'src/app/services/rest/saved-view.service'
-import { SearchService } from 'src/app/services/rest/search.service'
 import { environment } from 'src/environments/environment'
 import { DocumentDetailComponent } from '../document-detail/document-detail.component'
-import { DocumentListViewService } from 'src/app/services/document-list-view.service'
-import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
 import {
   RemoteVersionService,
   AppRemoteVersion,
@@ -46,6 +35,7 @@ import {
 } from '@angular/cdk/drag-drop'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
+import { ObjectWithId } from 'src/app/data/object-with-id'
 
 @Component({
   selector: 'pngx-app-frame',
@@ -63,16 +53,12 @@ export class AppFrameComponent
 
   slimSidebarAnimating: boolean = false
 
-  searchField = new FormControl('')
-
   constructor(
     public router: Router,
     private activatedRoute: ActivatedRoute,
     private openDocumentsService: OpenDocumentsService,
-    private searchService: SearchService,
     public savedViewService: SavedViewService,
     private remoteVersionService: RemoteVersionService,
-    private list: DocumentListViewService,
     public settingsService: SettingsService,
     public tasksService: TasksService,
     private readonly toastService: ToastService,
@@ -164,65 +150,6 @@ export class AppFrameComponent
     return !this.openDocumentsService.hasDirty()
   }
 
-  get searchFieldEmpty(): boolean {
-    return this.searchField.value.trim().length == 0
-  }
-
-  resetSearchField() {
-    this.searchField.reset('')
-  }
-
-  searchFieldKeyup(event: KeyboardEvent) {
-    if (event.key == 'Escape') {
-      this.resetSearchField()
-    }
-  }
-
-  searchAutoComplete = (text$: Observable<string>) =>
-    text$.pipe(
-      debounceTime(200),
-      distinctUntilChanged(),
-      map((term) => {
-        if (term.lastIndexOf(' ') != -1) {
-          return term.substring(term.lastIndexOf(' ') + 1)
-        } else {
-          return term
-        }
-      }),
-      switchMap((term) =>
-        term.length < 2
-          ? from([[]])
-          : this.searchService.autocomplete(term).pipe(
-              catchError(() => {
-                return from([[]])
-              })
-            )
-      )
-    )
-
-  itemSelected(event) {
-    event.preventDefault()
-    let currentSearch: string = this.searchField.value
-    let lastSpaceIndex = currentSearch.lastIndexOf(' ')
-    if (lastSpaceIndex != -1) {
-      currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
-      currentSearch += event.item + ' '
-    } else {
-      currentSearch = event.item + ' '
-    }
-    this.searchField.patchValue(currentSearch)
-  }
-
-  search() {
-    this.closeMenu()
-    this.list.quickFilter([
-      {
-        rule_type: FILTER_FULLTEXT_QUERY,
-        value: (this.searchField.value as string).trim(),
-      },
-    ])
-  }
-
   closeDocument(d: Document) {
     this.openDocumentsService
       .closeDocument(d)
diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.html b/src-ui/src/app/components/app-frame/global-search/global-search.component.html
new file mode 100644 (file)
index 0000000..8df6cab
--- /dev/null
@@ -0,0 +1,163 @@
+
+<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
+    <form class="form-inline position-relative">
+        <i-bs width="1em" height="1em" name="search"></i-bs>
+        <div class="input-group">
+            <div class="form-control form-control-sm">
+                <input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
+                    placeholder="Search" aria-label="Search" i18n-placeholder
+                    autocomplete="off" spellcheck="false"
+                    [(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)">
+                <div class="position-absolute top-50 end-0 translate-middle">
+                    @if (loading) {
+                        <div class="spinner-border spinner-border-sm text-muted mt-1"></div>
+                    }
+                </div>
+            </div>
+            @if (query && (searchResults?.documents.length === searchService.searchResultObjectLimit || searchService.searchDbOnly)) {
+                <button class="btn btn-sm btn-outline-secondary" type="button" (click)="runAdvanedSearch()">
+                    <ng-container i18n>Advanced search</ng-container>
+                    <i-bs width="1em" height="1em" name="arrow-right-short"></i-bs>
+                </button>
+            }
+        </div>
+    </form>
+
+    <ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
+        <div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
+        (click)="primaryAction(type, item)"
+        (mouseenter)="onItemHover($event)">
+            <i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
+            <div class="text-truncate">
+                {{item[nameProp]}}
+                @if (date) {
+                    <small class="small text-muted">{{date | customDate}}</small>
+                }
+            </div>
+            <div class="btn-group ms-auto">
+                <button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
+                (click)="primaryAction(type, item); $event.stopPropagation()"
+                [disabled]="disablePrimaryButton(type, item)"
+                (mouseenter)="onButtonHover($event)">
+                    @if (type === DataType.Document) {
+                        <i-bs width="1em" height="1em" name="pencil"></i-bs>
+                        <span>&nbsp;<ng-container i18n>Open</ng-container></span>
+                    } @else if (type === DataType.SavedView) {
+                        <i-bs width="1em" height="1em" name="eye"></i-bs>
+                        <span>&nbsp;<ng-container i18n>Open</ng-container></span>
+                    } @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
+                        <i-bs width="1em" height="1em" name="pencil"></i-bs>
+                        <span>&nbsp;<ng-container i18n>Edit</ng-container></span>
+                    } @else {
+                        <i-bs width="1em" height="1em" name="filter"></i-bs>
+                        <span>&nbsp;<ng-container i18n>Filter documents</ng-container></span>
+                    }
+                </button>
+                @if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
+                    <button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
+                    (click)="secondaryAction(type, item); $event.stopPropagation()"
+                    [disabled]="disableSecondaryButton(type, item)"
+                    (mouseenter)="onButtonHover($event)">
+                        @if (type === DataType.Document) {
+                            <i-bs width="1em" height="1em" name="download"></i-bs>
+                            <span>&nbsp;<ng-container i18n>Download</ng-container></span>
+                        } @else {
+                            <i-bs width="1em" height="1em" name="pencil"></i-bs>
+                            <span>&nbsp;<ng-container i18n>Edit</ng-container></span>
+                        }
+                    </button>
+                }
+            </div>
+        </div>
+    </ng-template>
+
+    <div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg" (keydown)="dropdownKeyDown($event)">
+        @if (searchResults?.total === 0) {
+            <h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
+        } @else {
+            @if (searchResults?.documents.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
+                @for (document of searchResults.documents; track document.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
+                }
+            }
+            @if (searchResults?.saved_views.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
+                @for (saved_view of searchResults.saved_views; track saved_view.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.tags.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
+                @for (tag of searchResults.tags; track tag.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.correspondents.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
+                @for (correspondent of searchResults.correspondents; track correspondent.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.document_types.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
+                @for (documentType of searchResults.document_types; track documentType.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.storage_paths.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
+                @for (storagePath of searchResults.storage_paths; track storagePath.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.users.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
+                @for (user of searchResults.users; track user.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.groups.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
+                @for (group of searchResults.groups; track group.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.custom_fields.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
+                @for (customField of searchResults.custom_fields; track customField.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.mail_accounts.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
+                @for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.mail_rules.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
+                @for (mailRule of searchResults.mail_rules; track mailRule.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
+                }
+            }
+
+            @if (searchResults?.workflows.length) {
+                <h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
+                @for (workflow of searchResults.workflows; track workflow.id) {
+                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
+                }
+            }
+        }
+
+    </div>
+</div>
diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.scss b/src-ui/src/app/components/app-frame/global-search/global-search.component.scss
new file mode 100644 (file)
index 0000000..646e40d
--- /dev/null
@@ -0,0 +1,97 @@
+form {
+  position: relative;
+
+  > i-bs[name="search"] {
+    position: absolute;
+    left: 0.6rem;
+    top: .35rem;
+    color: rgba(255, 255, 255, 0.6);
+
+    @media screen and (min-width: 768px) {
+      // adjust for smaller font size on non-mobile
+      top: 0.25rem;
+    }
+  }
+
+  &:focus-within {
+    i-bs[name="search"],
+    .badge {
+      display: none !important;
+    }
+
+    .form-control::placeholder {
+      color: rgba(255, 255, 255, 0);
+    }
+  }
+
+  .badge {
+    font-size: 0.8rem;
+  }
+
+  .input-group .btn {
+    border-color: rgba(255, 255, 255, 0.2);
+    color: var(--pngx-primary-text-contrast);
+  }
+
+  .form-control {
+    color: rgba(255, 255, 255, 0.3);
+    background-color: rgba(0, 0, 0, 0.15);
+    padding-left: 1.8rem;
+    border-color: rgba(255, 255, 255, 0.2);
+    transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
+    > input {
+      outline: none;
+
+      &::placeholder {
+        color: rgba(255, 255, 255, 0.4);
+      }
+    }
+
+    &:focus-within {
+      background-color: rgba(0, 0, 0, 0.3);
+      color: var(--bs-light);
+      flex-grow: 1;
+      padding-left: 0.5rem;
+    }
+  }
+
+}
+
+* {
+  --pngx-focus-alpha: 0;
+}
+
+.cursor-pointer {
+  cursor: pointer;
+}
+
+.mh-75 {
+  max-height: 75vh;
+}
+
+.dropdown-item {
+  &:has(button:focus) {
+    background-color: var(--pngx-bg-darker);
+  }
+
+  & button {
+    transition: all 0.3s ease, color 0.15s ease;
+    max-width: 2rem;
+    overflow: hidden;
+  }
+
+  & button span {
+    opacity: 0;
+    transition: inherit;
+  }
+
+  &:hover button,
+  &:has(button:focus) button {
+    max-width: 10rem;
+  }
+
+  &:hover button span,
+  &:has(button:focus) span {
+    opacity: 1;
+  }
+}
diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts b/src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts
new file mode 100644 (file)
index 0000000..a58db61
--- /dev/null
@@ -0,0 +1,453 @@
+import {
+  ComponentFixture,
+  TestBed,
+  fakeAsync,
+  tick,
+} from '@angular/core/testing'
+import { GlobalSearchComponent } from './global-search.component'
+import { of } from 'rxjs'
+import { SearchService } from 'src/app/services/rest/search.service'
+import { Router } from '@angular/router'
+import {
+  NgbDropdownModule,
+  NgbModal,
+  NgbModalModule,
+  NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
+import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import {
+  FILTER_FULLTEXT_QUERY,
+  FILTER_HAS_CORRESPONDENT_ANY,
+  FILTER_HAS_DOCUMENT_TYPE_ANY,
+  FILTER_HAS_STORAGE_PATH_ANY,
+  FILTER_HAS_TAGS_ANY,
+} from 'src/app/data/filter-rule-type'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
+import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
+import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
+import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
+import { ElementRef } from '@angular/core'
+import { ToastService } from 'src/app/services/toast.service'
+import { DataType } from 'src/app/data/datatype'
+
+const searchResults = {
+  total: 11,
+  documents: [
+    {
+      id: 1,
+      title: 'Test',
+      created_at: new Date(),
+      updated_at: new Date(),
+      document_type: { id: 1, name: 'Test' },
+      storage_path: { id: 1, path: 'Test' },
+      tags: [],
+      correspondents: [],
+      custom_fields: [],
+    },
+  ],
+  saved_views: [
+    {
+      id: 1,
+      name: 'TestSavedView',
+    },
+  ],
+  correspondents: [
+    {
+      id: 1,
+      name: 'TestCorrespondent',
+    },
+  ],
+  document_types: [
+    {
+      id: 1,
+      name: 'TestDocumentType',
+    },
+  ],
+  storage_paths: [
+    {
+      id: 1,
+      name: 'TestStoragePath',
+    },
+  ],
+  tags: [
+    {
+      id: 1,
+      name: 'TestTag',
+    },
+  ],
+  users: [
+    {
+      id: 1,
+      username: 'TestUser',
+    },
+  ],
+  groups: [
+    {
+      id: 1,
+      name: 'TestGroup',
+    },
+  ],
+  mail_accounts: [
+    {
+      id: 1,
+      name: 'TestMailAccount',
+    },
+  ],
+  mail_rules: [
+    {
+      id: 1,
+      name: 'TestMailRule',
+    },
+  ],
+  custom_fields: [
+    {
+      id: 1,
+      name: 'TestCustomField',
+    },
+  ],
+  workflows: [
+    {
+      id: 1,
+      name: 'TestWorkflow',
+    },
+  ],
+}
+
+describe('GlobalSearchComponent', () => {
+  let component: GlobalSearchComponent
+  let fixture: ComponentFixture<GlobalSearchComponent>
+  let searchService: SearchService
+  let router: Router
+  let modalService: NgbModal
+  let documentService: DocumentService
+  let documentListViewService: DocumentListViewService
+  let toastService: ToastService
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [GlobalSearchComponent],
+      imports: [
+        HttpClientTestingModule,
+        NgbModalModule,
+        NgbDropdownModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgxBootstrapIconsModule.pick(allIcons),
+      ],
+    }).compileComponents()
+
+    searchService = TestBed.inject(SearchService)
+    router = TestBed.inject(Router)
+    modalService = TestBed.inject(NgbModal)
+    documentService = TestBed.inject(DocumentService)
+    documentListViewService = TestBed.inject(DocumentListViewService)
+    toastService = TestBed.inject(ToastService)
+
+    fixture = TestBed.createComponent(GlobalSearchComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should handle keyboard nav', () => {
+    const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
+    expect(focusSpy).toHaveBeenCalled()
+
+    component.searchResults = searchResults as any
+    component.resultsDropdown.open()
+    fixture.detectChanges()
+
+    component['currentItemIndex'] = 0
+    component['setCurrentItem']()
+    const firstItemFocusSpy = jest.spyOn(
+      component.primaryButtons.get(1).nativeElement,
+      'focus'
+    )
+    component.dropdownKeyDown(
+      new KeyboardEvent('keydown', { key: 'ArrowDown' })
+    )
+    expect(component['currentItemIndex']).toBe(1)
+    expect(firstItemFocusSpy).toHaveBeenCalled()
+
+    const secondaryItemFocusSpy = jest.spyOn(
+      component.secondaryButtons.get(1).nativeElement,
+      'focus'
+    )
+    component.dropdownKeyDown(
+      new KeyboardEvent('keydown', { key: 'ArrowRight' })
+    )
+    expect(secondaryItemFocusSpy).toHaveBeenCalled()
+
+    component.dropdownKeyDown(
+      new KeyboardEvent('keydown', { key: 'ArrowLeft' })
+    )
+    expect(firstItemFocusSpy).toHaveBeenCalled()
+
+    const zeroItemSpy = jest.spyOn(
+      component.primaryButtons.get(0).nativeElement,
+      'focus'
+    )
+    component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
+    expect(component['currentItemIndex']).toBe(0)
+    expect(zeroItemSpy).toHaveBeenCalled()
+
+    const inputFocusSpy = jest.spyOn(
+      component.searchInput.nativeElement,
+      'focus'
+    )
+    component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
+    expect(component['currentItemIndex']).toBe(-1)
+    expect(inputFocusSpy).toHaveBeenCalled()
+
+    component.dropdownKeyDown(
+      new KeyboardEvent('keydown', { key: 'ArrowDown' })
+    )
+    component['currentItemIndex'] = searchResults.total - 1
+    component['setCurrentItem']()
+    component.dropdownKeyDown(
+      new KeyboardEvent('keydown', { key: 'ArrowDown' })
+    )
+    expect(component['currentItemIndex']).toBe(-1)
+
+    // Search input
+
+    component.searchInputKeyDown(
+      new KeyboardEvent('keydown', { key: 'ArrowUp' })
+    )
+    expect(component['currentItemIndex']).toBe(searchResults.total - 1)
+
+    component.searchInputKeyDown(
+      new KeyboardEvent('keydown', { key: 'ArrowDown' })
+    )
+    expect(component['currentItemIndex']).toBe(0)
+
+    component.searchResults = { total: 1 } as any
+    const primaryActionSpy = jest.spyOn(component, 'primaryAction')
+    component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
+    expect(primaryActionSpy).toHaveBeenCalled()
+
+    component.query = 'test'
+    const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
+    component.searchInputKeyDown(
+      new KeyboardEvent('keydown', { key: 'Escape' })
+    )
+    expect(resetSpy).toHaveBeenCalled()
+
+    component.query = ''
+    const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
+    component.searchInputKeyDown(
+      new KeyboardEvent('keydown', { key: 'Escape' })
+    )
+    expect(blurSpy).toHaveBeenCalled()
+
+    component.searchResults = { total: 1 } as any
+    component.resultsDropdown.close()
+    const openSpy = jest.spyOn(component.resultsDropdown, 'open')
+    component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
+    expect(openSpy).toHaveBeenCalled()
+  })
+
+  it('should search on query debounce', fakeAsync(() => {
+    const query = 'test'
+    const searchSpy = jest.spyOn(searchService, 'globalSearch')
+    searchSpy.mockReturnValue(of({} as any))
+    const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
+    component.queryDebounce.next(query)
+    tick(401)
+    expect(searchSpy).toHaveBeenCalledWith(query)
+    expect(dropdownOpenSpy).toHaveBeenCalled()
+  }))
+
+  it('should support primary action', () => {
+    const object = { id: 1 }
+    const routerSpy = jest.spyOn(router, 'navigate')
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    const modalSpy = jest.spyOn(modalService, 'open')
+
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+
+    component.primaryAction(DataType.Document, object)
+    expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id])
+
+    component.primaryAction(DataType.SavedView, object)
+    expect(routerSpy).toHaveBeenCalledWith(['/view', object.id])
+
+    component.primaryAction(DataType.Correspondent, object)
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_HAS_CORRESPONDENT_ANY, value: object.id.toString() },
+    ])
+
+    component.primaryAction(DataType.DocumentType, object)
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY, value: object.id.toString() },
+    ])
+
+    component.primaryAction(DataType.StoragePath, object)
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_HAS_STORAGE_PATH_ANY, value: object.id.toString() },
+    ])
+
+    component.primaryAction(DataType.Tag, object)
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_HAS_TAGS_ANY, value: object.id.toString() },
+    ])
+
+    component.primaryAction(DataType.User, object)
+    expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
+      size: 'lg',
+    })
+
+    component.primaryAction(DataType.Group, object)
+    expect(modalSpy).toHaveBeenCalledWith(GroupEditDialogComponent, {
+      size: 'lg',
+    })
+
+    component.primaryAction(DataType.MailAccount, object)
+    expect(modalSpy).toHaveBeenCalledWith(MailAccountEditDialogComponent, {
+      size: 'xl',
+    })
+
+    component.primaryAction(DataType.MailRule, object)
+    expect(modalSpy).toHaveBeenCalledWith(MailRuleEditDialogComponent, {
+      size: 'xl',
+    })
+
+    component.primaryAction(DataType.CustomField, object)
+    expect(modalSpy).toHaveBeenCalledWith(CustomFieldEditDialogComponent, {
+      size: 'md',
+    })
+
+    component.primaryAction(DataType.Workflow, object)
+    expect(modalSpy).toHaveBeenCalledWith(WorkflowEditDialogComponent, {
+      size: 'xl',
+    })
+
+    const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+
+    // fail first
+    editDialog.failed.emit({ error: 'error creating item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit(true)
+    expect(toastInfoSpy).toHaveBeenCalled()
+  })
+
+  it('should support secondary action', () => {
+    const doc = searchResults.documents[0]
+    const openSpy = jest.spyOn(window, 'open')
+    component.secondaryAction('document', doc)
+    expect(openSpy).toHaveBeenCalledWith(documentService.getDownloadUrl(doc.id))
+
+    const correspondent = searchResults.correspondents[0]
+    const modalSpy = jest.spyOn(modalService, 'open')
+
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+
+    component.secondaryAction(DataType.Correspondent, correspondent)
+    expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
+      size: 'md',
+    })
+
+    component.secondaryAction(
+      DataType.DocumentType,
+      searchResults.document_types[0]
+    )
+    expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
+      size: 'md',
+    })
+
+    component.secondaryAction(
+      DataType.StoragePath,
+      searchResults.storage_paths[0]
+    )
+    expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
+      size: 'md',
+    })
+
+    component.secondaryAction(DataType.Tag, searchResults.tags[0])
+    expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
+      size: 'md',
+    })
+
+    const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+
+    // fail first
+    editDialog.failed.emit({ error: 'error creating item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit(true)
+    expect(toastInfoSpy).toHaveBeenCalled()
+  })
+
+  it('should support reset', () => {
+    const debounce = jest.spyOn(component.queryDebounce, 'next')
+    const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
+    component['reset'](true)
+    expect(debounce).toHaveBeenCalledWith(null)
+    expect(component.searchResults).toBeNull()
+    expect(component['currentItemIndex']).toBe(-1)
+    expect(closeSpy).toHaveBeenCalled()
+  })
+
+  it('should support focus current item', () => {
+    component.searchResults = searchResults as any
+    fixture.detectChanges()
+    const focusSpy = jest.spyOn(
+      component.primaryButtons.get(0).nativeElement,
+      'focus'
+    )
+    component['currentItemIndex'] = 0
+    component['setCurrentItem']()
+    expect(focusSpy).toHaveBeenCalled()
+  })
+
+  it('should reset on dropdown close', () => {
+    const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
+    component.onDropdownOpenChange(false)
+    expect(resetSpy).toHaveBeenCalled()
+  })
+
+  it('should focus button on dropdown item hover', () => {
+    component.searchResults = searchResults as any
+    fixture.detectChanges()
+    const item: ElementRef = component.resultItems.first
+    const focusSpy = jest.spyOn(
+      component.primaryButtons.first.nativeElement,
+      'focus'
+    )
+    component.onItemHover({ currentTarget: item.nativeElement } as any)
+    expect(component['currentItemIndex']).toBe(0)
+    expect(focusSpy).toHaveBeenCalled()
+  })
+
+  it('should focus on button hover', () => {
+    const event = { currentTarget: { focus: jest.fn() } }
+    const focusSpy = jest.spyOn(event.currentTarget, 'focus')
+    component.onButtonHover(event as any)
+    expect(focusSpy).toHaveBeenCalled()
+  })
+
+  it('should support explicit advanced search', () => {
+    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+    component.query = 'test'
+    component.runAdvanedSearch()
+    expect(qfSpy).toHaveBeenCalledWith([
+      { rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
+    ])
+  })
+})
diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.ts b/src-ui/src/app/components/app-frame/global-search/global-search.component.ts
new file mode 100644 (file)
index 0000000..f2f1915
--- /dev/null
@@ -0,0 +1,362 @@
+import {
+  Component,
+  ViewChild,
+  ElementRef,
+  ViewChildren,
+  QueryList,
+  OnInit,
+} from '@angular/core'
+import { Router } from '@angular/router'
+import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
+import { Subject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
+import {
+  FILTER_FULLTEXT_QUERY,
+  FILTER_HAS_CORRESPONDENT_ANY,
+  FILTER_HAS_DOCUMENT_TYPE_ANY,
+  FILTER_HAS_STORAGE_PATH_ANY,
+  FILTER_HAS_TAGS_ANY,
+} from 'src/app/data/filter-rule-type'
+import { DataType } from 'src/app/data/datatype'
+import { ObjectWithId } from 'src/app/data/object-with-id'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import {
+  PermissionsService,
+  PermissionAction,
+} from 'src/app/services/permissions.service'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import {
+  GlobalSearchResult,
+  SearchService,
+} from 'src/app/services/rest/search.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
+import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
+import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
+import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
+import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
+import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
+import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
+import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
+import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
+import { HotKeyService } from 'src/app/services/hot-key.service'
+
+@Component({
+  selector: 'pngx-global-search',
+  templateUrl: './global-search.component.html',
+  styleUrl: './global-search.component.scss',
+})
+export class GlobalSearchComponent implements OnInit {
+  public DataType = DataType
+  public query: string
+  public queryDebounce: Subject<string>
+  public searchResults: GlobalSearchResult
+  private currentItemIndex: number = -1
+  private domIndex: number = -1
+  public loading: boolean = false
+
+  @ViewChild('searchInput') searchInput: ElementRef
+  @ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
+  @ViewChildren('resultItem') resultItems: QueryList<ElementRef>
+  @ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
+  @ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
+
+  constructor(
+    public searchService: SearchService,
+    private router: Router,
+    private modalService: NgbModal,
+    private documentService: DocumentService,
+    private documentListViewService: DocumentListViewService,
+    private permissionsService: PermissionsService,
+    private toastService: ToastService,
+    private hotkeyService: HotKeyService
+  ) {
+    this.queryDebounce = new Subject<string>()
+
+    this.queryDebounce
+      .pipe(
+        debounceTime(400),
+        map((query) => query?.trim()),
+        filter((query) => !query?.length || query?.length > 2),
+        distinctUntilChanged()
+      )
+      .subscribe((text) => {
+        this.query = text
+        if (text) this.search(text)
+      })
+  }
+
+  ngOnInit() {
+    this.hotkeyService
+      .addShortcut({ keys: '/', description: $localize`Global search` })
+      .subscribe(() => {
+        this.searchInput.nativeElement.focus()
+      })
+  }
+
+  private search(query: string) {
+    this.loading = true
+    this.searchService.globalSearch(query).subscribe((results) => {
+      this.searchResults = results
+      this.loading = false
+      this.resultsDropdown.open()
+    })
+  }
+
+  public primaryAction(type: string, object: ObjectWithId) {
+    this.reset(true)
+    let filterRuleType: number
+    let editDialogComponent: any
+    let size: string = 'md'
+    switch (type) {
+      case DataType.Document:
+        this.router.navigate(['/documents', object.id])
+        return
+      case DataType.SavedView:
+        this.router.navigate(['/view', object.id])
+        return
+      case DataType.Correspondent:
+        filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
+        break
+      case DataType.DocumentType:
+        filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
+        break
+      case DataType.StoragePath:
+        filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
+        break
+      case DataType.Tag:
+        filterRuleType = FILTER_HAS_TAGS_ANY
+        break
+      case DataType.User:
+        editDialogComponent = UserEditDialogComponent
+        size = 'lg'
+        break
+      case DataType.Group:
+        editDialogComponent = GroupEditDialogComponent
+        size = 'lg'
+        break
+      case DataType.MailAccount:
+        editDialogComponent = MailAccountEditDialogComponent
+        size = 'xl'
+        break
+      case DataType.MailRule:
+        editDialogComponent = MailRuleEditDialogComponent
+        size = 'xl'
+        break
+      case DataType.CustomField:
+        editDialogComponent = CustomFieldEditDialogComponent
+        break
+      case DataType.Workflow:
+        editDialogComponent = WorkflowEditDialogComponent
+        size = 'xl'
+        break
+    }
+
+    if (filterRuleType) {
+      this.documentListViewService.quickFilter([
+        { rule_type: filterRuleType, value: object.id.toString() },
+      ])
+    } else if (editDialogComponent) {
+      const modalRef: NgbModalRef = this.modalService.open(
+        editDialogComponent,
+        { size }
+      )
+      modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
+      modalRef.componentInstance.object = object
+      modalRef.componentInstance.succeeded.subscribe(() => {
+        this.toastService.showInfo($localize`Successfully updated object.`)
+      })
+      modalRef.componentInstance.failed.subscribe((e) => {
+        this.toastService.showError($localize`Error occurred saving object.`, e)
+      })
+    }
+  }
+
+  public secondaryAction(type: string, object: ObjectWithId) {
+    this.reset(true)
+    let editDialogComponent: any
+    let size: string = 'md'
+    switch (type) {
+      case DataType.Document:
+        window.open(this.documentService.getDownloadUrl(object.id))
+        break
+      case DataType.Correspondent:
+        editDialogComponent = CorrespondentEditDialogComponent
+        break
+      case DataType.DocumentType:
+        editDialogComponent = DocumentTypeEditDialogComponent
+        break
+      case DataType.StoragePath:
+        editDialogComponent = StoragePathEditDialogComponent
+        break
+      case DataType.Tag:
+        editDialogComponent = TagEditDialogComponent
+        break
+    }
+
+    if (editDialogComponent) {
+      const modalRef: NgbModalRef = this.modalService.open(
+        editDialogComponent,
+        { size }
+      )
+      modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
+      modalRef.componentInstance.object = object
+      modalRef.componentInstance.succeeded.subscribe(() => {
+        this.toastService.showInfo($localize`Successfully updated object.`)
+      })
+      modalRef.componentInstance.failed.subscribe((e) => {
+        this.toastService.showError($localize`Error occurred saving object.`, e)
+      })
+    }
+  }
+
+  private reset(close: boolean = false) {
+    this.queryDebounce.next(null)
+    this.searchResults = null
+    this.currentItemIndex = -1
+    if (close) {
+      this.resultsDropdown.close()
+    }
+  }
+
+  private setCurrentItem() {
+    // QueryLists do not always reflect the current DOM order, so we need to find the actual element
+    // Yes, using some vanilla JS
+    const result: HTMLElement = this.resultItems.first.nativeElement.parentNode
+      .querySelectorAll('.dropdown-item')
+      .item(this.currentItemIndex)
+    this.domIndex = this.resultItems
+      .toArray()
+      .indexOf(this.resultItems.find((item) => item.nativeElement === result))
+    const item: ElementRef = this.primaryButtons.get(this.domIndex)
+    item.nativeElement.focus()
+  }
+
+  onItemHover(event: MouseEvent) {
+    const item: ElementRef = this.resultItems
+      .toArray()
+      .find((item) => item.nativeElement === event.currentTarget)
+    this.currentItemIndex = this.resultItems.toArray().indexOf(item)
+    this.setCurrentItem()
+  }
+
+  onButtonHover(event: MouseEvent) {
+    ;(event.currentTarget as HTMLElement).focus()
+  }
+
+  public searchInputKeyDown(event: KeyboardEvent) {
+    if (
+      event.key === 'ArrowDown' &&
+      this.searchResults?.total &&
+      this.resultsDropdown.isOpen()
+    ) {
+      event.preventDefault()
+      this.currentItemIndex = 0
+      this.setCurrentItem()
+    } else if (
+      event.key === 'ArrowUp' &&
+      this.searchResults?.total &&
+      this.resultsDropdown.isOpen()
+    ) {
+      event.preventDefault()
+      this.currentItemIndex = this.searchResults.total - 1
+      this.setCurrentItem()
+    } else if (
+      event.key === 'Enter' &&
+      this.searchResults?.total === 1 &&
+      this.resultsDropdown.isOpen()
+    ) {
+      this.primaryButtons.first.nativeElement.click()
+      this.searchInput.nativeElement.blur()
+    } else if (
+      event.key === 'Enter' &&
+      this.searchResults?.total &&
+      !this.resultsDropdown.isOpen()
+    ) {
+      this.resultsDropdown.open()
+    } else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
+      if (this.query?.length) {
+        this.reset(true)
+      } else {
+        this.searchInput.nativeElement.blur()
+      }
+    }
+  }
+
+  dropdownKeyDown(event: KeyboardEvent) {
+    if (
+      this.searchResults?.total &&
+      this.resultsDropdown.isOpen() &&
+      document.activeElement !== this.searchInput.nativeElement
+    ) {
+      if (event.key === 'ArrowDown') {
+        event.preventDefault()
+        if (this.currentItemIndex < this.searchResults.total - 1) {
+          this.currentItemIndex++
+          this.setCurrentItem()
+        } else {
+          this.searchInput.nativeElement.focus()
+          this.currentItemIndex = -1
+        }
+      } else if (event.key === 'ArrowUp') {
+        event.preventDefault()
+        if (this.currentItemIndex > 0) {
+          this.currentItemIndex--
+          this.setCurrentItem()
+        } else {
+          this.searchInput.nativeElement.focus()
+          this.currentItemIndex = -1
+        }
+      } else if (event.key === 'ArrowRight') {
+        event.preventDefault()
+        this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
+      } else if (event.key === 'ArrowLeft') {
+        event.preventDefault()
+        this.primaryButtons.get(this.domIndex).nativeElement.focus()
+      }
+    }
+  }
+
+  public onDropdownOpenChange(open: boolean) {
+    if (!open) {
+      this.reset()
+    }
+  }
+
+  public disablePrimaryButton(type: DataType, object: ObjectWithId): boolean {
+    if (
+      [
+        DataType.Workflow,
+        DataType.CustomField,
+        DataType.Group,
+        DataType.User,
+      ].includes(type)
+    ) {
+      return !this.permissionsService.currentUserHasObjectPermissions(
+        PermissionAction.Change,
+        object
+      )
+    }
+
+    return false
+  }
+
+  public disableSecondaryButton(type: DataType, object: ObjectWithId): boolean {
+    if (DataType.Document === type) {
+      return false
+    }
+
+    return !this.permissionsService.currentUserHasObjectPermissions(
+      PermissionAction.Change,
+      object
+    )
+  }
+
+  runAdvanedSearch() {
+    this.documentListViewService.quickFilter([
+      { rule_type: FILTER_FULLTEXT_QUERY, value: this.query },
+    ])
+    this.reset(true)
+  }
+}
index fe377cc7074e4c99186c188d51c814a8460ddf8f..a285144f48f8067ca305dad91dc0261818b20c7a 100644 (file)
@@ -26,6 +26,7 @@ import { TagComponent } from '../tag/tag.component'
 import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { HotKeyService } from 'src/app/services/hot-key.service'
 
 const items: Tag[] = [
   {
@@ -53,6 +54,7 @@ let selectionModel: FilterableDropdownSelectionModel
 describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
   let component: FilterableDropdownComponent
   let fixture: ComponentFixture<FilterableDropdownComponent>
+  let hotkeyService: HotKeyService
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
@@ -72,6 +74,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
       ],
     }).compileComponents()
 
+    hotkeyService = TestBed.inject(HotKeyService)
     fixture = TestBed.createComponent(FilterableDropdownComponent)
     component = fixture.componentInstance
     selectionModel = new FilterableDropdownSelectionModel()
@@ -577,4 +580,14 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
     expect(selectionModel.getSelectedItems()).toEqual([items[0]])
     expect(selectionModel.getExcludedItems()).toEqual([items[1]])
   })
+
+  it('should support shortcut keys', () => {
+    component.items = items
+    component.icon = 'tag-fill'
+    component.shortcutKey = 't'
+    fixture.detectChanges()
+    const openSpy = jest.spyOn(component.dropdown, 'open')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
+    expect(openSpy).toHaveBeenCalled()
+  })
 })
index 4f39d32c339e9ed7902faf264830791223652ec6..4a3c70953cf69e0b85870e28a8e3c8f648063fe5 100644 (file)
@@ -5,14 +5,17 @@ import {
   Output,
   ElementRef,
   ViewChild,
+  OnInit,
+  OnDestroy,
 } from '@angular/core'
 import { FilterPipe } from 'src/app/pipes/filter.pipe'
 import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
 import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component'
 import { MatchingModel } from 'src/app/data/matching-model'
-import { Subject } from 'rxjs'
+import { Subject, filter, take, takeUntil } from 'rxjs'
 import { SelectionDataItem } from 'src/app/services/rest/document.service'
 import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
+import { HotKeyService } from 'src/app/services/hot-key.service'
 
 export interface ChangedItems {
   itemsToAdd: MatchingModel[]
@@ -322,7 +325,7 @@ export class FilterableDropdownSelectionModel {
   templateUrl: './filterable-dropdown.component.html',
   styleUrls: ['./filterable-dropdown.component.scss'],
 })
-export class FilterableDropdownComponent {
+export class FilterableDropdownComponent implements OnDestroy, OnInit {
   @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
   @ViewChild('dropdown') dropdown: NgbDropdown
   @ViewChild('buttonItems') buttonItems: ElementRef
@@ -419,6 +422,9 @@ export class FilterableDropdownComponent {
   @Input()
   documentCounts: SelectionDataItem[]
 
+  @Input()
+  shortcutKey: string
+
   get name(): string {
     return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
   }
@@ -427,12 +433,39 @@ export class FilterableDropdownComponent {
 
   private keyboardIndex: number
 
-  constructor(private filterPipe: FilterPipe) {
+  private unsubscribeNotifier: Subject<any> = new Subject()
+
+  constructor(
+    private filterPipe: FilterPipe,
+    private hotkeyService: HotKeyService
+  ) {
     this.selectionModelChange.subscribe((updatedModel) => {
       this.modelIsDirty = updatedModel.isDirty()
     })
   }
 
+  ngOnInit(): void {
+    if (this.shortcutKey) {
+      this.hotkeyService
+        .addShortcut({
+          keys: this.shortcutKey,
+          description: $localize`Open ${this.title} filter`,
+        })
+        .pipe(
+          takeUntil(this.unsubscribeNotifier),
+          filter(() => !this.disabled)
+        )
+        .subscribe(() => {
+          this.dropdown.open()
+        })
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.unsubscribeNotifier.next(true)
+    this.unsubscribeNotifier.complete()
+  }
+
   applyClicked() {
     if (this.selectionModel.isDirty()) {
       this.dropdown.close()
diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.html b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.html
new file mode 100644 (file)
index 0000000..c98a96e
--- /dev/null
@@ -0,0 +1,23 @@
+<div class="modal-header">
+    <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
+    <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
+</div>
+<div class="modal-body">
+    <table class="table">
+        <tbody>
+            @for (key of hotkeys.entries(); track key[0]) {
+                <tr>
+                  <td>{{ key[1] }}</td>
+                  <td class="d-flex justify-content-end">
+                    <kbd [innerHTML]="formatKey(key[0])"></kbd>
+                    @if (key[0].includes('control')) {
+                        &nbsp;(macOS&nbsp;<kbd [innerHTML]="formatKey(key[0], true)"></kbd>)
+                    }
+                  </td>
+                </tr>
+            }
+        </tbody>
+    </table>
+</div>
+<div class="modal-footer">
+</div>
diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.scss b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.spec.ts b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..a47e516
--- /dev/null
@@ -0,0 +1,35 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { HotkeyDialogComponent } from './hotkey-dialog.component'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+
+describe('HotkeyDialogComponent', () => {
+  let component: HotkeyDialogComponent
+  let fixture: ComponentFixture<HotkeyDialogComponent>
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [HotkeyDialogComponent],
+      providers: [NgbActiveModal],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(HotkeyDialogComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should create', () => {
+    expect(component).toBeTruthy()
+  })
+
+  it('should support close', () => {
+    const closeSpy = jest.spyOn(component.activeModal, 'close')
+    component.close()
+    expect(closeSpy).toHaveBeenCalled()
+  })
+
+  it('should format keys', () => {
+    expect(component.formatKey('control.a')).toEqual('&#8963; + a') // ⌃ + a
+    expect(component.formatKey('control.a', true)).toEqual('&#8984; + a') // ⌘ + a
+  })
+})
diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts
new file mode 100644 (file)
index 0000000..f89d5be
--- /dev/null
@@ -0,0 +1,38 @@
+import { Component } from '@angular/core'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+
+const SYMBOLS = {
+  meta: '&#8984;', // ⌘
+  control: '&#8963;', // ⌃
+  shift: '&#8679;', // ⇧
+  left: '&#8592;', // ←
+  right: '&#8594;', // →
+  up: '&#8593;', // ↑
+  down: '&#8595;', // ↓
+}
+
+@Component({
+  selector: 'pngx-hotkey-dialog',
+  templateUrl: './hotkey-dialog.component.html',
+  styleUrl: './hotkey-dialog.component.scss',
+})
+export class HotkeyDialogComponent {
+  public title: string = $localize`Keyboard shortcuts`
+  public hotkeys: Map<string, string> = new Map()
+
+  constructor(public activeModal: NgbActiveModal) {}
+
+  public close(): void {
+    this.activeModal.close()
+  }
+
+  public formatKey(key: string, macOS: boolean = false): string {
+    if (macOS) {
+      key = key.replace('control', 'meta')
+    }
+    return key
+      .split('.')
+      .map((k) => SYMBOLS[k] || k)
+      .join(' + ')
+  }
+}
index b439c770f35565786bb5d50b3728cf38d0c598ae..a0c02a688a899867616ed5ed4712fb7d7b0717ea 100644 (file)
@@ -12,8 +12,12 @@ import {
 } from '@angular/core/testing'
 import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 import { By } from '@angular/platform-browser'
-import { Router, ActivatedRoute, convertToParamMap } from '@angular/router'
-import { RouterTestingModule } from '@angular/router/testing'
+import {
+  Router,
+  ActivatedRoute,
+  convertToParamMap,
+  RouterModule,
+} from '@angular/router'
 import {
   NgbModal,
   NgbModule,
@@ -253,7 +257,7 @@ describe('DocumentDetailComponent', () => {
         DatePipe,
       ],
       imports: [
-        RouterTestingModule.withRoutes(routes),
+        RouterModule.forRoot(routes),
         HttpClientTestingModule,
         NgbModule,
         NgSelectModule,
@@ -1126,6 +1130,35 @@ describe('DocumentDetailComponent', () => {
     req.flush(true)
   })
 
+  it('should support keyboard shortcuts', () => {
+    initNormally()
+
+    jest.spyOn(component, 'hasNext').mockReturnValue(true)
+    const nextSpy = jest.spyOn(component, 'nextDoc')
+    document.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
+    )
+    expect(nextSpy).toHaveBeenCalled()
+
+    jest.spyOn(component, 'hasPrevious').mockReturnValue(true)
+    const prevSpy = jest.spyOn(component, 'previousDoc')
+    document.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'arrowleft', ctrlKey: true })
+    )
+    expect(prevSpy).toHaveBeenCalled()
+
+    jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
+    const saveSpy = jest.spyOn(component, 'save')
+    document.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
+    )
+    expect(saveSpy).toHaveBeenCalled()
+
+    const closeSpy = jest.spyOn(component, 'close')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
+    expect(closeSpy).toHaveBeenCalled()
+  })
+
   function initNormally() {
     jest
       .spyOn(activatedRoute, 'paramMap', 'get')
index aea7ca47229b01299385c6e49a75e6ad7f1c35ff..5e3b6c2a73e77fe33e839c0d42438c38add6a5b6 100644 (file)
@@ -69,6 +69,7 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
 import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
 import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
 import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
+import { HotKeyService } from 'src/app/services/hot-key.service'
 
 enum DocumentDetailNavIDs {
   Details = 1,
@@ -201,7 +202,8 @@ export class DocumentDetailComponent
     private permissionsService: PermissionsService,
     private userService: UserService,
     private customFieldsService: CustomFieldsService,
-    private http: HttpClient
+    private http: HttpClient,
+    private hotKeyService: HotKeyService
   ) {
     super()
   }
@@ -455,6 +457,40 @@ export class DocumentDetailComponent
         })
       }
     })
+
+    this.hotKeyService
+      .addShortcut({
+        keys: 'control.arrowright',
+        description: $localize`Next document`,
+      })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        if (this.hasNext()) this.nextDoc()
+      })
+
+    this.hotKeyService
+      .addShortcut({
+        keys: 'control.arrowleft',
+        description: $localize`Previous document`,
+      })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        if (this.hasPrevious()) this.previousDoc()
+      })
+
+    this.hotKeyService
+      .addShortcut({ keys: 'escape', description: $localize`Close document` })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        this.close()
+      })
+
+    this.hotKeyService
+      .addShortcut({ keys: 'control.s', description: $localize`Save document` })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        if (this.openDocumentService.isDirty(this.document)) this.save()
+      })
   }
 
   ngOnDestroy(): void {
index e10d00a7aa034bc9d52ea7de164cffb53f45c3b8..014b579e1667b43493df83b6097d7486c1ae92ef 100644 (file)
@@ -21,7 +21,7 @@
             <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
               filterPlaceholder="Filter tags" i18n-filterPlaceholder
               [items]="tags"
-              [disabled]="!userCanEditAll"
+              [disabled]="!userCanEditAll || disabled"
               [editing]="true"
               [manyToOne]="true"
               [applyOnClose]="applyOnClose"
               (opened)="openTagsDropdown()"
               [(selectionModel)]="tagSelectionModel"
               [documentCounts]="tagDocumentCounts"
-              (apply)="setTags($event)">
+              (apply)="setTags($event)"
+              shortcutKey="t">
             </pngx-filterable-dropdown>
           }
           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
             <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
               filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
               [items]="correspondents"
-              [disabled]="!userCanEditAll"
+              [disabled]="!userCanEditAll || disabled"
               [editing]="true"
               [applyOnClose]="applyOnClose"
               [createRef]="createCorrespondent.bind(this)"
               (opened)="openCorrespondentDropdown()"
               [(selectionModel)]="correspondentSelectionModel"
               [documentCounts]="correspondentDocumentCounts"
-              (apply)="setCorrespondents($event)">
+              (apply)="setCorrespondents($event)"
+              shortcutKey="y">
             </pngx-filterable-dropdown>
           }
           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
             <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
               filterPlaceholder="Filter document types" i18n-filterPlaceholder
               [items]="documentTypes"
-              [disabled]="!userCanEditAll"
+              [disabled]="!userCanEditAll || disabled"
               [editing]="true"
               [applyOnClose]="applyOnClose"
               [createRef]="createDocumentType.bind(this)"
               (opened)="openDocumentTypeDropdown()"
               [(selectionModel)]="documentTypeSelectionModel"
               [documentCounts]="documentTypeDocumentCounts"
-              (apply)="setDocumentTypes($event)">
+              (apply)="setDocumentTypes($event)"
+              shortcutKey="u">
             </pngx-filterable-dropdown>
           }
           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
             <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
               filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
               [items]="storagePaths"
-              [disabled]="!userCanEditAll"
+              [disabled]="!userCanEditAll || disabled"
               [editing]="true"
               [applyOnClose]="applyOnClose"
               [createRef]="createStoragePath.bind(this)"
               (opened)="openStoragePathDropdown()"
               [(selectionModel)]="storagePathsSelectionModel"
               [documentCounts]="storagePathDocumentCounts"
-              (apply)="setStoragePaths($event)">
+              (apply)="setStoragePaths($event)"
+              shortcutKey="i">
             </pngx-filterable-dropdown>
           }
           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
index 1d3b4d0a9d71d40121620acb3f8711643c9a057b..56c04816552f11a4e14268795ccdcff08bf13534 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
+import { Component, Input, OnDestroy, OnInit } from '@angular/core'
 import { Tag } from 'src/app/data/tag'
 import { Correspondent } from 'src/app/data/correspondent'
 import { DocumentType } from 'src/app/data/document-type'
@@ -80,6 +80,9 @@ export class BulkEditorComponent
     downloadUseFormatting: new FormControl(false),
   })
 
+  @Input()
+  public disabled: boolean = false
+
   constructor(
     private documentTypeService: DocumentTypeService,
     private tagService: TagService,
index 25e28a7a228386b18c4e68975592a47512ef5923..3030909c46d14572f0215ef920aa69c1ac0b3ec6 100644 (file)
@@ -96,8 +96,8 @@
 </pngx-page-header>
 
 <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
-  <pngx-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
-  <pngx-bulk-editor [hidden]="!isBulkEditing"></pngx-bulk-editor>
+  <pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
+  <pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
 </div>
 
 
index 4c2f48765a63cd5a89fdd5e72180644309c16d9e..984b27c69295e1110d21b55d493ef068cbd5ecde 100644 (file)
@@ -19,6 +19,7 @@ import {
   NgbModalRef,
   NgbPopoverModule,
   NgbTooltipModule,
+  NgbTypeaheadModule,
 } from '@ng-bootstrap/ng-bootstrap'
 import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
 import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -153,6 +154,7 @@ describe('DocumentListComponent', () => {
         NgbTooltipModule,
         NgxBootstrapIconsModule.pick(allIcons),
         NgSelectModule,
+        NgbTypeaheadModule,
       ],
     }).compileComponents()
 
@@ -654,4 +656,42 @@ describe('DocumentListComponent', () => {
       'Custom Field 1'
     )
   })
+
+  it('should support hotkeys', () => {
+    fixture.detectChanges()
+    const resetSpy = jest.spyOn(component['filterEditor'], 'resetSelected')
+    jest.spyOn(component, 'isFiltered', 'get').mockReturnValue(true)
+    component.clickTag(1)
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
+    expect(resetSpy).toHaveBeenCalled()
+
+    jest
+      .spyOn(documentListService, 'selected', 'get')
+      .mockReturnValue(new Set([1]))
+    const clearSelectedSpy = jest.spyOn(documentListService, 'selectNone')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
+    expect(clearSelectedSpy).toHaveBeenCalled()
+
+    const selectAllSpy = jest.spyOn(documentListService, 'selectAll')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
+    expect(selectAllSpy).toHaveBeenCalled()
+
+    const selectPageSpy = jest.spyOn(documentListService, 'selectPage')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'p' }))
+    expect(selectPageSpy).toHaveBeenCalled()
+
+    jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
+    fixture.detectChanges()
+    const detailSpy = jest.spyOn(component, 'openDocumentDetail')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
+    expect(detailSpy).toHaveBeenCalledWith(docs[0])
+
+    jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
+    jest
+      .spyOn(documentListService, 'selected', 'get')
+      .mockReturnValue(new Set([docs[1].id]))
+    fixture.detectChanges()
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
+    expect(detailSpy).toHaveBeenCalledWith(docs[1].id)
+  })
 })
index d56f9c508ec1be6b226dfdebe00337ea93eddde8..5a97ed6fa2d248776f215665a2f7e5832a4ad32d 100644 (file)
@@ -32,6 +32,7 @@ import { ToastService } from 'src/app/services/toast.service'
 import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
 import { FilterEditorComponent } from './filter-editor/filter-editor.component'
 import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'
+import { HotKeyService } from 'src/app/services/hot-key.service'
 
 @Component({
   selector: 'pngx-document-list',
@@ -55,6 +56,7 @@ export class DocumentListComponent
     private consumerStatusService: ConsumerStatusService,
     public openDocumentsService: OpenDocumentsService,
     public settingsService: SettingsService,
+    private hotKeyService: HotKeyService,
     public permissionService: PermissionsService
   ) {
     super()
@@ -215,6 +217,50 @@ export class DocumentListComponent
           this.unmodifiedFilterRules = []
         }
       })
+
+    this.hotKeyService
+      .addShortcut({
+        keys: 'escape',
+        description: $localize`Reset filters / selection`,
+      })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        if (this.list.selected.size > 0) {
+          this.list.selectNone()
+        } else if (this.isFiltered) {
+          this.filterEditor.resetSelected()
+        }
+      })
+
+    this.hotKeyService
+      .addShortcut({ keys: 'a', description: $localize`Select all` })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        this.list.selectAll()
+      })
+
+    this.hotKeyService
+      .addShortcut({ keys: 'p', description: $localize`Select page` })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        this.list.selectPage()
+      })
+
+    this.hotKeyService
+      .addShortcut({
+        keys: 'o',
+        description: $localize`Open first [selected] document`,
+      })
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(() => {
+        if (this.list.documents.length > 0) {
+          if (this.list.selected.size > 0) {
+            this.openDocumentDetail(Array.from(this.list.selected)[0])
+          } else {
+            this.openDocumentDetail(this.list.documents[0])
+          }
+        }
+      })
   }
 
   ngOnDestroy() {
@@ -297,8 +343,11 @@ export class DocumentListComponent
     })
   }
 
-  openDocumentDetail(document: Document) {
-    this.router.navigate(['documents', document.id])
+  openDocumentDetail(document: Document | number) {
+    this.router.navigate([
+      'documents',
+      typeof document === 'number' ? document : document.id,
+    ])
   }
 
   toggleSelected(document: Document, event: MouseEvent): void {
index 451f84f9fce0ffacbd65b087312d93e7e7e97bce..99ef0cdc74155064db99b138ce66ab13490b7340 100644 (file)
             <i-bs width="1em" height="1em" name="x"></i-bs>
           </button>
         }
-        <input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup)="textFilterKeyup($event)" [readonly]="textFilterTarget === 'fulltext-morelike'">
+        <input #textFilterInput class="form-control form-control-sm" type="text"
+        [disabled]="textFilterModifierIsNull"
+        [(ngModel)]="textFilter"
+        (keyup)="textFilterKeyup($event)"
+        [ngbTypeahead]="searchAutoComplete"
+        (selectItem)="itemSelected($event)"
+        [readonly]="textFilterTarget === 'fulltext-morelike'">
       </div>
     </div>
   </div>
@@ -38,7 +44,9 @@
           (selectionModelChange)="updateRules()"
           (opened)="onTagsDropdownOpen()"
           [documentCounts]="tagDocumentCounts"
-          [allowSelectNone]="true"></pngx-filterable-dropdown>
+          [allowSelectNone]="true"
+          [disabled]="disabled"
+          shortcutKey="t"></pngx-filterable-dropdown>
         }
         @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
           <pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
@@ -48,7 +56,9 @@
           (selectionModelChange)="updateRules()"
           (opened)="onCorrespondentDropdownOpen()"
           [documentCounts]="correspondentDocumentCounts"
-          [allowSelectNone]="true"></pngx-filterable-dropdown>
+          [allowSelectNone]="true"
+          [disabled]="disabled"
+          shortcutKey="y"></pngx-filterable-dropdown>
         }
         @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
             <pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
@@ -58,7 +68,9 @@
             (selectionModelChange)="updateRules()"
             (opened)="onDocumentTypeDropdownOpen()"
             [documentCounts]="documentTypeDocumentCounts"
-            [allowSelectNone]="true"></pngx-filterable-dropdown>
+            [allowSelectNone]="true"
+            [disabled]="disabled"
+            shortcutKey="u"></pngx-filterable-dropdown>
         }
         @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePaths.length > 0) {
           <pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
@@ -68,7 +80,9 @@
           (selectionModelChange)="updateRules()"
           (opened)="onStoragePathDropdownOpen()"
           [documentCounts]="storagePathDocumentCounts"
-          [allowSelectNone]="true"></pngx-filterable-dropdown>
+          [allowSelectNone]="true"
+          [disabled]="disabled"
+          shortcutKey="i"></pngx-filterable-dropdown>
         }
 
         @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
index f52907bf2839e77474e6c5aea0b5d855034070ec..0fcbbc299521a31df34b2d7fa73f88965908d608 100644 (file)
@@ -11,14 +11,14 @@ import {
 } from '@angular/core/testing'
 import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 import { By } from '@angular/platform-browser'
-import { RouterTestingModule } from '@angular/router/testing'
 import {
   NgbDropdownModule,
   NgbDatepickerModule,
   NgbDropdownItem,
+  NgbTypeaheadModule,
 } from '@ng-bootstrap/ng-bootstrap'
 import { NgSelectComponent } from '@ng-select/ng-select'
-import { of } from 'rxjs'
+import { of, throwError } from 'rxjs'
 import {
   FILTER_TITLE,
   FILTER_TITLE_CONTENT,
@@ -92,6 +92,8 @@ import {
 import { environment } from 'src/environments/environment'
 import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
 import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { RouterModule } from '@angular/router'
+import { SearchService } from 'src/app/services/rest/search.service'
 
 const tags: Tag[] = [
   {
@@ -164,6 +166,7 @@ describe('FilterEditorComponent', () => {
   let settingsService: SettingsService
   let permissionsService: PermissionsService
   let httpTestingController: HttpTestingController
+  let searchService: SearchService
 
   beforeEach(fakeAsync(() => {
     TestBed.configureTestingModule({
@@ -222,12 +225,13 @@ describe('FilterEditorComponent', () => {
       ],
       imports: [
         HttpClientTestingModule,
-        RouterTestingModule,
+        RouterModule,
         NgbDropdownModule,
         FormsModule,
         ReactiveFormsModule,
         NgbDatepickerModule,
         NgxBootstrapIconsModule.pick(allIcons),
+        NgbTypeaheadModule,
       ],
     }).compileComponents()
 
@@ -235,6 +239,7 @@ describe('FilterEditorComponent', () => {
     settingsService = TestBed.inject(SettingsService)
     settingsService.currentUser = users[0]
     permissionsService = TestBed.inject(PermissionsService)
+    searchService = TestBed.inject(SearchService)
     jest
       .spyOn(permissionsService, 'currentUserCan')
       .mockImplementation((action, type) => {
@@ -2034,6 +2039,11 @@ describe('FilterEditorComponent', () => {
       new KeyboardEvent('keyup', { key: 'Escape' })
     )
     expect(component.textFilter).toEqual('')
+    const blurSpy = jest.spyOn(component.textFilterInput.nativeElement, 'blur')
+    component.textFilterInput.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Escape' })
+    )
+    expect(blurSpy).toHaveBeenCalled()
   })
 
   it('should adjust text filter targets if more like search', () => {
@@ -2044,4 +2054,40 @@ describe('FilterEditorComponent', () => {
       name: $localize`More like`,
     })
   })
+
+  it('should call autocomplete endpoint on input', fakeAsync(() => {
+    component.textFilterTarget = 'fulltext-query' // TEXT_FILTER_TARGET_FULLTEXT_QUERY
+    const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
+    component.searchAutoComplete(of('hello')).subscribe()
+    tick(250)
+    expect(autocompleteSpy).toHaveBeenCalled()
+
+    component.searchAutoComplete(of('hello world 1')).subscribe()
+    tick(250)
+    expect(autocompleteSpy).toHaveBeenCalled()
+  }))
+
+  it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
+    component.textFilterTarget = 'fulltext-query' // TEXT_FILTER_TARGET_FULLTEXT_QUERY
+    const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
+    serviceAutocompleteSpy.mockReturnValue(
+      throwError(() => new Error('autcomplete failed'))
+    )
+    // serviceAutocompleteSpy.mockReturnValue(of([' world']))
+    let result
+    component.searchAutoComplete(of('hello')).subscribe((res) => {
+      result = res
+    })
+    tick(250)
+    expect(serviceAutocompleteSpy).toHaveBeenCalled()
+    expect(result).toEqual([])
+  }))
+
+  it('should support choosing a autocomplete item', () => {
+    expect(component.textFilter).toBeNull()
+    component.itemSelected({ item: 'hello', preventDefault: () => true })
+    expect(component.textFilter).toEqual('hello ')
+    component.itemSelected({ item: 'world', preventDefault: () => true })
+    expect(component.textFilter).toEqual('hello world ')
+  })
 })
index b59ae53f19fbf8baccb09d5659a8bd3d8714b2b0..994de01f02bd1cedcaf5ff1d13b0629b36f77005 100644 (file)
@@ -7,12 +7,21 @@ import {
   OnDestroy,
   ViewChild,
   ElementRef,
+  AfterViewInit,
 } from '@angular/core'
 import { Tag } from 'src/app/data/tag'
 import { Correspondent } from 'src/app/data/correspondent'
 import { DocumentType } from 'src/app/data/document-type'
-import { Subject, Subscription } from 'rxjs'
-import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'
+import { Observable, Subject, Subscription, from } from 'rxjs'
+import {
+  catchError,
+  debounceTime,
+  distinctUntilChanged,
+  filter,
+  map,
+  switchMap,
+  takeUntil,
+} from 'rxjs/operators'
 import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
 import { TagService } from 'src/app/services/rest/tag.service'
 import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
@@ -82,6 +91,7 @@ import {
 import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
 import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 import { CustomField } from 'src/app/data/custom-field'
+import { SearchService } from 'src/app/services/rest/search.service'
 
 const TEXT_FILTER_TARGET_TITLE = 'title'
 const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@@ -169,7 +179,7 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
 })
 export class FilterEditorComponent
   extends ComponentWithPermissions
-  implements OnInit, OnDestroy
+  implements OnInit, OnDestroy, AfterViewInit
 {
   generateFilterName() {
     if (this.filterRules.length == 1) {
@@ -251,7 +261,8 @@ export class FilterEditorComponent
     private documentService: DocumentService,
     private storagePathService: StoragePathService,
     public permissionsService: PermissionsService,
-    private customFieldService: CustomFieldsService
+    private customFieldService: CustomFieldsService,
+    private searchService: SearchService
   ) {
     super()
   }
@@ -275,6 +286,8 @@ export class FilterEditorComponent
   _moreLikeId: number
   _moreLikeDoc: Document
 
+  unsubscribeNotifier: Subject<any> = new Subject()
+
   get textFilterTargets() {
     if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
       return DEFAULT_TEXT_FILTER_TARGET_OPTIONS.concat([
@@ -944,7 +957,9 @@ export class FilterEditorComponent
   }
 
   textFilterDebounce: Subject<string>
-  subscription: Subscription
+
+  @Input()
+  public disabled: boolean = false
 
   ngOnInit() {
     if (
@@ -1000,19 +1015,29 @@ export class FilterEditorComponent
 
     this.textFilterDebounce = new Subject<string>()
 
-    this.subscription = this.textFilterDebounce
+    this.textFilterDebounce
       .pipe(
+        takeUntil(this.unsubscribeNotifier),
         debounceTime(400),
         distinctUntilChanged(),
         filter((query) => !query.length || query.length > 2)
       )
-      .subscribe((text) => this.updateTextFilter(text))
+      .subscribe((text) =>
+        this.updateTextFilter(
+          text,
+          this.textFilterTarget !== TEXT_FILTER_TARGET_FULLTEXT_QUERY
+        )
+      )
 
     if (this._textFilter) this.documentService.searchQuery = this._textFilter
   }
 
+  ngAfterViewInit() {
+    this.textFilterInput.nativeElement.focus()
+  }
+
   ngOnDestroy() {
-    this.textFilterDebounce.complete()
+    this.unsubscribeNotifier.next(true)
   }
 
   resetSelected() {
@@ -1057,10 +1082,12 @@ export class FilterEditorComponent
     this.customFieldSelectionModel.apply()
   }
 
-  updateTextFilter(text) {
+  updateTextFilter(text, updateRules = true) {
     this._textFilter = text
-    this.documentService.searchQuery = text
-    this.updateRules()
+    if (updateRules) {
+      this.documentService.searchQuery = text
+      this.updateRules()
+    }
   }
 
   textFilterKeyup(event: KeyboardEvent) {
@@ -1071,8 +1098,12 @@ export class FilterEditorComponent
       if (filterString.length) {
         this.updateTextFilter(filterString)
       }
-    } else if (event.key == 'Escape') {
-      this.resetTextField()
+    } else if (event.key === 'Escape') {
+      if (this._textFilter?.length) {
+        this.resetTextField()
+      } else {
+        this.textFilterInput.nativeElement.blur()
+      }
     }
   }
 
@@ -1105,4 +1136,40 @@ export class FilterEditorComponent
       this.updateRules()
     }
   }
+
+  searchAutoComplete = (text$: Observable<string>) =>
+    text$.pipe(
+      debounceTime(200),
+      distinctUntilChanged(),
+      filter(() => this.textFilterTarget === TEXT_FILTER_TARGET_FULLTEXT_QUERY),
+      map((term) => {
+        if (term.lastIndexOf(' ') != -1) {
+          return term.substring(term.lastIndexOf(' ') + 1)
+        } else {
+          return term
+        }
+      }),
+      switchMap((term) =>
+        term.length < 2
+          ? from([[]])
+          : this.searchService.autocomplete(term).pipe(
+              catchError(() => {
+                return from([[]])
+              })
+            )
+      )
+    )
+
+  itemSelected(event) {
+    event.preventDefault()
+    let currentSearch: string = this._textFilter ?? ''
+    let lastSpaceIndex = currentSearch.lastIndexOf(' ')
+    if (lastSpaceIndex != -1) {
+      currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
+      currentSearch += event.item + ' '
+    } else {
+      currentSearch = event.item + ' '
+    }
+    this.updateTextFilter(currentSearch)
+  }
 }
diff --git a/src-ui/src/app/data/datatype.ts b/src-ui/src/app/data/datatype.ts
new file mode 100644 (file)
index 0000000..288186c
--- /dev/null
@@ -0,0 +1,14 @@
+export enum DataType {
+  Document = 'document',
+  SavedView = 'saved_view',
+  Correspondent = 'correspondent',
+  DocumentType = 'document_type',
+  StoragePath = 'storage_path',
+  Tag = 'tag',
+  User = 'user',
+  Group = 'group',
+  MailAccount = 'mail_account',
+  MailRule = 'mail_rule',
+  CustomField = 'custom_field',
+  Workflow = 'workflow',
+}
index cd4700096438cfc617b80ae8f95434ab0f154c3e..9a87a421c0bc848a6d4a88e100236c1cae739404 100644 (file)
@@ -1,3 +1,5 @@
+import { DataType } from './datatype'
+
 // These correspond to src/documents/models.py and changes here require a DB migration (and vice versa)
 export const FILTER_TITLE = 0
 export const FILTER_CONTENT = 1
@@ -78,57 +80,57 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
     id: FILTER_CORRESPONDENT,
     filtervar: 'correspondent__id',
     isnull_filtervar: 'correspondent__isnull',
-    datatype: 'correspondent',
+    datatype: DataType.Correspondent,
     multi: false,
   },
   {
     id: FILTER_HAS_CORRESPONDENT_ANY,
     filtervar: 'correspondent__id__in',
-    datatype: 'correspondent',
+    datatype: DataType.Correspondent,
     multi: true,
   },
   {
     id: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
     filtervar: 'correspondent__id__none',
-    datatype: 'correspondent',
+    datatype: DataType.Correspondent,
     multi: true,
   },
   {
     id: FILTER_STORAGE_PATH,
     filtervar: 'storage_path__id',
     isnull_filtervar: 'storage_path__isnull',
-    datatype: 'storage_path',
+    datatype: DataType.StoragePath,
     multi: false,
   },
   {
     id: FILTER_HAS_STORAGE_PATH_ANY,
     filtervar: 'storage_path__id__in',
-    datatype: 'storage_path',
+    datatype: DataType.StoragePath,
     multi: true,
   },
   {
     id: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
     filtervar: 'storage_path__id__none',
-    datatype: 'storage_path',
+    datatype: DataType.StoragePath,
     multi: true,
   },
   {
     id: FILTER_DOCUMENT_TYPE,
     filtervar: 'document_type__id',
     isnull_filtervar: 'document_type__isnull',
-    datatype: 'document_type',
+    datatype: DataType.DocumentType,
     multi: false,
   },
   {
     id: FILTER_HAS_DOCUMENT_TYPE_ANY,
     filtervar: 'document_type__id__in',
-    datatype: 'document_type',
+    datatype: DataType.DocumentType,
     multi: true,
   },
   {
     id: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
     filtervar: 'document_type__id__none',
-    datatype: 'document_type',
+    datatype: DataType.DocumentType,
     multi: true,
   },
   {
@@ -141,19 +143,19 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
   {
     id: FILTER_HAS_TAGS_ALL,
     filtervar: 'tags__id__all',
-    datatype: 'tag',
+    datatype: DataType.Tag,
     multi: true,
   },
   {
     id: FILTER_HAS_TAGS_ANY,
     filtervar: 'tags__id__in',
-    datatype: 'tag',
+    datatype: DataType.Tag,
     multi: true,
   },
   {
     id: FILTER_DOES_NOT_HAVE_TAG,
     filtervar: 'tags__id__none',
-    datatype: 'tag',
+    datatype: DataType.Tag,
     multi: true,
   },
   {
index 41f9ba361ba5b5ef2e715890e39a1d4d325c095f..6f8f246ff8458208f6f5ce757224f9acf8643486 100644 (file)
@@ -56,6 +56,7 @@ export const SETTINGS_KEYS = {
   DEFAULT_PERMS_EDIT_GROUPS: 'general-settings:permissions:default-edit-groups',
   DOCUMENT_EDITING_REMOVE_INBOX_TAGS:
     'general-settings:document-editing:remove-inbox-tags',
+  SEARCH_DB_ONLY: 'general-settings:search:db-only',
 }
 
 export const SETTINGS: UiSetting[] = [
@@ -219,4 +220,9 @@ export const SETTINGS: UiSetting[] = [
     type: 'boolean',
     default: false,
   },
+  {
+    key: SETTINGS_KEYS.SEARCH_DB_ONLY,
+    type: 'boolean',
+    default: false,
+  },
 ]
diff --git a/src-ui/src/app/services/hot-key.service.spec.ts b/src-ui/src/app/services/hot-key.service.spec.ts
new file mode 100644 (file)
index 0000000..d23293c
--- /dev/null
@@ -0,0 +1,99 @@
+import { TestBed } from '@angular/core/testing'
+import { EventManager } from '@angular/platform-browser'
+import { DOCUMENT } from '@angular/common'
+
+import { HotKeyService } from './hot-key.service'
+import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
+
+describe('HotKeyService', () => {
+  let service: HotKeyService
+  let eventManager: EventManager
+  let document: Document
+  let modalService: NgbModal
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [HotKeyService, EventManager],
+      imports: [NgbModalModule],
+    })
+    service = TestBed.inject(HotKeyService)
+    eventManager = TestBed.inject(EventManager)
+    document = TestBed.inject(DOCUMENT)
+    modalService = TestBed.inject(NgbModal)
+  })
+
+  it('should support adding a shortcut', () => {
+    const callback = jest.fn()
+    const addEventListenerSpy = jest.spyOn(eventManager, 'addEventListener')
+
+    const observable = service
+      .addShortcut({ keys: 'control.a' })
+      .subscribe(() => {
+        callback()
+      })
+
+    expect(addEventListenerSpy).toHaveBeenCalled()
+
+    document.dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'a', ctrlKey: true })
+    )
+    expect(callback).toHaveBeenCalled()
+
+    //coverage
+    observable.unsubscribe()
+  })
+
+  it('should support adding a shortcut with a description, show modal', () => {
+    const addEventListenerSpy = jest.spyOn(eventManager, 'addEventListener')
+    service
+      .addShortcut({ keys: 'control.a', description: 'Select all' })
+      .subscribe()
+    expect(addEventListenerSpy).toHaveBeenCalled()
+    const modalSpy = jest.spyOn(modalService, 'open')
+    document.dispatchEvent(
+      new KeyboardEvent('keydown', { key: '?', shiftKey: true })
+    )
+    expect(modalSpy).toHaveBeenCalled()
+  })
+
+  it('should ignore keydown events from input elements that dont have a modifier key', () => {
+    // constructor adds a shortcut for shift.?
+    const modalSpy = jest.spyOn(modalService, 'open')
+    const input = document.createElement('input')
+    const textArea = document.createElement('textarea')
+    const event = new KeyboardEvent('keydown', { key: '?', shiftKey: true })
+    jest.spyOn(event, 'target', 'get').mockReturnValue(input)
+    document.dispatchEvent(event)
+    jest.spyOn(event, 'target', 'get').mockReturnValue(textArea)
+    document.dispatchEvent(event)
+    expect(modalSpy).not.toHaveBeenCalled()
+  })
+
+  it('should dismiss all modals on escape and not fire event', () => {
+    const callback = jest.fn()
+    service
+      .addShortcut({ keys: 'escape', description: 'Escape' })
+      .subscribe(callback)
+    const modalSpy = jest.spyOn(modalService, 'open')
+    document.dispatchEvent(
+      new KeyboardEvent('keydown', { key: '?', shiftKey: true })
+    )
+    expect(modalSpy).toHaveBeenCalled()
+    const dismissAllSpy = jest.spyOn(modalService, 'dismissAll')
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+    expect(dismissAllSpy).toHaveBeenCalled()
+    expect(callback).not.toHaveBeenCalled()
+  })
+
+  it('should not fire event on escape when open dropdowns ', () => {
+    const callback = jest.fn()
+    service
+      .addShortcut({ keys: 'escape', description: 'Escape' })
+      .subscribe(callback)
+    const dropdown = document.createElement('div')
+    dropdown.classList.add('dropdown-menu', 'show')
+    document.body.appendChild(dropdown)
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+    expect(callback).not.toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/services/hot-key.service.ts b/src-ui/src/app/services/hot-key.service.ts
new file mode 100644 (file)
index 0000000..22a7575
--- /dev/null
@@ -0,0 +1,98 @@
+import { DOCUMENT } from '@angular/common'
+import { Inject, Injectable } from '@angular/core'
+import { EventManager } from '@angular/platform-browser'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { Observable } from 'rxjs'
+import { HotkeyDialogComponent } from '../components/common/hotkey-dialog/hotkey-dialog.component'
+
+export interface ShortcutOptions {
+  element?: any
+  keys: string
+  description?: string
+}
+
+@Injectable({
+  providedIn: 'root',
+})
+export class HotKeyService {
+  private defaults: Partial<ShortcutOptions> = {
+    element: this.document,
+  }
+
+  private hotkeys: Map<string, string> = new Map()
+
+  constructor(
+    private eventManager: EventManager,
+    @Inject(DOCUMENT) private document: Document,
+    private modalService: NgbModal
+  ) {
+    this.addShortcut({ keys: 'shift.?' }).subscribe(() => {
+      this.openHelpModal()
+    })
+  }
+
+  public addShortcut(options: ShortcutOptions) {
+    const optionsWithDefaults = { ...this.defaults, ...options }
+    const event = `keydown.${optionsWithDefaults.keys}`
+
+    if (optionsWithDefaults.description) {
+      this.hotkeys.set(
+        optionsWithDefaults.keys,
+        optionsWithDefaults.description
+      )
+    }
+
+    return new Observable((observer) => {
+      const handler = (e: KeyboardEvent) => {
+        if (
+          !(e.altKey || e.metaKey || e.ctrlKey) &&
+          (e.target instanceof HTMLInputElement ||
+            e.target instanceof HTMLTextAreaElement)
+        ) {
+          // Ignore keydown events from input elements that dont have a modifier key
+          return
+        }
+
+        this.modalService.dismissAll()
+        if (
+          e.key === 'Escape' &&
+          (this.modalService.hasOpenModals() ||
+            this.document.getElementsByClassName('dropdown-menu show').length >
+              0)
+        ) {
+          // If there is a modal open or menu open, ignore the keydown event
+          return
+        }
+
+        e.preventDefault()
+        observer.next(e)
+      }
+
+      const dispose = this.eventManager.addEventListener(
+        optionsWithDefaults.element,
+        event,
+        handler
+      )
+
+      let disposeMeta
+      if (event.includes('control')) {
+        disposeMeta = this.eventManager.addEventListener(
+          optionsWithDefaults.element,
+          event.replace('control', 'meta'),
+          handler
+        )
+      }
+
+      return () => {
+        dispose()
+        if (disposeMeta) disposeMeta()
+        this.hotkeys.delete(optionsWithDefaults.keys)
+      }
+    })
+  }
+
+  private openHelpModal() {
+    const modal = this.modalService.open(HotkeyDialogComponent)
+    modal.componentInstance.hotkeys = this.hotkeys
+  }
+}
index 09341da623cc58f396c02b7dbf994e167ace299b..21d5d91a842710006b118bb0ac69be5783fea005 100644 (file)
@@ -135,6 +135,7 @@ describe('OpenDocumentsService', () => {
     expect(openDocumentsService.hasDirty()).toBeFalsy()
     openDocumentsService.setDirty(documents[0], true)
     expect(openDocumentsService.hasDirty()).toBeTruthy()
+    expect(openDocumentsService.isDirty(documents[0])).toBeTruthy()
     let openModal
     modalService.activeInstances.subscribe((instances) => {
       openModal = instances[0]
index 363a51b037aada26e637df85e9ba9218f58732b7..33e98ce12065edd5041964fb57ab0cd1861ff2a4 100644 (file)
@@ -90,6 +90,10 @@ export class OpenDocumentsService {
     return this.dirtyDocuments.size > 0
   }
 
+  isDirty(doc: Document): boolean {
+    return this.dirtyDocuments.has(doc.id)
+  }
+
   closeDocument(doc: Document): Observable<boolean> {
     let index = this.openDocuments.findIndex((d) => d.id == doc.id)
     if (index == -1) return of(true)
index 7f42aa7da6c8d971af186dbb1be7ffa0213b0156..346b8a09276c46c373f3240aece20eff2fa62528 100644 (file)
@@ -6,10 +6,13 @@ import { Subscription } from 'rxjs'
 import { TestBed } from '@angular/core/testing'
 import { environment } from 'src/environments/environment'
 import { SearchService } from './search.service'
+import { SettingsService } from '../settings.service'
+import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 
 let httpTestingController: HttpTestingController
 let service: SearchService
 let subscription: Subscription
+let settingsService: SettingsService
 const endpoint = 'search/autocomplete'
 
 describe('SearchService', () => {
@@ -20,6 +23,7 @@ describe('SearchService', () => {
     })
 
     httpTestingController = TestBed.inject(HttpTestingController)
+    settingsService = TestBed.inject(SettingsService)
     service = TestBed.inject(SearchService)
   })
 
@@ -36,4 +40,18 @@ describe('SearchService', () => {
     )
     expect(req.request.method).toEqual('GET')
   })
+
+  it('should call correct api endpoint on globalSearch', () => {
+    const query = 'apple'
+    service.globalSearch(query).subscribe()
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}search/?query=${query}`
+    )
+
+    settingsService.set(SETTINGS_KEYS.SEARCH_DB_ONLY, true)
+    subscription = service.globalSearch(query).subscribe()
+    httpTestingController.expectOne(
+      `${environment.apiBaseUrl}search/?query=${query}&db_only=true`
+    )
+  })
 })
index 4a75230d937ef435096c0d7bab4109f646fd6409..7a82d4f2f10b74e6087b88c4759f58b02b488719 100644 (file)
@@ -1,15 +1,48 @@
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { Observable } from 'rxjs'
-import { map } from 'rxjs/operators'
 import { environment } from 'src/environments/environment'
-import { DocumentService } from './document.service'
+import { Document } from 'src/app/data/document'
+import { DocumentType } from 'src/app/data/document-type'
+import { Correspondent } from 'src/app/data/correspondent'
+import { CustomField } from 'src/app/data/custom-field'
+import { Group } from 'src/app/data/group'
+import { MailAccount } from 'src/app/data/mail-account'
+import { MailRule } from 'src/app/data/mail-rule'
+import { StoragePath } from 'src/app/data/storage-path'
+import { Tag } from 'src/app/data/tag'
+import { User } from 'src/app/data/user'
+import { Workflow } from 'src/app/data/workflow'
+import { SettingsService } from '../settings.service'
+import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
+import { SavedView } from 'src/app/data/saved-view'
+
+export interface GlobalSearchResult {
+  total: number
+  documents: Document[]
+  saved_views: SavedView[]
+  correspondents: Correspondent[]
+  document_types: DocumentType[]
+  storage_paths: StoragePath[]
+  tags: Tag[]
+  users: User[]
+  groups: Group[]
+  mail_accounts: MailAccount[]
+  mail_rules: MailRule[]
+  custom_fields: CustomField[]
+  workflows: Workflow[]
+}
 
 @Injectable({
   providedIn: 'root',
 })
 export class SearchService {
-  constructor(private http: HttpClient) {}
+  public readonly searchResultObjectLimit: number = 3 // documents/views.py GlobalSearchView > OBJECT_LIMIT
+
+  constructor(
+    private http: HttpClient,
+    private settingsService: SettingsService
+  ) {}
 
   autocomplete(term: string): Observable<string[]> {
     return this.http.get<string[]>(
@@ -17,4 +50,19 @@ export class SearchService {
       { params: new HttpParams().set('term', term) }
     )
   }
+
+  globalSearch(query: string): Observable<GlobalSearchResult> {
+    let params = new HttpParams().set('query', query)
+    if (this.searchDbOnly) {
+      params = params.set('db_only', true)
+    }
+    return this.http.get<GlobalSearchResult>(
+      `${environment.apiBaseUrl}search/`,
+      { params }
+    )
+  }
+
+  public get searchDbOnly(): boolean {
+    return this.settingsService.get(SETTINGS_KEYS.SEARCH_DB_ONLY)
+  }
 }
index 22e4b348b197944afe506f4670f087fde92c1edd..04b908720fcb0a063b9520f445c23e0c9112bc69 100644 (file)
@@ -87,7 +87,7 @@ table .btn-link {
   color: var(--pngx-primary-text-contrast) !important;
 }
 
-.navbar .dropdown .btn {
+.navbar .dropdown .btn {
   color: var(--pngx-primary-text-contrast) !important;
 }
 
@@ -456,7 +456,7 @@ ul.pagination {
     color: var(--bs-body-color);
 
     &:hover, &:focus {
-      background-color: var(--pngx-bg-alt);
+      background-color: var(--pngx-bg-darker);
       color: var(--bs-body-color);
     }
 
index 806966ec71ee61a5a7e95c7c7746392ba244bea1..98261b8da7623cee34335011ab4b6ea50e6a82fd 100644 (file)
@@ -142,7 +142,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
     background-color: var(--pngx-body-color-accent);
   }
 
-  .search-form-container {
+  .search-container {
     input, input:focus {
       color: var(--bs-body-color) !important;
     }
@@ -277,8 +277,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
     color: var(--bs-body-color)
   }
 
-  .dropdown-menu {
+  .dropdown-item {
     --bs-dropdown-color: var(--bs-body-color);
+    --pngx-bg-darker: var(--pngx-bg-alt);
   }
 
   .list-group {
@@ -323,6 +324,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
           // navbar is og green in dark mode
           @include paperless-green;
         }
+
+        .navbar.bg-primary .dropdown-menu {
+          @include paperless-green-dark-mode;
+        }
       }
 
       @include dark-mode;
index e95a7bacb6e8e5d7218e719efbdd2a9e78ac7a9c..b3eace7c9c8ec1c488c83ef30e228ea4e88307ab 100644 (file)
@@ -796,6 +796,34 @@ class DocumentSerializer(
         )
 
 
+class SearchResultSerializer(DocumentSerializer):
+    def to_representation(self, instance):
+        doc = (
+            Document.objects.select_related(
+                "correspondent",
+                "storage_path",
+                "document_type",
+                "owner",
+            )
+            .prefetch_related("tags", "custom_fields", "notes")
+            .get(id=instance["id"])
+        )
+        notes = ",".join(
+            [str(c.note) for c in doc.notes.all()],
+        )
+        r = super().to_representation(doc)
+        r["__search_hit__"] = {
+            "score": instance.score,
+            "highlights": instance.highlights("content", text=doc.content),
+            "note_highlights": (
+                instance.highlights("notes", text=notes) if doc else None
+            ),
+            "rank": instance.rank,
+        }
+
+        return r
+
+
 class SavedViewFilterRuleSerializer(serializers.ModelSerializer):
     class Meta:
         model = SavedViewFilterRule
index cfbcce74ce6dce0b48fac02a3594e405780599c8..c10d6c1bb2c8359ce62e0fca05be218a2f50628e 100644 (file)
@@ -4,6 +4,7 @@ from unittest import mock
 
 import pytest
 from dateutil.relativedelta import relativedelta
+from django.contrib.auth.models import Group
 from django.contrib.auth.models import Permission
 from django.contrib.auth.models import User
 from django.test import override_settings
@@ -20,9 +21,13 @@ from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import Note
+from documents.models import SavedView
 from documents.models import StoragePath
 from documents.models import Tag
+from documents.models import Workflow
 from documents.tests.utils import DirectoriesMixin
+from paperless_mail.models import MailAccount
+from paperless_mail.models import MailRule
 
 
 class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
@@ -1153,3 +1158,110 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
             search_query("&ordering=-owner"),
             [d3.id, d2.id, d1.id],
         )
+
+    def test_global_search(self):
+        """
+        GIVEN:
+            - Multiple documents and objects
+        WHEN:
+            - Global search query is made
+        THEN:
+            - Appropriately filtered results are returned
+        """
+        d1 = Document.objects.create(
+            title="invoice doc1",
+            content="the thing i bought at a shop and paid with bank account",
+            checksum="A",
+            pk=1,
+        )
+        d2 = Document.objects.create(
+            title="bank statement doc2",
+            content="things i paid for in august",
+            checksum="B",
+            pk=2,
+        )
+        d3 = Document.objects.create(
+            title="tax bill doc3",
+            content="no b word",
+            checksum="C",
+            pk=3,
+        )
+
+        with index.open_index_writer() as writer:
+            index.update_document(writer, d1)
+            index.update_document(writer, d2)
+            index.update_document(writer, d3)
+
+        correspondent1 = Correspondent.objects.create(name="bank correspondent 1")
+        Correspondent.objects.create(name="correspondent 2")
+        document_type1 = DocumentType.objects.create(name="bank invoice")
+        DocumentType.objects.create(name="invoice")
+        storage_path1 = StoragePath.objects.create(name="bank path 1", path="path1")
+        StoragePath.objects.create(name="path 2", path="path2")
+        tag1 = Tag.objects.create(name="bank tag1")
+        Tag.objects.create(name="tag2")
+        user1 = User.objects.create_superuser("bank user1")
+        User.objects.create_user("user2")
+        group1 = Group.objects.create(name="bank group1")
+        Group.objects.create(name="group2")
+        SavedView.objects.create(
+            name="bank view",
+            show_on_dashboard=True,
+            show_in_sidebar=True,
+            sort_field="",
+            owner=user1,
+        )
+        mail_account1 = MailAccount.objects.create(name="bank mail account 1")
+        mail_account2 = MailAccount.objects.create(name="mail account 2")
+        mail_rule1 = MailRule.objects.create(
+            name="bank mail rule 1",
+            account=mail_account1,
+            action=MailRule.MailAction.MOVE,
+        )
+        MailRule.objects.create(
+            name="mail rule 2",
+            account=mail_account2,
+            action=MailRule.MailAction.MOVE,
+        )
+        custom_field1 = CustomField.objects.create(
+            name="bank custom field 1",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        CustomField.objects.create(
+            name="custom field 2",
+            data_type=CustomField.FieldDataType.INT,
+        )
+        workflow1 = Workflow.objects.create(name="bank workflow 1")
+        Workflow.objects.create(name="workflow 2")
+
+        self.client.force_authenticate(user1)
+
+        response = self.client.get("/api/search/?query=bank")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        results = response.data
+        self.assertEqual(len(results["documents"]), 2)
+        self.assertEqual(len(results["saved_views"]), 1)
+        self.assertNotEqual(results["documents"][0]["id"], d3.id)
+        self.assertNotEqual(results["documents"][1]["id"], d3.id)
+        self.assertEqual(results["correspondents"][0]["id"], correspondent1.id)
+        self.assertEqual(results["document_types"][0]["id"], document_type1.id)
+        self.assertEqual(results["storage_paths"][0]["id"], storage_path1.id)
+        self.assertEqual(results["tags"][0]["id"], tag1.id)
+        self.assertEqual(results["users"][0]["id"], user1.id)
+        self.assertEqual(results["groups"][0]["id"], group1.id)
+        self.assertEqual(results["mail_accounts"][0]["id"], mail_account1.id)
+        self.assertEqual(results["mail_rules"][0]["id"], mail_rule1.id)
+        self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id)
+        self.assertEqual(results["workflows"][0]["id"], workflow1.id)
+
+    def test_global_search_bad_request(self):
+        """
+        WHEN:
+            - Global search query is made without or with query < 3 characters
+        THEN:
+            - Error is returned
+        """
+        response = self.client.get("/api/search/")
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        response = self.client.get("/api/search/?query=no")
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
index 8f6d017ca3c1a9262f26758012661bf8f38af52d..d281a5f2c27fae6adcfb1f74deb92f38dabecf8c 100644 (file)
@@ -17,6 +17,7 @@ from urllib.parse import urlparse
 import pathvalidate
 from django.apps import apps
 from django.conf import settings
+from django.contrib.auth.models import Group
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.db import connections
@@ -137,6 +138,7 @@ from documents.serialisers import DocumentSerializer
 from documents.serialisers import DocumentTypeSerializer
 from documents.serialisers import PostDocumentSerializer
 from documents.serialisers import SavedViewSerializer
+from documents.serialisers import SearchResultSerializer
 from documents.serialisers import ShareLinkSerializer
 from documents.serialisers import StoragePathSerializer
 from documents.serialisers import TagSerializer
@@ -152,7 +154,13 @@ from paperless import version
 from paperless.celery import app as celery_app
 from paperless.config import GeneralConfig
 from paperless.db import GnuPG
+from paperless.serialisers import GroupSerializer
+from paperless.serialisers import UserSerializer
 from paperless.views import StandardPagination
+from paperless_mail.models import MailAccount
+from paperless_mail.models import MailRule
+from paperless_mail.serialisers import MailAccountSerializer
+from paperless_mail.serialisers import MailRuleSerializer
 
 if settings.AUDIT_LOG_ENABLED:
     from auditlog.models import LogEntry
@@ -813,34 +821,6 @@ class DocumentViewSet(
         return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
 
 
-class SearchResultSerializer(DocumentSerializer, PassUserMixin):
-    def to_representation(self, instance):
-        doc = (
-            Document.objects.select_related(
-                "correspondent",
-                "storage_path",
-                "document_type",
-                "owner",
-            )
-            .prefetch_related("tags", "custom_fields", "notes")
-            .get(id=instance["id"])
-        )
-        notes = ",".join(
-            [str(c.note) for c in doc.notes.all()],
-        )
-        r = super().to_representation(doc)
-        r["__search_hit__"] = {
-            "score": instance.score,
-            "highlights": instance.highlights("content", text=doc.content),
-            "note_highlights": (
-                instance.highlights("notes", text=notes) if doc else None
-            ),
-            "rank": instance.rank,
-        }
-
-        return r
-
-
 class UnifiedSearchViewSet(DocumentViewSet):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -1158,6 +1138,189 @@ class SearchAutoCompleteView(APIView):
         )
 
 
+class GlobalSearchView(PassUserMixin):
+    permission_classes = (IsAuthenticated,)
+    serializer_class = SearchResultSerializer
+
+    def get(self, request, *args, **kwargs):
+        query = request.query_params.get("query", None)
+        if query is None:
+            return HttpResponseBadRequest("Query required")
+        elif len(query) < 3:
+            return HttpResponseBadRequest("Query must be at least 3 characters")
+
+        db_only = request.query_params.get("db_only", False)
+
+        OBJECT_LIMIT = 3
+        docs = []
+        if request.user.has_perm("documents.view_document"):
+            all_docs = get_objects_for_user_owner_aware(
+                request.user,
+                "view_document",
+                Document,
+            )
+            # First search by title
+            docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
+            if not db_only and len(docs) < OBJECT_LIMIT:
+                # If we don't have enough results, search by content
+                from documents import index
+
+                with index.open_index_searcher() as s:
+                    q, _ = index.DelayedFullTextQuery(
+                        s,
+                        request.query_params,
+                        10,
+                        request.user,
+                    )._get_query()
+                    results = s.search(q, limit=OBJECT_LIMIT)
+                    docs = docs | all_docs.filter(id__in=[r["id"] for r in results])
+        saved_views = (
+            SavedView.objects.filter(owner=request.user, name__icontains=query)[
+                :OBJECT_LIMIT
+            ]
+            if request.user.has_perm("documents.view_savedview")
+            else []
+        )
+        tags = (
+            get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
+                name__icontains=query,
+            )[:OBJECT_LIMIT]
+            if request.user.has_perm("documents.view_tag")
+            else []
+        )
+        correspondents = (
+            get_objects_for_user_owner_aware(
+                request.user,
+                "view_correspondent",
+                Correspondent,
+            ).filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("documents.view_correspondent")
+            else []
+        )
+        document_types = (
+            get_objects_for_user_owner_aware(
+                request.user,
+                "view_documenttype",
+                DocumentType,
+            ).filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("documents.view_documenttype")
+            else []
+        )
+        storage_paths = (
+            get_objects_for_user_owner_aware(
+                request.user,
+                "view_storagepath",
+                StoragePath,
+            ).filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("documents.view_storagepath")
+            else []
+        )
+        users = (
+            User.objects.filter(username__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("auth.view_user")
+            else []
+        )
+        groups = (
+            Group.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("auth.view_group")
+            else []
+        )
+        mail_rules = (
+            MailRule.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("paperless_mail.view_mailrule")
+            else []
+        )
+        mail_accounts = (
+            MailAccount.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("paperless_mail.view_mailaccount")
+            else []
+        )
+        workflows = (
+            Workflow.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("documents.view_workflow")
+            else []
+        )
+        custom_fields = (
+            CustomField.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
+            if request.user.has_perm("documents.view_customfield")
+            else []
+        )
+
+        context = {
+            "request": request,
+        }
+
+        docs_serializer = DocumentSerializer(docs, many=True, context=context)
+        saved_views_serializer = SavedViewSerializer(
+            saved_views,
+            many=True,
+            context=context,
+        )
+        tags_serializer = TagSerializer(tags, many=True, context=context)
+        correspondents_serializer = CorrespondentSerializer(
+            correspondents,
+            many=True,
+            context=context,
+        )
+        document_types_serializer = DocumentTypeSerializer(
+            document_types,
+            many=True,
+            context=context,
+        )
+        storage_paths_serializer = StoragePathSerializer(
+            storage_paths,
+            many=True,
+            context=context,
+        )
+        users_serializer = UserSerializer(users, many=True, context=context)
+        groups_serializer = GroupSerializer(groups, many=True, context=context)
+        mail_rules_serializer = MailRuleSerializer(
+            mail_rules,
+            many=True,
+            context=context,
+        )
+        mail_accounts_serializer = MailAccountSerializer(
+            mail_accounts,
+            many=True,
+            context=context,
+        )
+        workflows_serializer = WorkflowSerializer(workflows, many=True, context=context)
+        custom_fields_serializer = CustomFieldSerializer(
+            custom_fields,
+            many=True,
+            context=context,
+        )
+
+        return Response(
+            {
+                "total": len(docs)
+                + len(saved_views)
+                + len(tags)
+                + len(correspondents)
+                + len(document_types)
+                + len(storage_paths)
+                + len(users)
+                + len(groups)
+                + len(mail_rules)
+                + len(mail_accounts)
+                + len(workflows)
+                + len(custom_fields),
+                "documents": docs_serializer.data,
+                "saved_views": saved_views_serializer.data,
+                "tags": tags_serializer.data,
+                "correspondents": correspondents_serializer.data,
+                "document_types": document_types_serializer.data,
+                "storage_paths": storage_paths_serializer.data,
+                "users": users_serializer.data,
+                "groups": groups_serializer.data,
+                "mail_rules": mail_rules_serializer.data,
+                "mail_accounts": mail_accounts_serializer.data,
+                "workflows": workflows_serializer.data,
+                "custom_fields": custom_fields_serializer.data,
+            },
+        )
+
+
 class StatisticsView(APIView):
     permission_classes = (IsAuthenticated,)
 
index 2cb0a9cc5dbc79f623fa8cd3caedcd4799af5a63..373d03af7c925cbefc65a63efd95a332546b29c5 100644 (file)
@@ -2,7 +2,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: paperless-ngx\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-04-24 22:54-0700\n"
+"POT-Creation-Date: 2024-04-26 07:19-0700\n"
 "PO-Revision-Date: 2022-02-17 04:17\n"
 "Last-Translator: \n"
 "Language-Team: English\n"
@@ -917,12 +917,12 @@ msgstr ""
 msgid "Invalid color."
 msgstr ""
 
-#: documents/serialisers.py:1169
+#: documents/serialisers.py:1197
 #, python-format
 msgid "File type %(type)s not supported"
 msgstr ""
 
-#: documents/serialisers.py:1278
+#: documents/serialisers.py:1306
 msgid "Invalid variable detected."
 msgstr ""
 
@@ -1418,7 +1418,7 @@ msgstr ""
 msgid "Chinese Simplified"
 msgstr ""
 
-#: paperless/urls.py:230
+#: paperless/urls.py:236
 msgid "Paperless-ngx administration"
 msgstr ""
 
index 12b049918a4252cd7bbaa0719dd8be995e9cba6c..8626cc8b17edadf7b69f1c6e427205209ef0c2ff 100644 (file)
@@ -21,6 +21,7 @@ from documents.views import BulkEditView
 from documents.views import CorrespondentViewSet
 from documents.views import CustomFieldViewSet
 from documents.views import DocumentTypeViewSet
+from documents.views import GlobalSearchView
 from documents.views import IndexView
 from documents.views import LogViewSet
 from documents.views import PostDocumentView
@@ -91,6 +92,11 @@ urlpatterns = [
                     SearchAutoCompleteView.as_view(),
                     name="autocomplete",
                 ),
+                re_path(
+                    "^search/",
+                    GlobalSearchView.as_view(),
+                    name="global_search",
+                ),
                 re_path("^statistics/", StatisticsView.as_view(), name="statistics"),
                 re_path(
                     "^documents/post_document/",