- `/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.
- `/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
[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
## 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
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
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()
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')
<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 'Inbox'. Those settings are found under Settings > 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 'view' 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 <em>tons</em> more features and info we didn'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 <strong>current user only</strong>.</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 "<x id="PH" equiv-text="savedView.name"/>" 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 "<x id="PH" equiv-text="this.document.title"/>"?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">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>"<x id="PH" equiv-text="items[0].name"/>"</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>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</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 'modify "tag1" and "tag2"'</note>
</trans-unit>
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</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 'modify "tag1", "tag2" and "tag3"'</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 "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="correspondent.name"/>" 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 "<x id="PH" equiv-text="documentType.name"/>" 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 "<x id="PH" equiv-text="storagePath.name"/>" 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 "<x id="PH" equiv-text="customField.name"/>" 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 "<x id="PH" equiv-text="customField.name"/>" 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 "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" 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 "<x id="PH" equiv-text="savedView.name"/>" 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 & 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">
}
}
+Object.defineProperty(window, 'open', { value: jest.fn() })
Object.defineProperty(window, 'localStorage', { value: mock() })
Object.defineProperty(window, 'sessionStorage', { value: mock() })
Object.defineProperty(window, 'getComputedStyle', {
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'
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
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()
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
+ hotKeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(AppComponent)
component = fixture.componentInstance
})
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'])
+ })
})
PermissionsService,
PermissionType,
} from './services/permissions.service'
+import { HotKeyService } from './services/hot-key.service'
@Component({
selector: 'pngx-root',
private tasksService: TasksService,
public tourService: TourService,
private renderer: Renderer2,
- private permissionsService: PermissionsService
+ private permissionsService: PermissionsService,
+ private hotKeyService: HotKeyService
) {
this.settings.updateAppearanceSettings()
}
}
})
+ 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`
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,
doorOpen,
download,
envelope,
+ envelopeAt,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
personFill,
personFillLock,
personLock,
+ personSquare,
plus,
plusCircle,
questionCircle,
sortAlphaDown,
sortAlphaUpAlt,
tagFill,
+ tag,
tags,
textIndentLeft,
textLeft,
doorOpen,
download,
envelope,
+ envelopeAt,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
personFill,
personFillLock,
personLock,
+ personSquare,
plus,
plusCircle,
questionCircle,
sortAlphaDown,
sortAlphaUpAlt,
tagFill,
+ tag,
tags,
textIndentLeft,
textLeft,
DocumentHistoryComponent,
DragDropSelectComponent,
CustomFieldDisplayComponent,
+ GlobalSearchComponent,
+ HotkeyDialogComponent,
],
imports: [
BrowserModule,
</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">
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
- expect(setSpy).toHaveBeenCalledTimes(25)
+ expect(setSpy).toHaveBeenCalledTimes(26)
// succeed
storeSpy.mockReturnValueOnce(of(true))
defaultPermsEditUsers: new FormControl(null),
defaultPermsEditGroups: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
+ searchDbOnly: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null),
documentEditingRemoveInboxTags: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
),
+ searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
savedViews: {},
}
}
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()
}
</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">
}
}
-.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;
}
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 = [
{
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,
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)
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)
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 {
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,
} 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',
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,
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)
--- /dev/null
+
+<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> <ng-container i18n>Open</ng-container></span>
+ } @else if (type === DataType.SavedView) {
+ <i-bs width="1em" height="1em" name="eye"></i-bs>
+ <span> <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> <ng-container i18n>Edit</ng-container></span>
+ } @else {
+ <i-bs width="1em" height="1em" name="filter"></i-bs>
+ <span> <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> <ng-container i18n>Download</ng-container></span>
+ } @else {
+ <i-bs width="1em" height="1em" name="pencil"></i-bs>
+ <span> <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>
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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' },
+ ])
+ })
+})
--- /dev/null
+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)
+ }
+}
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[] = [
{
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent
let fixture: ComponentFixture<FilterableDropdownComponent>
+ let hotkeyService: HotKeyService
beforeEach(async () => {
TestBed.configureTestingModule({
],
}).compileComponents()
+ hotkeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance
selectionModel = new 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()
+ })
})
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[]
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
@Input()
documentCounts: SelectionDataItem[]
+ @Input()
+ shortcutKey: string
+
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
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()
--- /dev/null
+<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')) {
+ (macOS <kbd [innerHTML]="formatKey(key[0], true)"></kbd>)
+ }
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+</div>
+<div class="modal-footer">
+</div>
--- /dev/null
+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('⌃ + a') // ⌃ + a
+ expect(component.formatKey('control.a', true)).toEqual('⌘ + a') // ⌘ + a
+ })
+})
--- /dev/null
+import { Component } from '@angular/core'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+
+const SYMBOLS = {
+ meta: '⌘', // ⌘
+ control: '⌃', // ⌃
+ shift: '⇧', // ⇧
+ left: '←', // ←
+ right: '→', // →
+ up: '↑', // ↑
+ down: '↓', // ↓
+}
+
+@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(' + ')
+ }
+}
} 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,
DatePipe,
],
imports: [
- RouterTestingModule.withRoutes(routes),
+ RouterModule.forRoot(routes),
HttpClientTestingModule,
NgbModule,
NgSelectModule,
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')
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,
private permissionsService: PermissionsService,
private userService: UserService,
private customFieldsService: CustomFieldsService,
- private http: HttpClient
+ private http: HttpClient,
+ private hotKeyService: HotKeyService
) {
super()
}
})
}
})
+
+ 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 {
<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)) {
-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'
downloadUseFormatting: new FormControl(false),
})
+ @Input()
+ public disabled: boolean = false
+
constructor(
private documentTypeService: DocumentTypeService,
private tagService: TagService,
</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>
NgbModalRef,
NgbPopoverModule,
NgbTooltipModule,
+ NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
NgbTooltipModule,
NgxBootstrapIconsModule.pick(allIcons),
NgSelectModule,
+ NgbTypeaheadModule,
],
}).compileComponents()
'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)
+ })
})
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',
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService,
public settingsService: SettingsService,
+ private hotKeyService: HotKeyService,
public permissionService: PermissionsService
) {
super()
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() {
})
}
- 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 {
<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>
(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
(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
(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
(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) {
} 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,
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[] = [
{
let settingsService: SettingsService
let permissionsService: PermissionsService
let httpTestingController: HttpTestingController
+ let searchService: SearchService
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
],
imports: [
HttpClientTestingModule,
- RouterTestingModule,
+ RouterModule,
NgbDropdownModule,
FormsModule,
ReactiveFormsModule,
NgbDatepickerModule,
NgxBootstrapIconsModule.pick(allIcons),
+ NgbTypeaheadModule,
],
}).compileComponents()
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = users[0]
permissionsService = TestBed.inject(PermissionsService)
+ searchService = TestBed.inject(SearchService)
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
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', () => {
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 ')
+ })
})
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'
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'
})
export class FilterEditorComponent
extends ComponentWithPermissions
- implements OnInit, OnDestroy
+ implements OnInit, OnDestroy, AfterViewInit
{
generateFilterName() {
if (this.filterRules.length == 1) {
private documentService: DocumentService,
private storagePathService: StoragePathService,
public permissionsService: PermissionsService,
- private customFieldService: CustomFieldsService
+ private customFieldService: CustomFieldsService,
+ private searchService: SearchService
) {
super()
}
_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([
}
textFilterDebounce: Subject<string>
- subscription: Subscription
+
+ @Input()
+ public disabled: boolean = false
ngOnInit() {
if (
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() {
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) {
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()
+ }
}
}
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)
+ }
}
--- /dev/null
+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',
+}
+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
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,
},
{
{
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,
},
{
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[] = [
type: 'boolean',
default: false,
},
+ {
+ key: SETTINGS_KEYS.SEARCH_DB_ONLY,
+ type: 'boolean',
+ default: false,
+ },
]
--- /dev/null
+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()
+ })
+})
--- /dev/null
+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
+ }
+}
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]
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)
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', () => {
})
httpTestingController = TestBed.inject(HttpTestingController)
+ settingsService = TestBed.inject(SettingsService)
service = TestBed.inject(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`
+ )
+ })
})
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[]>(
{ 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)
+ }
}
color: var(--pngx-primary-text-contrast) !important;
}
-.navbar .dropdown .btn {
+.navbar .dropdown > .btn {
color: var(--pngx-primary-text-contrast) !important;
}
color: var(--bs-body-color);
&:hover, &:focus {
- background-color: var(--pngx-bg-alt);
+ background-color: var(--pngx-bg-darker);
color: var(--bs-body-color);
}
background-color: var(--pngx-body-color-accent);
}
- .search-form-container {
+ .search-container {
input, input:focus {
color: var(--bs-body-color) !important;
}
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 {
// navbar is og green in dark mode
@include paperless-green;
}
+
+ .navbar.bg-primary .dropdown-menu {
+ @include paperless-green-dark-mode;
+ }
}
@include dark-mode;
)
+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
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
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):
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)
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
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
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
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)
)
+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,)
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"
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 ""
msgid "Chinese Simplified"
msgstr ""
-#: paperless/urls.py:230
+#: paperless/urls.py:236
msgid "Paperless-ngx administration"
msgstr ""
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
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/",