diff --git a/docker/gunicorn.conf.py b/docker/gunicorn.conf.py index a2f456079..5c9c0eb37 100644 --- a/docker/gunicorn.conf.py +++ b/docker/gunicorn.conf.py @@ -1,4 +1,4 @@ -bind = '127.0.0.1:8000' +bind = ['[::]:8000', 'localhost:8000'] backlog = 2048 workers = 3 worker_class = 'sync' diff --git a/docker/hub/docker-compose.postgres.yml b/docker/hub/docker-compose.postgres.yml index e7a32bec0..c2b599e52 100644 --- a/docker/hub/docker-compose.postgres.yml +++ b/docker/hub/docker-compose.postgres.yml @@ -15,7 +15,7 @@ services: POSTGRES_PASSWORD: paperless webserver: - image: jonaswinkler/paperless-ng:0.9.9 + image: jonaswinkler/paperless-ng:0.9.10 restart: always depends_on: - db diff --git a/docker/hub/docker-compose.sqlite.yml b/docker/hub/docker-compose.sqlite.yml index 98b7d70a2..429d42c06 100644 --- a/docker/hub/docker-compose.sqlite.yml +++ b/docker/hub/docker-compose.sqlite.yml @@ -5,7 +5,7 @@ services: restart: always webserver: - image: jonaswinkler/paperless-ng:0.9.9 + image: jonaswinkler/paperless-ng:0.9.10 restart: always depends_on: - broker diff --git a/docker/local/Dockerfile b/docker/local/Dockerfile index 461b9e4fc..1fd5e24fa 100644 --- a/docker/local/Dockerfile +++ b/docker/local/Dockerfile @@ -9,6 +9,8 @@ RUN apt-get update \ && apt-get -y --no-install-recommends install \ build-essential \ curl \ + file \ + fonts-liberation \ ghostscript \ gnupg \ icc-profiles-free \ @@ -20,6 +22,7 @@ RUN apt-get update \ libpq-dev \ libqpdf-dev \ libxml2 \ + libxslt1-dev \ optipng \ pngquant \ qpdf \ @@ -62,6 +65,7 @@ RUN sudo -HEu paperless python3 manage.py collectstatic --clear --no-input VOLUME ["/usr/src/paperless/data", "/usr/src/paperless/media", "/usr/src/paperless/consume", "/usr/src/paperless/export"] ENTRYPOINT ["/sbin/docker-entrypoint.sh"] +EXPOSE 8000 CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisord.conf"] LABEL maintainer="Jonas Winkler " diff --git a/docker/supervisord.conf b/docker/supervisord.conf index ebe0f005d..9b97b6825 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -8,7 +8,7 @@ loglevel=info ; log level; default info; others: debug,warn,trace user=root [program:gunicorn] -command=gunicorn -c /usr/src/paperless/gunicorn.conf.py -b 0.0.0.0:8000 paperless.wsgi +command=gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.wsgi user=paperless stdout_logfile=/dev/stdout diff --git a/docs/changelog.rst b/docs/changelog.rst index e63c19d7d..bba0d087f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,37 @@ Changelog ********* +paperless-ng 0.9.10 +################### + +* Bulk editing + + * Thanks to `Michael Shamoon`_, we've got a new interface for the bulk editor. + * There are some configuration options in the settings to alter the behavior. + +* Other changes and additions + + * The Paperless-ng logo now navigates to the dashboard. + * Filter for documents that don't have any correspondents, types or tags assigned. + * Tags, types and correspondents are now sorted case insensitive. + * Lots of preparation work for localization support. + +* Fixes + + * Added missing dependencies for Raspberry Pi builds. + * Fixed an issue with plain text file consumption: Thumbnail generation failed due to missing fonts. + * An issue with the search index reporting missing documents after bulk deletes was fixed. + * Issue with the tag selector not clearing input correctly. + * The consumer used to stop working when encountering an incomplete classifier model file. + +.. note:: + + The bulk delete operations did not update the search index. Therefore, documents that you deleted remained in the index and + caused the search to return messages about missing documents when searching. Further bulk operations will properly update + the index. + + However, this change is not retroactive: If you used the delete method of the bulk editor, you need to reindex your search index + by :ref:`running the management command document_index with the argument reindex `. paperless-ng 0.9.9 ################## diff --git a/docs/configuration.rst b/docs/configuration.rst index efc1a9db1..5ccb80b3a 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -400,6 +400,15 @@ PAPERLESS_FILENAME_DATE_ORDER= Defaults to none, which disables this feature. +PAPERLESS_THUMBNAIL_FONT_NAME= + Paperless creates thumbnails for plain text files by rendering the content + of the file on an image and uses a predefined font for that. This + font can be changed here. + + Note that this won't have any effect on already generated thumbnails. + + Defaults to ``/usr/share/fonts/liberation/LiberationSerif-Regular.ttf``. + Binaries ######## diff --git a/docs/setup.rst b/docs/setup.rst index 4d29ce640..437409194 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -221,8 +221,9 @@ writing. Windows is not and will never be supported. * ``python3-pip``, optionally ``pipenv`` for package installation * ``python3-dev`` + * ``fonts-liberation`` for generating thumbnails for plain text files * ``imagemagick`` >= 6 for PDF conversion - * ``optipng`` for optimising thumbnails + * ``optipng`` for optimizing thumbnails * ``gnupg`` for handling encrypted documents * ``libpoppler-cpp-dev`` for PDF to text conversion * ``libmagic-dev`` for mime type detection @@ -242,8 +243,7 @@ writing. Windows is not and will never be supported. * ``tesseract-ocr`` language packs (``tesseract-ocr-eng``, ``tesseract-ocr-deu``, etc) You will also need ``build-essential``, ``python3-setuptools`` and ``python3-wheel`` - for installing some of the python dependencies. You can remove that - again after installation. + for installing some of the python dependencies. 2. Install ``redis`` >= 5.0 and configure it to start automatically. diff --git a/paperless.conf.example b/paperless.conf.example index cc4cc0eb7..d9d0f5b06 100644 --- a/paperless.conf.example +++ b/paperless.conf.example @@ -54,6 +54,7 @@ #PAPERLESS_POST_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh #PAPERLESS_FILENAME_DATE_ORDER=YMD #PAPERLESS_FILENAME_PARSE_TRANSFORMS=[] +#PAPERLESS_THUMBNAIL_FONT_NAME= # Binaries diff --git a/src-ui/angular.json b/src-ui/angular.json index 2ff1bb3b0..ce2dd82f7 100644 --- a/src-ui/angular.json +++ b/src-ui/angular.json @@ -26,12 +26,16 @@ "aot": true, "assets": [ "src/favicon.ico", - "src/assets" + "src/assets", + "src/manifest.webmanifest" ], "styles": [ "src/styles.scss" ], - "scripts": [] + "scripts": [], + "allowedCommonJsDependencies": [ + "ng2-pdf-viewer" + ] }, "configurations": { "production": { @@ -90,7 +94,8 @@ "karmaConfig": "karma.conf.js", "assets": [ "src/favicon.ico", - "src/assets" + "src/assets", + "src/manifest.webmanifest" ], "styles": [ "src/styles.scss" @@ -127,4 +132,4 @@ } }, "defaultProject": "paperless-ui" -} \ No newline at end of file +} diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf new file mode 100644 index 000000000..5f12c504c --- /dev/null +++ b/src-ui/messages.xlf @@ -0,0 +1,1608 @@ + + + + + + Documents + + src/app/components/document-list/document-list.component.ts + 38 + + + + View "" saved successfully. + + src/app/components/document-list/document-list.component.ts + 84 + + + + View "" created successfully. + + src/app/components/document-list/document-list.component.ts + 103 + + + + Select + + src/app/components/document-list/document-list.component.html + 7 + + + + Select none + + src/app/components/document-list/document-list.component.html + 11 + + + + Select page + + src/app/components/document-list/document-list.component.html + 12 + + + + Select all + + src/app/components/document-list/document-list.component.html + 13 + + + + Sort by + + src/app/components/document-list/document-list.component.html + 41 + + + + Views + + src/app/components/document-list/document-list.component.html + 64 + + + + Save as... + + src/app/components/document-list/document-list.component.html + 72 + + + + Save "" + + src/app/components/document-list/document-list.component.html + 71 + + + + {VAR_PLURAL, plural, =1 {document} other {documents}} + + src/app/components/document-list/document-list.component.html + 86 + + + + Selected of + + src/app/components/document-list/document-list.component.html + 86 + + + + {VAR_PLURAL, plural, =1 {1 document} other { documents}} + + src/app/components/document-list/document-list.component.html + 87 + + + + ASN + + src/app/components/document-list/document-list.component.html + 100 + + + + Correspondent + + src/app/components/document-list/document-list.component.html + 101 + + + + Title + + src/app/components/document-list/document-list.component.html + 102 + + + + Document type + + src/app/components/document-list/document-list.component.html + 103 + + + + Created + + src/app/components/document-list/document-list.component.html + 104 + + + + Added + + src/app/components/document-list/document-list.component.html + 105 + + + + Confirm delete + + src/app/components/document-detail/document-detail.component.ts + 161 + + + + Do you really want to delete document ""? + + src/app/components/document-detail/document-detail.component.ts + 162 + + + + The files for this document will be deleted permanently. This operation cannot be undone. + + src/app/components/document-detail/document-detail.component.ts + 163 + + + + Delete document + + src/app/components/document-detail/document-detail.component.ts + 165 + + + + Delete + + src/app/components/document-detail/document-detail.component.html + 15 + + + + Download + + src/app/components/document-detail/document-detail.component.html + 23 + + + + More like this + + src/app/components/document-detail/document-detail.component.html + 38 + + + + Close + + src/app/components/document-detail/document-detail.component.html + 44 + + + + Details + + src/app/components/document-detail/document-detail.component.html + 56 + + + + Content + + src/app/components/document-detail/document-detail.component.html + 76 + + + + Metadata + + src/app/components/document-detail/document-detail.component.html + 85 + + + + Discard + + src/app/components/document-detail/document-detail.component.html + 134 + + + + Save + + src/app/components/document-detail/document-detail.component.html + 136 + + + + Page + + src/app/components/document-detail/document-detail.component.html + 4 + + + + of + + src/app/components/document-detail/document-detail.component.html + 8 + + + + Download original + + src/app/components/document-detail/document-detail.component.html + 29 + + + + Archive serial number + + src/app/components/document-detail/document-detail.component.html + 61 + + + + Date created + + src/app/components/document-detail/document-detail.component.html + 65 + + + + Tags + + src/app/components/document-detail/document-detail.component.html + 70 + + + + Date modified + + src/app/components/document-detail/document-detail.component.html + 91 + + + + Date added + + src/app/components/document-detail/document-detail.component.html + 95 + + + + Media filename + + src/app/components/document-detail/document-detail.component.html + 99 + + + + Original MD5 checksum + + src/app/components/document-detail/document-detail.component.html + 103 + + + + Original file size + + src/app/components/document-detail/document-detail.component.html + 107 + + + + Original mime type + + src/app/components/document-detail/document-detail.component.html + 111 + + + + Archive MD5 checksum + + src/app/components/document-detail/document-detail.component.html + 115 + + + + Archive file size + + src/app/components/document-detail/document-detail.component.html + 119 + + + + Original document metadata + + src/app/components/document-detail/document-detail.component.html + 125 + + + + Archived document metadata + + src/app/components/document-detail/document-detail.component.html + 126 + + + + Save & next + + src/app/components/document-detail/document-detail.component.html + 135 + + + + Hello , welcome to Paperless-ng! + + src/app/components/dashboard/dashboard.component.ts + 33 + + + + Welcome to Paperless-ng! + + src/app/components/dashboard/dashboard.component.ts + 35 + + + + Dashboard + + src/app/components/dashboard/dashboard.component.html + 1 + + + + Do you really want to delete the tag ""? + + src/app/components/manage/tag-list/tag-list.component.ts + 31 + + + + Create + + src/app/components/manage/tag-list/tag-list.component.html + 2 + + + + Name + + src/app/components/manage/tag-list/tag-list.component.html + 13 + + + + Color + + src/app/components/manage/tag-list/tag-list.component.html + 14 + + + + Matching + + src/app/components/manage/tag-list/tag-list.component.html + 15 + + + + Document count + + src/app/components/manage/tag-list/tag-list.component.html + 16 + + + + Actions + + src/app/components/manage/tag-list/tag-list.component.html + 17 + + + + Documents + + src/app/components/manage/tag-list/tag-list.component.html + 32 + + + + Edit + + src/app/components/manage/tag-list/tag-list.component.html + 37 + + + + Do you really want to delete the document type ""? + + src/app/components/manage/document-type-list/document-type-list.component.ts + 26 + + + + Document types + + src/app/components/manage/document-type-list/document-type-list.component.html + 1 + + + + Logs + + src/app/components/manage/logs/logs.component.html + 1 + + + + Filter + + src/app/components/manage/logs/logs.component.html + 7 + + + + Saved view " deleted. + + src/app/components/manage/settings/settings.component.ts + 52 + + + + Settings saved successfully. + + src/app/components/manage/settings/settings.component.ts + 61 + + + + Error while storing settings on server: + + src/app/components/manage/settings/settings.component.ts + 73 + + + + General settings + + src/app/components/manage/settings/settings.component.html + 10 + + + + Saved views + + src/app/components/manage/settings/settings.component.html + 41 + + + + Document list + + src/app/components/manage/settings/settings.component.html + 13 + + + + Items per page + + src/app/components/manage/settings/settings.component.html + 17 + + + + Bulk editing + + src/app/components/manage/settings/settings.component.html + 33 + + + + Show confirmation dialogs + + src/app/components/manage/settings/settings.component.html + 35 + + + + Deleting documents will always ask for confirmation. + + src/app/components/manage/settings/settings.component.html + 35 + + + + Apply on close + + src/app/components/manage/settings/settings.component.html + 36 + + + + Appears on + + src/app/components/manage/settings/settings.component.html + 53 + + + + Show on dashboard + + src/app/components/manage/settings/settings.component.html + 56 + + + + Show in sidebar + + src/app/components/manage/settings/settings.component.html + 60 + + + + No saved views defined. + + src/app/components/manage/settings/settings.component.html + 70 + + + + 404 Not Found + + src/app/components/not-found/not-found.component.html + 7 + + + + Do you really want to delete the correspondent ""? + + src/app/components/manage/correspondent-list/correspondent-list.component.ts + 26 + + + + Correspondents + + src/app/components/manage/correspondent-list/correspondent-list.component.html + 1 + + + + Last correspondence + + src/app/components/manage/correspondent-list/correspondent-list.component.html + 15 + + + + Confirmation + + src/app/components/common/confirm-dialog/confirm-dialog.component.ts + 17 + + + + Confirm + + src/app/components/common/confirm-dialog/confirm-dialog.component.ts + 29 + + + + Create new correspondent + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.ts + 21 + + + + Edit correspondent + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.ts + 25 + + + + Could not save correspondent: + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.ts + 29 + + + + Matching algorithm + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 11 + + + + Match + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 12 + + + + Auto matching does not require you to fill in this field. + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 12 + + + + Case insensitive + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 13 + + + + Auto matching ignores this option. + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 13 + + + + Cancel + + src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 16 + + + + Create new tag + + src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.ts + 21 + + + + Edit tag + + src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.ts + 25 + + + + Could not save tag: + + src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.ts + 29 + + + + Inbox tag + + src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.html + 21 + + + + Inbox tags are automatically assigned to all consumed documents. + + src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.html + 21 + + + + Create new document type + + src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.ts + 21 + + + + Edit document type + + src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.ts + 25 + + + + Could not save document type: + + src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.ts + 29 + + + + Search results + + src/app/components/search/search.component.html + 1 + + + + Invalid search query: + + src/app/components/search/search.component.html + 4 + + + + Showing documents similar to + + src/app/components/search/search.component.html + 7 + + + + Search query: + + src/app/components/search/search.component.html + 11 + + + + Did you mean ""? + + src/app/components/search/search.component.html + 13 + + + + {VAR_PLURAL, plural, =0 {No results} =1 {One result} other { results}} + + src/app/components/search/search.component.html + 18 + + + + Paperless-ng + + src/app/components/app-frame/app-frame.component.html + 4 + + app title + + + Search for documents + + src/app/components/app-frame/app-frame.component.html + 12 + + + + Manage + + src/app/components/app-frame/app-frame.component.html + 77 + + + + Settings + + src/app/components/app-frame/app-frame.component.html + 112 + + + + Admin + + src/app/components/app-frame/app-frame.component.html + 119 + + + + Misc + + src/app/components/app-frame/app-frame.component.html + 125 + + + + Documentation + + src/app/components/app-frame/app-frame.component.html + 132 + + + + GitHub + + src/app/components/app-frame/app-frame.component.html + 139 + + + + Logout + + src/app/components/app-frame/app-frame.component.html + 146 + + + + Open documents + + src/app/components/app-frame/app-frame.component.html + 57 + + + + Close all + + src/app/components/app-frame/app-frame.component.html + 71 + + + + Correspondent: + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 28 + + + + Type: + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 31 + + + + Tag: + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 34 + + + + Filter by: + + src/app/components/document-list/filter-editor/filter-editor.component.html + 4 + + + + Filter tags + + src/app/components/document-list/filter-editor/filter-editor.component.html + 12 + + + + Filter correspondents + + src/app/components/document-list/filter-editor/filter-editor.component.html + 19 + + + + Filter document types + + src/app/components/document-list/filter-editor/filter-editor.component.html + 25 + + + + Clear all filters + + src/app/components/document-list/filter-editor/filter-editor.component.html + 47 + + + + Not assigned + + src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts + 145 + + Filter drop down element to filter for documents with no correspondent/type/tag assigned + + + Apply + + src/app/components/common/filterable-dropdown/filterable-dropdown.component.html + 28 + + + + Last 7 days + + src/app/components/common/date-dropdown/date-dropdown.component.ts + 24 + + + + Last month + + src/app/components/common/date-dropdown/date-dropdown.component.ts + 25 + + + + Last 3 months + + src/app/components/common/date-dropdown/date-dropdown.component.ts + 26 + + + + Last year + + src/app/components/common/date-dropdown/date-dropdown.component.ts + 27 + + + + After + + src/app/components/common/date-dropdown/date-dropdown.component.html + 13 + + + + Before + + src/app/components/common/date-dropdown/date-dropdown.component.html + 29 + + + + Clear + + src/app/components/common/date-dropdown/date-dropdown.component.html + 18 + + + + View + + src/app/components/document-list/document-card-large/document-card-large.component.html + 50 + + + + Score: + + src/app/components/document-list/document-card-large/document-card-large.component.html + 61 + + + + Created: + + src/app/components/document-list/document-card-large/document-card-large.component.html + 65 + + + + Filter by correspondent + + src/app/components/document-list/document-card-large/document-card-large.component.html + 20 + + + + Filter by tag + + src/app/components/document-list/document-card-large/document-card-large.component.html + 24 + + + + View in browser + + src/app/components/document-list/document-card-small/document-card-small.component.html + 40 + + + + "" and "" + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 103 + + This is for messages like 'modify "tag1" and "tag2"' + + + "" + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 105 + + + + , + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 105 + + this is used to separate enumerations and should probably be a comma and a whitespace in most languages + + + and "" + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 106 + + this is for messages like 'modify "tag1", "tag2" and "tag3"' + + + Confirm tags assignment + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 115 + + + + This operation will add the tag "" to selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 118 + + + + This operation will add the tags to selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 120 + + + + This operation will remove the tag "" from selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 123 + + + + This operation will remove the tags from selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 125 + + + + This operation will add the tags and remove the tags on selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 127 + + + + Confirm correspondent assignment + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 157 + + + + This operation will assign the correspondent "" to selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 159 + + + + This operation will remove the correspondent from selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 161 + + + + Confirm document type assignment + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 190 + + + + This operation will assign the document type "" to selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 192 + + + + This operation will remove the document type from selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 194 + + + + Delete confirm + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 219 + + + + This operation will permanently delete selected document(s). + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 220 + + + + This operation cannot be undone. + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 221 + + + + Delete document(s) + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 223 + + + + Select: + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 11 + + + + All + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 21 + + + + Edit: + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 28 + + + + Save current view + + src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html + 3 + + + + Show all + + src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html + 3 + + + + Statistics + + src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html + 1 + + + + Documents in inbox: + + src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html + 3 + + + + Total documents: + + src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html + 4 + + + + The document has been uploaded and will be processed by the consumer shortly. + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts + 63 + + + + There was an error while uploading the document: + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts + 71 + + + + An error has occurred while uploading the document. Sorry! + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts + 75 + + + + Upload new documents + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html + 1 + + + + Drop documents here or + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html + 5 + + + + Browse files + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html + 5 + + + + {VAR_PLURAL, plural, =1 {file} =other { files}} + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html + 13 + + + + Uploading ... + + src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html + 13 + + + + First steps + + src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html + 1 + + + + Paperless is running! :) + + src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html + 5 + + + + You can start uploading documents by dropping them in the file upload box to the right or by dropping them in the configured consumption folder and they'll start showing up in the documents list. After you've added some metadata to your documents, use the filtering mechanisms of paperless to create custom views (such as 'Recently added', 'Tagged TODO') and they will appear on the dashboard instead of this message. + + src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html + 6,7 + + + + Paperless offers some more features that try to make your life easier: + + src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html + 8 + + + + Once you've got a couple documents in paperless and added metadata to them, paperless can assign that metadata to new documents automatically. + + src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html + 10 + + + + You can configure paperless to read your mails and add documents from attached files. + + src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html + 11 + + + + Consult the documentation on how to use these features. The section on basic usage also has some information on how to use paperless in general. + + src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html + 13 + + + + Metadata + + src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts + 18 + + + + Select + + src/app/components/common/select-dialog/select-dialog.component.ts + 18 + + + + Please select an object + + src/app/components/common/select-dialog/select-dialog.component.ts + 21 + + + + Yes + + src/app/pipes/yes-no.pipe.ts + 9 + + + + No + + src/app/pipes/yes-no.pipe.ts + 9 + + + + (no title) + + src/app/pipes/document-title.pipe.ts + 12 + + + + Error + + src/app/services/toast.service.ts + 31 + + + + Information + + src/app/services/toast.service.ts + 35 + + + + Correspondent + + src/app/services/rest/document.service.ts + 16 + + + + Document type + + src/app/services/rest/document.service.ts + 17 + + + + Title + + src/app/services/rest/document.service.ts + 18 + + + + ASN + + src/app/services/rest/document.service.ts + 19 + + + + Created + + src/app/services/rest/document.service.ts + 20 + + + + Added + + src/app/services/rest/document.service.ts + 21 + + + + Modified + + src/app/services/rest/document.service.ts + 22 + + + + Light blue + + src/app/data/paperless-tag.ts + 6 + + + + Blue + + src/app/data/paperless-tag.ts + 7 + + + + Light green + + src/app/data/paperless-tag.ts + 8 + + + + Green + + src/app/data/paperless-tag.ts + 9 + + + + Light red + + src/app/data/paperless-tag.ts + 10 + + + + Red + + src/app/data/paperless-tag.ts + 11 + + + + Light orange + + src/app/data/paperless-tag.ts + 12 + + + + Orange + + src/app/data/paperless-tag.ts + 13 + + + + Light violet + + src/app/data/paperless-tag.ts + 14 + + + + Violet + + src/app/data/paperless-tag.ts + 15 + + + + Brown + + src/app/data/paperless-tag.ts + 16 + + + + Black + + src/app/data/paperless-tag.ts + 17 + + + + Light grey + + src/app/data/paperless-tag.ts + 18 + + + + Create new item + + src/app/components/common/edit-dialog/edit-dialog.component.ts + 38 + + + + Edit item + + src/app/components/common/edit-dialog/edit-dialog.component.ts + 42 + + + + Could not save element: + + src/app/components/common/edit-dialog/edit-dialog.component.ts + 46 + + + + Automatic + + src/app/components/manage/generic-list/generic-list.component.ts + 31 + + + + Do you really want to delete this element? + + src/app/components/manage/generic-list/generic-list.component.ts + 88 + + + + Associated documents will not be deleted. + + src/app/components/manage/generic-list/generic-list.component.ts + 95 + + + + Delete + + src/app/components/manage/generic-list/generic-list.component.ts + 97 + + + + Any + + src/app/data/matching-model.ts + 12 + + + + All + + src/app/data/matching-model.ts + 13 + + + + Literal + + src/app/data/matching-model.ts + 14 + + + + Regular expression + + src/app/data/matching-model.ts + 15 + + + + Fuzzy match + + src/app/data/matching-model.ts + 16 + + + + Auto + + src/app/data/matching-model.ts + 17 + + + + + diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 37b3a027d..c78dc3cfe 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -26,12 +26,13 @@ import { ResultHighlightComponent } from './components/search/result-highlight/r import { PageHeaderComponent } from './components/common/page-header/page-header.component'; import { AppFrameComponent } from './components/app-frame/app-frame.component'; import { ToastsComponent } from './components/common/toasts/toasts.component'; -import { FilterEditorComponent } from './components/filter-editor/filter-editor.component'; -import { FilterDropdownComponent } from './components/filter-editor/filter-dropdown/filter-dropdown.component'; -import { FilterDropdownButtonComponent } from './components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component'; -import { FilterDropdownDateComponent } from './components/filter-editor/filter-dropdown-date/filter-dropdown-date.component'; +import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'; +import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'; +import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; +import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'; import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'; import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'; +import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'; import { NgxFileDropModule } from 'ngx-file-drop'; import { TextComponent } from './components/common/input/text/text.component'; import { SelectComponent } from './components/common/input/select/select.component'; @@ -54,8 +55,8 @@ import { FileSizePipe } from './pipes/file-size.pipe'; import { FilterPipe } from './pipes/filter.pipe'; import { DocumentTitlePipe } from './pipes/document-title.pipe'; import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component'; -import { NgSelectModule } from '@ng-select/ng-select'; import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component'; +import { NgSelectModule } from '@ng-select/ng-select'; @NgModule({ declarations: [ @@ -80,11 +81,12 @@ import { SelectDialogComponent } from './components/common/select-dialog/select- AppFrameComponent, ToastsComponent, FilterEditorComponent, - FilterDropdownComponent, - FilterDropdownButtonComponent, - FilterDropdownDateComponent, + FilterableDropdownComponent, + ToggleableDropdownButtonComponent, + DateDropdownComponent, DocumentCardLargeComponent, DocumentCardSmallComponent, + BulkEditorComponent, TextComponent, SelectComponent, CheckComponent, diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 2458005f4..d191ec0de 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -1,16 +1,16 @@ @@ -28,136 +28,122 @@ - - Dashboard +  Dashboard diff --git a/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.ts index 4791d0e77..c397811a4 100644 --- a/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.ts +++ b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.ts @@ -14,7 +14,7 @@ export class ConfirmDialogComponent implements OnInit { public confirmClicked = new EventEmitter() @Input() - title = "Confirmation" + title = $localize`Confirmation` @Input() messageBold @@ -26,7 +26,7 @@ export class ConfirmDialogComponent implements OnInit { btnClass = "btn-primary" @Input() - btnCaption = "Confirm" + btnCaption = $localize`Confirm` confirmButtonEnabled = true seconds = 0 diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown-date/filter-dropdown-date.component.html b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.html similarity index 89% rename from src-ui/src/app/components/filter-editor/filter-dropdown-date/filter-dropdown-date.component.html rename to src-ui/src/app/components/common/date-dropdown/date-dropdown.component.html index aca6e836c..e4f17c4e6 100644 --- a/src-ui/src/app/components/filter-editor/filter-dropdown-date/filter-dropdown-date.component.html +++ b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.html @@ -2,7 +2,7 @@ -
- +
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index d705c3176..053258f34 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -158,11 +158,11 @@ export class DocumentDetailComponent implements OnInit { delete() { let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) - modal.componentInstance.title = "Confirm delete" - modal.componentInstance.messageBold = `Do you really want to delete document '${this.document.title}'?` - modal.componentInstance.message = `The files for this document will be deleted permanently. This operation cannot be undone.` + modal.componentInstance.title = $localize`Confirm delete` + modal.componentInstance.messageBold = $localize`Do you really want to delete document "${this.document.title}"?` + modal.componentInstance.message = $localize`The files for this document will be deleted permanently. This operation cannot be undone.` modal.componentInstance.btnClass = "btn-danger" - modal.componentInstance.btnCaption = "Delete document" + modal.componentInstance.btnCaption = $localize`Delete document` modal.componentInstance.confirmClicked.subscribe(() => { this.documentsService.delete(this.document).subscribe(() => { modal.close() diff --git a/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts index 160274e41..34bbbd655 100644 --- a/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts +++ b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts @@ -15,7 +15,7 @@ export class MetadataCollapseComponent implements OnInit { metadata @Input() - title = "Metadata" + title = $localize`Metadata` ngOnInit(): void { } diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html new file mode 100644 index 000000000..b9912ec1d --- /dev/null +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -0,0 +1,67 @@ +
+
+ +
+
+
+ +
+ + +
+
+
+
+
+ + + + + + + +
+
+
+
+ +
+
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.scss b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown-date/filter-dropdown-date.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts similarity index 50% rename from src-ui/src/app/components/filter-editor/filter-dropdown-date/filter-dropdown-date.component.spec.ts rename to src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 6bf59e2e7..140d73301 100644 --- a/src-ui/src/app/components/filter-editor/filter-dropdown-date/filter-dropdown-date.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -1,20 +1,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FilterDropdownDateComponent } from './filter-dropdown-date.component'; +import { BulkEditorComponent } from './bulk-editor.component'; -describe('FilterDropdownDateComponent', () => { - let component: FilterDropdownDateComponent; - let fixture: ComponentFixture; +describe('BulkEditorComponent', () => { + let component: BulkEditorComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ FilterDropdownDateComponent ] + declarations: [ BulkEditorComponent ] }) .compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(FilterDropdownDateComponent); + fixture = TestBed.createComponent(BulkEditorComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts new file mode 100644 index 000000000..e69ab241b --- /dev/null +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -0,0 +1,232 @@ +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { PaperlessTag } from 'src/app/data/paperless-tag'; +import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; +import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; +import { TagService } from 'src/app/services/rest/tag.service'; +import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; +import { DocumentTypeService } from 'src/app/services/rest/document-type.service'; +import { DocumentListViewService } from 'src/app/services/document-list-view.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { DocumentService, SelectionDataItem } from 'src/app/services/rest/document.service'; +import { OpenDocumentsService } from 'src/app/services/open-documents.service'; +import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'; +import { ChangedItems, FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component'; +import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; +import { MatchingModel } from 'src/app/data/matching-model'; +import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; + +@Component({ + selector: 'app-bulk-editor', + templateUrl: './bulk-editor.component.html', + styleUrls: ['./bulk-editor.component.scss'] +}) +export class BulkEditorComponent { + + tags: PaperlessTag[] + correspondents: PaperlessCorrespondent[] + documentTypes: PaperlessDocumentType[] + + tagSelectionModel = new FilterableDropdownSelectionModel() + correspondentSelectionModel = new FilterableDropdownSelectionModel() + documentTypeSelectionModel = new FilterableDropdownSelectionModel() + + constructor( + private documentTypeService: DocumentTypeService, + private tagService: TagService, + private correspondentService: CorrespondentService, + public list: DocumentListViewService, + private documentService: DocumentService, + private modalService: NgbModal, + private openDocumentService: OpenDocumentsService, + private settings: SettingsService + ) { } + + applyOnClose: boolean = this.settings.get(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE) + showConfirmationDialogs: boolean = this.settings.get(SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS) + + ngOnInit() { + this.tagService.listAll().subscribe(result => this.tags = result.results) + this.correspondentService.listAll().subscribe(result => this.correspondents = result.results) + this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results) + } + + private executeBulkOperation(method: string, args): Observable { + return this.documentService.bulkEdit(Array.from(this.list.selected), method, args).pipe( + tap(() => { + this.list.reload() + this.list.reduceSelectionToFilter() + this.list.selected.forEach(id => { + this.openDocumentService.refreshDocument(id) + }) + }) + ) + } + + private applySelectionData(items: SelectionDataItem[], selectionModel: FilterableDropdownSelectionModel) { + let selectionData = new Map() + items.forEach(i => { + if (i.document_count == this.list.selected.size) { + selectionData.set(i.id, ToggleableItemState.Selected) + } else if (i.document_count > 0) { + selectionData.set(i.id, ToggleableItemState.PartiallySelected) + } + }) + selectionModel.init(selectionData) + } + + openTagsDropdown() { + this.documentService.getSelectionData(Array.from(this.list.selected)).subscribe(s => { + this.applySelectionData(s.selected_tags, this.tagSelectionModel) + }) + } + + openDocumentTypeDropdown() { + this.documentService.getSelectionData(Array.from(this.list.selected)).subscribe(s => { + this.applySelectionData(s.selected_document_types, this.documentTypeSelectionModel) + }) + } + + openCorrespondentDropdown() { + this.documentService.getSelectionData(Array.from(this.list.selected)).subscribe(s => { + this.applySelectionData(s.selected_correspondents, this.correspondentSelectionModel) + }) + } + + private _localizeList(items: MatchingModel[]) { + if (items.length == 0) { + return "" + } else if (items.length == 1) { + return items[0].name + } else if (items.length == 2) { + return $localize`:This is for messages like 'modify "tag1" and "tag2"':"${items[0].name}" and "${items[1].name}"` + } else { + let list = items.slice(0, items.length - 1).map(i => $localize`"${i.name}"`).join($localize`:this is used to separate enumerations and should probably be a comma and a whitespace in most languages:, `) + return $localize`:this is for messages like 'modify "tag1", "tag2" and "tag3"':${list} and "${items[items.length - 1].name}"` + } + } + + setTags(changedTags: ChangedItems) { + if (changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length == 0) return + + if (this.showConfirmationDialogs) { + let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) + modal.componentInstance.title = $localize`Confirm tags assignment` + if (changedTags.itemsToAdd.length == 1 && changedTags.itemsToRemove.length == 0) { + let tag = changedTags.itemsToAdd[0] + modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).` + } else if (changedTags.itemsToAdd.length > 1 && changedTags.itemsToRemove.length == 0) { + modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(changedTags.itemsToAdd)} to ${this.list.selected.size} selected document(s).` + } else if (changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length == 1) { + let tag = changedTags.itemsToRemove[0] + modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).` + } else if (changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length > 1) { + modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(changedTags.itemsToRemove)} from ${this.list.selected.size} selected document(s).` + } else { + modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(changedTags.itemsToAdd)} and remove the tags ${this._localizeList(changedTags.itemsToRemove)} on ${this.list.selected.size} selected document(s).` + } + + modal.componentInstance.btnClass = "btn-warning" + modal.componentInstance.btnCaption = $localize`Confirm` + modal.componentInstance.confirmClicked.subscribe(() => { + this.performSetTags(modal, changedTags) + }) + } else { + this.performSetTags(null, changedTags) + } + } + + private performSetTags(modal, changedTags: ChangedItems) { + this.executeBulkOperation('modify_tags', {"add_tags": changedTags.itemsToAdd.map(t => t.id), "remove_tags": changedTags.itemsToRemove.map(t => t.id)}).subscribe( + response => { + if (modal) { + modal.close() + } + } + ) + } + + setCorrespondents(changedCorrespondents: ChangedItems) { + if (changedCorrespondents.itemsToAdd.length == 0 && changedCorrespondents.itemsToRemove.length == 0) return + + let correspondent = changedCorrespondents.itemsToAdd.length > 0 ? changedCorrespondents.itemsToAdd[0] : null + + if (this.showConfirmationDialogs) { + let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) + modal.componentInstance.title = $localize`Confirm correspondent assignment` + if (correspondent) { + modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).` + } else { + modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).` + } + modal.componentInstance.btnClass = "btn-warning" + modal.componentInstance.btnCaption = $localize`Confirm` + modal.componentInstance.confirmClicked.subscribe(() => { + this.performSetCorrespondents(modal, correspondent) + }) + } else { + this.performSetCorrespondents(null, correspondent) + } + } + + private performSetCorrespondents(modal, correspondent: MatchingModel) { + this.executeBulkOperation('set_correspondent', {"correspondent": correspondent ? correspondent.id : null}).subscribe( + response => { + if (modal) { + modal.close() + } + } + ) + } + + setDocumentTypes(changedDocumentTypes: ChangedItems) { + if (changedDocumentTypes.itemsToAdd.length == 0 && changedDocumentTypes.itemsToRemove.length == 0) return + + let documentType = changedDocumentTypes.itemsToAdd.length > 0 ? changedDocumentTypes.itemsToAdd[0] : null + + if (this.showConfirmationDialogs) { + let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) + modal.componentInstance.title = $localize`Confirm document type assignment` + if (documentType) { + modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).` + } else { + modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).` + } + modal.componentInstance.btnClass = "btn-warning" + modal.componentInstance.btnCaption = $localize`Confirm` + modal.componentInstance.confirmClicked.subscribe(() => { + this.performSetDocumentTypes(modal, documentType) + }) + } else { + this.performSetDocumentTypes(null, documentType) + } + } + + private performSetDocumentTypes(modal, documentType) { + this.executeBulkOperation('set_document_type', {"document_type": documentType ? documentType.id : null}).subscribe( + response => { + if (modal) { + modal.close() + } + } + ) + } + + applyDelete() { + let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) + modal.componentInstance.delayConfirm(5) + modal.componentInstance.title = $localize`Delete confirm` + modal.componentInstance.messageBold = $localize`This operation will permanently delete ${this.list.selected.size} selected document(s).` + modal.componentInstance.message = $localize`This operation cannot be undone.` + modal.componentInstance.btnClass = "btn-danger" + modal.componentInstance.btnCaption = $localize`Delete document(s)` + modal.componentInstance.confirmClicked.subscribe(() => { + this.executeBulkOperation("delete", {}).subscribe( + response => { + modal.close() + } + ) + }) + } +} diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html index 8f52f707f..c1757eb35 100644 --- a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html +++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html @@ -1,11 +1,11 @@
- +
- +
@@ -17,11 +17,11 @@
- {{(document.correspondent$ | async)?.name}} + {{(document.correspondent$ | async)?.name}} {{(document.correspondent$ | async)?.name}}: {{document.title | documentTitle}} - +
#{{document.archive_serial_number}}
@@ -36,37 +36,33 @@
#{{document.archiv - - More like this +  More like this - - Edit +  Edit - - View +  View - - Download +  Download
- Score: + Score: - Created: {{document.created | date}} + Created: {{document.created | date}}
diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts index 336af41da..e3bd4b7f7 100644 --- a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts +++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts @@ -12,15 +12,11 @@ export class DocumentCardLargeComponent implements OnInit { constructor(private documentService: DocumentService, private sanitizer: DomSanitizer) { } - _selected = false - - get selected() { - return this._selected - } - @Input() - set selected(value: boolean) { - this._selected = value + selected = false + + setSelected(value: boolean) { + this.selected = value this.selectedChange.emit(value) } diff --git a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html index 658aa1ea0..f6128e077 100644 --- a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html +++ b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html @@ -1,18 +1,18 @@ -
-
+
+
- + -
+
- +
- +
+ {{moreTags}} @@ -23,7 +23,7 @@

- {{(document.correspondent$ | async)?.name}}: + {{(document.correspondent$ | async)?.name}}: {{document.title | documentTitle}}

@@ -32,18 +32,18 @@
- + - + - + diff --git a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.scss b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.scss index 36db2203c..a4af1bb11 100644 --- a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.scss +++ b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.scss @@ -8,7 +8,15 @@ } .document-card-check { - display: none + display: none; + position: absolute; + top: 0; + left: 0; + + .custom-control { + margin-left: 4px; + margin-right: -3px; + } } .document-card:hover .document-card-check { @@ -17,8 +25,12 @@ .card-selected { border-color: $primary; + + .document-card-check { + display: block; + } } .doc-img-background-selected { background-color: $primaryFaded; -} \ No newline at end of file +} diff --git a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts index 6b7f86614..ed69c5c50 100644 --- a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts +++ b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts @@ -12,15 +12,11 @@ export class DocumentCardSmallComponent implements OnInit { constructor(private documentService: DocumentService) { } - _selected = false - - get selected() { - return this._selected - } - @Input() - set selected(value: boolean) { - this._selected = value + selected = false + + setSelected(value: boolean) { + this.selected = value this.selectedChange.emit(value) } diff --git a/src-ui/src/app/components/document-list/document-list.component.html b/src-ui/src/app/components/document-list/document-list.component.html index 25bcb7468..e627c428d 100644 --- a/src-ui/src/app/components/document-list/document-list.component.html +++ b/src-ui/src/app/components/document-list/document-list.component.html @@ -1,25 +1,16 @@
- -
- - - - - - - - - - - - +
+ + +
@@ -47,7 +38,7 @@
- +
@@ -70,15 +61,15 @@
- +
@@ -87,11 +78,13 @@
- + +
-

Selected {{list.selected.size}} of {{list.collectionSize || 0}} document(s) (filtered)

+

Selected {{list.selected.size}} of {{list.collectionSize || 0}} {list.collectionSize, plural, =1 {document} other {documents}}

+

{list.collectionSize, plural, =1 {1 document} other {{{list.collectionSize || 0}} documents}}

@@ -104,12 +97,12 @@ - - - - - - + + + + + + @@ -146,7 +139,6 @@
ASNCorrespondentTitleDocument typeCreatedAddedASNCorrespondentTitleDocument typeCreatedAdded
-
diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index f72a92aa9..fbd8065ae 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -1,22 +1,14 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; import { PaperlessDocument } from 'src/app/data/paperless-document'; import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; import { DocumentListViewService } from 'src/app/services/document-list-view.service'; -import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; -import { DocumentTypeService } from 'src/app/services/rest/document-type.service'; -import { DocumentService, DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service'; -import { TagService } from 'src/app/services/rest/tag.service'; +import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service'; import { SavedViewService } from 'src/app/services/rest/saved-view.service'; import { Toast, ToastService } from 'src/app/services/toast.service'; -import { FilterEditorComponent } from '../filter-editor/filter-editor.component'; -import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'; -import { SelectDialogComponent } from '../common/select-dialog/select-dialog.component'; +import { FilterEditorComponent } from './filter-editor/filter-editor.component'; import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'; -import { OpenDocumentsService } from 'src/app/services/open-documents.service'; @Component({ selector: 'app-document-list', @@ -31,12 +23,7 @@ export class DocumentListComponent implements OnInit { public route: ActivatedRoute, private router: Router, private toastService: ToastService, - public modalService: NgbModal, - private correspondentService: CorrespondentService, - private documentTypeService: DocumentTypeService, - private tagService: TagService, - private documentService: DocumentService, - private openDocumentService: OpenDocumentsService) { } + private modalService: NgbModal) { } @ViewChild("filterEditor") private filterEditor: FilterEditorComponent @@ -48,13 +35,17 @@ export class DocumentListComponent implements OnInit { } getTitle() { - return this.list.savedViewTitle || "Documents" + return this.list.savedViewTitle || $localize`Documents` } getSortFields() { return DOCUMENT_SORT_FIELDS } + get isBulkEditing(): boolean { + return this.list.selected.size > 0 + } + saveDisplayMode() { localStorage.setItem('document-list:displayMode', this.displayMode) } @@ -90,7 +81,7 @@ export class DocumentListComponent implements OnInit { saveViewConfig() { this.savedViewService.update(this.list.savedView).subscribe(result => { - this.toastService.showToast(Toast.make("Information", `View "${this.list.savedView.name}" saved successfully.`)) + this.toastService.showInfo($localize`View "${this.list.savedView.name}" saved successfully.`) }) } @@ -109,139 +100,33 @@ export class DocumentListComponent implements OnInit { } this.savedViewService.create(savedView).subscribe(() => { modal.close() - this.toastService.showToast(Toast.make("Information", `View "${savedView.name}" created successfully.`)) + this.toastService.showInfo($localize`View "${savedView.name}" created successfully.`) }) }) } clickTag(tagID: number) { - this.filterEditor.toggleTag(tagID) + this.list.selectNone() + setTimeout(() => { + this.filterEditor.toggleTag(tagID) + }) } clickCorrespondent(correspondentID: number) { - this.filterEditor.toggleCorrespondent(correspondentID) + this.list.selectNone() + setTimeout(() => { + this.filterEditor.toggleCorrespondent(correspondentID) + }) } clickDocumentType(documentTypeID: number) { - this.filterEditor.toggleDocumentType(documentTypeID) + this.list.selectNone() + setTimeout(() => { + this.filterEditor.toggleDocumentType(documentTypeID) + }) } trackByDocumentId(index, item: PaperlessDocument) { return item.id } - - private executeBulkOperation(method: string, args): Observable { - return this.documentService.bulkEdit(Array.from(this.list.selected), method, args).pipe( - tap(() => { - this.list.reload() - this.list.selected.forEach(id => { - this.openDocumentService.refreshDocument(id) - }) - this.list.selectNone() - }) - ) - } - - bulkSetCorrespondent() { - let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'}) - modal.componentInstance.title = "Select correspondent" - modal.componentInstance.message = `Select the correspondent you wish to assign to ${this.list.selected.size} selected document(s):` - this.correspondentService.listAll().subscribe(response => { - modal.componentInstance.objects = response.results - }) - modal.componentInstance.selectClicked.subscribe(selectedId => { - this.executeBulkOperation('set_correspondent', {"correspondent": selectedId}).subscribe( - response => { - modal.close() - } - ) - }) - } - - bulkRemoveCorrespondent() { - let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) - modal.componentInstance.title = "Remove correspondent" - modal.componentInstance.message = `This operation will remove the correspondent from all ${this.list.selected.size} selected document(s).` - modal.componentInstance.confirmClicked.subscribe(() => { - this.executeBulkOperation('set_correspondent', {"correspondent": null}).subscribe(r => { - modal.close() - }) - }) - } - - bulkSetDocumentType() { - let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'}) - modal.componentInstance.title = "Select document type" - modal.componentInstance.message = `Select the document type you wish to assign to ${this.list.selected.size} selected document(s):` - this.documentTypeService.listAll().subscribe(response => { - modal.componentInstance.objects = response.results - }) - modal.componentInstance.selectClicked.subscribe(selectedId => { - this.executeBulkOperation('set_document_type', {"document_type": selectedId}).subscribe( - response => { - modal.close() - } - ) - }) - } - - bulkRemoveDocumentType() { - let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) - modal.componentInstance.title = "Remove document type" - modal.componentInstance.message = `This operation will remove the document type from all ${this.list.selected.size} selected document(s).` - modal.componentInstance.confirmClicked.subscribe(() => { - this.executeBulkOperation('set_document_type', {"document_type": null}).subscribe(r => { - modal.close() - }) - }) - } - - bulkAddTag() { - let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'}) - modal.componentInstance.title = "Select tag" - modal.componentInstance.message = `Select the tag you wish to assign to ${this.list.selected.size} selected document(s):` - this.tagService.listAll().subscribe(response => { - modal.componentInstance.objects = response.results - }) - modal.componentInstance.selectClicked.subscribe(selectedId => { - this.executeBulkOperation('add_tag', {"tag": selectedId}).subscribe( - response => { - modal.close() - } - ) - }) - } - - bulkRemoveTag() { - let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'}) - modal.componentInstance.title = "Select tag" - modal.componentInstance.message = `Select the tag you wish to remove from ${this.list.selected.size} selected document(s):` - this.tagService.listAll().subscribe(response => { - modal.componentInstance.objects = response.results - }) - modal.componentInstance.selectClicked.subscribe(selectedId => { - this.executeBulkOperation('remove_tag', {"tag": selectedId}).subscribe( - response => { - modal.close() - } - ) - }) - } - - bulkDelete() { - let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) - modal.componentInstance.delayConfirm(5) - modal.componentInstance.title = "Delete confirm" - modal.componentInstance.messageBold = `This operation will permanently delete all ${this.list.selected.size} selected document(s).` - modal.componentInstance.message = `This operation cannot be undone.` - modal.componentInstance.btnClass = "btn-danger" - modal.componentInstance.btnCaption = "Delete document(s)" - modal.componentInstance.confirmClicked.subscribe(() => { - this.executeBulkOperation("delete", {}).subscribe( - response => { - modal.close() - } - ) - }) - } } diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html new file mode 100644 index 000000000..efbf6ce7e --- /dev/null +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html @@ -0,0 +1,51 @@ +
+
+
+ + +
+
+
+
+
+ + + + + +
+
+
+
+ +
+
diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.scss b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.scss similarity index 100% rename from src-ui/src/app/components/filter-editor/filter-editor.component.scss rename to src-ui/src/app/components/document-list/filter-editor/filter-editor.component.scss diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts similarity index 100% rename from src-ui/src/app/components/filter-editor/filter-editor.component.spec.ts rename to src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts new file mode 100644 index 000000000..4b62d6a51 --- /dev/null +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -0,0 +1,203 @@ +import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core'; +import { PaperlessTag } from 'src/app/data/paperless-tag'; +import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; +import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; +import { Subject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged } 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 { FilterRule } from 'src/app/data/filter-rule'; +import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE } from 'src/app/data/filter-rule-type'; +import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component'; +import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; + +@Component({ + selector: 'app-filter-editor', + templateUrl: './filter-editor.component.html', + styleUrls: ['./filter-editor.component.scss'] +}) +export class FilterEditorComponent implements OnInit, OnDestroy { + + generateFilterName() { + if (this.filterRules.length == 1) { + let rule = this.filterRules[0] + switch(this.filterRules[0].rule_type) { + + case FILTER_CORRESPONDENT: + return $localize`Correspondent: ${this.correspondents.find(c => c.id == +rule.value)?.name}` + + case FILTER_DOCUMENT_TYPE: + return $localize`Type: ${this.documentTypes.find(dt => dt.id == +rule.value)?.name}` + + case FILTER_HAS_TAG: + return $localize`Tag: ${this.tags.find(t => t.id == +rule.value)?.name}` + + } + } + + return "" + } + + constructor( + private documentTypeService: DocumentTypeService, + private tagService: TagService, + private correspondentService: CorrespondentService + ) { } + + tags: PaperlessTag[] = [] + correspondents: PaperlessCorrespondent[] = [] + documentTypes: PaperlessDocumentType[] = [] + + _titleFilter = "" + + tagSelectionModel = new FilterableDropdownSelectionModel() + correspondentSelectionModel = new FilterableDropdownSelectionModel() + documentTypeSelectionModel = new FilterableDropdownSelectionModel() + + dateCreatedBefore: string + dateCreatedAfter: string + dateAddedBefore: string + dateAddedAfter: string + + @Input() + set filterRules (value: FilterRule[]) { + this.documentTypeSelectionModel.clear(false) + this.tagSelectionModel.clear(false) + this.correspondentSelectionModel.clear(false) + + value.forEach(rule => { + switch (rule.rule_type) { + case FILTER_TITLE: + this._titleFilter = rule.value + break + case FILTER_CREATED_AFTER: + this.dateCreatedAfter = rule.value + break + case FILTER_CREATED_BEFORE: + this.dateCreatedBefore = rule.value + break + case FILTER_ADDED_AFTER: + this.dateAddedAfter = rule.value + break + case FILTER_ADDED_BEFORE: + this.dateAddedBefore = rule.value + break + case FILTER_HAS_TAG: + this.tagSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) + break + case FILTER_HAS_ANY_TAG: + this.tagSelectionModel.set(null, ToggleableItemState.Selected, false) + break + case FILTER_CORRESPONDENT: + this.correspondentSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) + break + case FILTER_DOCUMENT_TYPE: + this.documentTypeSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) + break + } + }) + } + + get filterRules() { + let filterRules: FilterRule[] = [] + if (this._titleFilter) { + filterRules.push({rule_type: FILTER_TITLE, value: this._titleFilter}) + } + if (this.tagSelectionModel.isNoneSelected()) { + filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"}) + } else { + this.tagSelectionModel.getSelectedItems().filter(tag => tag.id).forEach(tag => { + filterRules.push({rule_type: FILTER_HAS_TAG, value: tag.id?.toString()}) + }) + } + this.correspondentSelectionModel.getSelectedItems().forEach(correspondent => { + filterRules.push({rule_type: FILTER_CORRESPONDENT, value: correspondent.id?.toString()}) + }) + this.documentTypeSelectionModel.getSelectedItems().forEach(documentType => { + filterRules.push({rule_type: FILTER_DOCUMENT_TYPE, value: documentType.id?.toString()}) + }) + if (this.dateCreatedBefore) { + filterRules.push({rule_type: FILTER_CREATED_BEFORE, value: this.dateCreatedBefore}) + } + if (this.dateCreatedAfter) { + filterRules.push({rule_type: FILTER_CREATED_AFTER, value: this.dateCreatedAfter}) + } + if (this.dateAddedBefore) { + filterRules.push({rule_type: FILTER_ADDED_BEFORE, value: this.dateAddedBefore}) + } + if (this.dateAddedAfter) { + filterRules.push({rule_type: FILTER_ADDED_AFTER, value: this.dateAddedAfter}) + } + return filterRules + } + + @Output() + filterRulesChange = new EventEmitter() + + updateRules() { + this.filterRulesChange.next(this.filterRules) + } + + hasFilters() { + return this._titleFilter || + this.dateAddedAfter || this.dateAddedBefore || this.dateCreatedAfter || this.dateCreatedBefore || + this.tagSelectionModel.selectionSize() || this.correspondentSelectionModel.selectionSize() || this.documentTypeSelectionModel.selectionSize() + } + + get titleFilter() { + return this._titleFilter + } + + set titleFilter(value) { + this.titleFilterDebounce.next(value) + } + + titleFilterDebounce: Subject + subscription: Subscription + + ngOnInit() { + this.tagService.listAll().subscribe(result => this.tags = result.results) + this.correspondentService.listAll().subscribe(result => this.correspondents = result.results) + this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results) + + this.titleFilterDebounce = new Subject() + + this.subscription = this.titleFilterDebounce.pipe( + debounceTime(400), + distinctUntilChanged() + ).subscribe(title => { + this._titleFilter = title + this.updateRules() + }) + } + + ngOnDestroy() { + this.titleFilterDebounce.complete() + } + + clearSelected() { + this._titleFilter = "" + this.tagSelectionModel.clear(false) + this.documentTypeSelectionModel.clear(false) + this.correspondentSelectionModel.clear(false) + this.dateAddedBefore = null + this.dateAddedAfter = null + this.dateCreatedBefore = null + this.dateCreatedAfter = null + this.updateRules() + } + + toggleTag(tagId: number) { + this.tagSelectionModel.toggle(tagId) + } + + toggleCorrespondent(correspondentId: number) { + this.correspondentSelectionModel.toggle(correspondentId) + } + + toggleDocumentType(documentTypeId: number) { + this.documentTypeSelectionModel.toggle(documentTypeId) + } + +} diff --git a/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html b/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html index 8819aa313..9e28448a2 100644 --- a/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html +++ b/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html @@ -1,17 +1,17 @@
diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.html b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.html deleted file mode 100644 index 8dff12a33..000000000 --- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.html +++ /dev/null @@ -1,12 +0,0 @@ - diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.spec.ts b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.spec.ts deleted file mode 100644 index 5cf1fefa2..000000000 --- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { FilterDropodownButtonComponent } from './filter-dropdown-button.component'; - -describe('FilterDropodownButtonComponent', () => { - let component: FilterDropodownButtonComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ FilterDropodownButtonComponent ] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(FilterDropodownButtonComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.ts b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.ts deleted file mode 100644 index d3ddd3cbf..000000000 --- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core'; -import { PaperlessTag } from 'src/app/data/paperless-tag'; -import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; -import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; - -@Component({ - selector: 'app-filter-dropdown-button', - templateUrl: './filter-dropdown-button.component.html', - styleUrls: ['./filter-dropdown-button.component.scss'] -}) -export class FilterDropdownButtonComponent implements OnInit { - - @Input() - item: PaperlessTag | PaperlessDocumentType | PaperlessCorrespondent - - @Input() - selected: boolean - - @Output() - toggle = new EventEmitter() - - isTag: boolean - - ngOnInit() { - this.isTag = 'is_inbox_tag' in this.item // ~ this.item instanceof PaperlessTag - } - - toggleItem(): void { - this.selected = !this.selected - this.toggle.emit(this.item) - } -} diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.html b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.html deleted file mode 100644 index d0cbfc3c9..000000000 --- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.html +++ /dev/null @@ -1,29 +0,0 @@ -
- - -
diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.ts b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.ts deleted file mode 100644 index b9d3fca6f..000000000 --- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Component, EventEmitter, Input, Output, ElementRef, ViewChild } from '@angular/core'; -import { ObjectWithId } from 'src/app/data/object-with-id'; -import { FilterPipe } from 'src/app/pipes/filter.pipe'; -import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' - -@Component({ - selector: 'app-filter-dropdown', - templateUrl: './filter-dropdown.component.html', - styleUrls: ['./filter-dropdown.component.scss'] -}) -export class FilterDropdownComponent { - - constructor(private filterPipe: FilterPipe) { } - - @Input() - items: ObjectWithId[] - - @Input() - itemsSelected: ObjectWithId[] - - @Input() - title: string - - @Input() - icon: string - - @Output() - toggle = new EventEmitter() - - @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef - @ViewChild('filterDropdown') filterDropdown: NgbDropdown - - filterText: string - - toggleItem(item: ObjectWithId): void { - this.toggle.emit(item) - } - - isItemSelected(item: ObjectWithId): boolean { - return this.itemsSelected?.find(i => i.id == item.id) !== undefined - } - - dropdownOpenChange(open: boolean): void { - if (open) { - setTimeout(() => { - this.listFilterTextInput.nativeElement.focus(); - }, 0); - } else { - this.filterText = '' - } - } - - listFilterEnter(): void { - let filtered = this.filterPipe.transform(this.items, this.filterText) - if (filtered.length == 1) this.toggleItem(filtered.shift()) - this.filterDropdown.close() - } -} diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.html b/src-ui/src/app/components/filter-editor/filter-editor.component.html deleted file mode 100644 index 6847a2902..000000000 --- a/src-ui/src/app/components/filter-editor/filter-editor.component.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
-
- - -
-
-
-
-
- - - - - -
-
-
-
- -
-
diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/filter-editor/filter-editor.component.ts deleted file mode 100644 index 913c738a5..000000000 --- a/src-ui/src/app/components/filter-editor/filter-editor.component.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core'; -import { PaperlessTag } from 'src/app/data/paperless-tag'; -import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; -import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; -import { Subject, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; -import { NgbDateParserFormatter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; -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 { FilterRule } from 'src/app/data/filter-rule'; -import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES, FILTER_TITLE } from 'src/app/data/filter-rule-type'; -import { DateSelection } from './filter-dropdown-date/filter-dropdown-date.component'; - -@Component({ - selector: 'app-filter-editor', - templateUrl: './filter-editor.component.html', - styleUrls: ['./filter-editor.component.scss'] -}) -export class FilterEditorComponent implements OnInit, OnDestroy { - - generateFilterName() { - if (this.filterRules.length == 1) { - let rule = this.filterRules[0] - switch(this.filterRules[0].rule_type) { - - case FILTER_CORRESPONDENT: - return `Correspondent: ${this.correspondents.find(c => c.id == +rule.value)?.name}` - - case FILTER_DOCUMENT_TYPE: - return `Type: ${this.documentTypes.find(dt => dt.id == +rule.value)?.name}` - - case FILTER_HAS_TAG: - return `Tag: ${this.tags.find(t => t.id == +rule.value)?.name}` - - } - } - - return "" - } - - constructor( - private documentTypeService: DocumentTypeService, - private tagService: TagService, - private correspondentService: CorrespondentService, - private dateParser: NgbDateParserFormatter - ) { } - - tags: PaperlessTag[] = [] - correspondents: PaperlessCorrespondent[] - documentTypes: PaperlessDocumentType[] = [] - - @Input() - filterRules: FilterRule[] - - @Output() - filterRulesChange = new EventEmitter() - - hasFilters() { - return this.filterRules.length > 0 - } - - get selectedTags(): PaperlessTag[] { - let tagRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_HAS_TAG) - return this.tags?.filter(t => tagRules.find(tr => +tr.value == t.id)) - } - - get selectedCorrespondents(): PaperlessCorrespondent[] { - let correspondentRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_CORRESPONDENT) - return this.correspondents?.filter(c => correspondentRules.find(cr => +cr.value == c.id)) - } - - get selectedDocumentTypes(): PaperlessDocumentType[] { - let documentTypeRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_DOCUMENT_TYPE) - return this.documentTypes?.filter(dt => documentTypeRules.find(dtr => +dtr.value == dt.id)) - } - - get titleFilter() { - let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE) - return existingRule ? existingRule.value : '' - } - - set titleFilter(value) { - this.titleFilterDebounce.next(value) - } - - titleFilterDebounce: Subject - subscription: Subscription - - ngOnInit() { - this.tagService.listAll().subscribe(result => this.tags = result.results) - this.correspondentService.listAll().subscribe(result => this.correspondents = result.results) - this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results) - - this.titleFilterDebounce = new Subject() - - this.subscription = this.titleFilterDebounce.pipe( - debounceTime(400), - distinctUntilChanged() - ).subscribe(title => { - this.setTitleRule(title) - }) - } - - ngOnDestroy() { - this.titleFilterDebounce.complete() - // TODO: not sure if both is necessary - this.subscription.unsubscribe() - } - - applyFilters() { - this.filterRulesChange.next(this.filterRules) - } - - clearSelected() { - this.filterRules = [] - this.applyFilters() - } - - private toggleFilterRule(filterRuleTypeID: number, value: number) { - - let filterRuleType = FILTER_RULE_TYPES.find(t => t.id == filterRuleTypeID) - - let existingRule = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID && rule.value == value?.toString()) - let existingRuleOfSameType = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID) - - if (existingRule) { - // if this exact rule already exists, remove it in all cases. - this.filterRules.splice(this.filterRules.indexOf(existingRule), 1) - } else if (filterRuleType.multi || !existingRuleOfSameType) { - // if we allow multiple rules per type, or no rule of this type already exists, push a new rule. - this.filterRules.push({rule_type: filterRuleTypeID, value: value?.toString()}) - } else { - // otherwise (i.e., no multi support AND there's already a rule of this type), update the rule. - existingRuleOfSameType.value = value?.toString() - } - this.applyFilters() - } - - private setTitleRule(title: string) { - let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE) - - if (!existingRule && title) { - this.filterRules.push({rule_type: FILTER_TITLE, value: title}) - } else if (existingRule && !title) { - this.filterRules.splice(this.filterRules.findIndex(rule => rule.rule_type == FILTER_TITLE), 1) - } else if (existingRule && title) { - existingRule.value = title - } - this.applyFilters() - } - - toggleTag(tagId: number) { - this.toggleFilterRule(FILTER_HAS_TAG, tagId) - } - - toggleCorrespondent(correspondentId: number) { - this.toggleFilterRule(FILTER_CORRESPONDENT, correspondentId) - } - - toggleDocumentType(documentTypeId: number) { - this.toggleFilterRule(FILTER_DOCUMENT_TYPE, documentTypeId) - } - - - - // Date handling - - - onDatesCreatedSet(dates: DateSelection) { - this.setDateCreatedBefore(dates.before) - this.setDateCreatedAfter(dates.after) - this.applyFilters() - } - - onDatesAddedSet(dates: DateSelection) { - this.setDateAddedBefore(dates.before) - this.setDateAddedAfter(dates.after) - this.applyFilters() - } - - get dateCreatedBefore(): string { - let createdBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_BEFORE) - return createdBeforeRule ? createdBeforeRule.value : null - } - - get dateCreatedAfter(): string { - let createdAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_AFTER) - return createdAfterRule ? createdAfterRule.value : null - } - - get dateAddedBefore(): string { - let addedBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_BEFORE) - return addedBeforeRule ? addedBeforeRule.value : null - } - - get dateAddedAfter(): string { - let addedAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_AFTER) - return addedAfterRule ? addedAfterRule.value : null - } - - setDateCreatedBefore(date?: string) { - if (date) this.setDateFilter(date, FILTER_CREATED_BEFORE) - else this.clearDateFilter(FILTER_CREATED_BEFORE) - } - - setDateCreatedAfter(date?: string) { - if (date) this.setDateFilter(date, FILTER_CREATED_AFTER) - else this.clearDateFilter(FILTER_CREATED_AFTER) - } - - setDateAddedBefore(date?: string) { - if (date) this.setDateFilter(date, FILTER_ADDED_BEFORE) - else this.clearDateFilter(FILTER_ADDED_BEFORE) - } - - setDateAddedAfter(date?: string) { - if (date) this.setDateFilter(date, FILTER_ADDED_AFTER) - else this.clearDateFilter(FILTER_ADDED_AFTER) - } - - setDateFilter(date: string, dateRuleTypeID: number) { - let existingRule = this.filterRules.find(rule => rule.rule_type == dateRuleTypeID) - - if (existingRule) { - existingRule.value = date - } else { - this.filterRules.push({rule_type: dateRuleTypeID, value: date}) - } - } - - clearDateFilter(dateRuleTypeID: number) { - let ruleIndex = this.filterRules.findIndex(rule => rule.rule_type == dateRuleTypeID) - if (ruleIndex != -1) { - this.filterRules.splice(ruleIndex, 1) - } - } - -} diff --git a/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html b/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html index e09ea38bf..e35c57e26 100644 --- a/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html +++ b/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.html @@ -7,13 +7,13 @@
\ No newline at end of file diff --git a/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.ts b/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.ts index bc6b2a823..b6c3e08d4 100644 --- a/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.ts +++ b/src-ui/src/app/components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component.ts @@ -14,7 +14,19 @@ import { ToastService } from 'src/app/services/toast.service'; export class CorrespondentEditDialogComponent extends EditDialogComponent { constructor(service: CorrespondentService, activeModal: NgbActiveModal, toastService: ToastService) { - super(service, activeModal, toastService, 'correspondent') + super(service, activeModal, toastService) + } + + getCreateTitle() { + return $localize`Create new correspondent` + } + + getEditTitle() { + return $localize`Edit correspondent` + } + + getSaveErrorMessage(error: string) { + return $localize`Could not save correspondent: ${error}` } getForm(): FormGroup { diff --git a/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.html b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.html index 2efd1c58d..4abc72037 100644 --- a/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.html +++ b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.html @@ -1,7 +1,5 @@ - - + +
@@ -11,11 +9,11 @@ - - - - - + + + + + @@ -29,21 +27,18 @@ diff --git a/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts index a128340b9..37b6fa66e 100644 --- a/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts +++ b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts @@ -1,5 +1,4 @@ -import { Component, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { Component } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type'; import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; @@ -16,20 +15,16 @@ import { CorrespondentEditDialogComponent } from './correspondent-edit-dialog/co export class CorrespondentListComponent extends GenericListComponent { constructor(correspondentsService: CorrespondentService, modalService: NgbModal, - private router: Router, private list: DocumentListViewService ) { super(correspondentsService,modalService,CorrespondentEditDialogComponent) } - getObjectName(object: PaperlessCorrespondent) { - return `correspondent '${object.name}'` + getDeleteMessage(object: PaperlessCorrespondent) { + return $localize`Do you really want to delete the correspondent "${object.name}"?` } filterDocuments(object: PaperlessCorrespondent) { - this.list.documentListView.filter_rules = [ - {rule_type: FILTER_CORRESPONDENT, value: object.id.toString()} - ] - this.router.navigate(["documents"]) + this.list.quickFilter([{rule_type: FILTER_CORRESPONDENT, value: object.id.toString()}]) } } diff --git a/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.html b/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.html index 3338c40c3..458fbb4bc 100644 --- a/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.html +++ b/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.html @@ -7,14 +7,14 @@ \ No newline at end of file diff --git a/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.ts b/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.ts index a8052f453..df81f88c9 100644 --- a/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.ts +++ b/src-ui/src/app/components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component.ts @@ -14,7 +14,19 @@ import { ToastService } from 'src/app/services/toast.service'; export class DocumentTypeEditDialogComponent extends EditDialogComponent { constructor(service: DocumentTypeService, activeModal: NgbActiveModal, toastService: ToastService) { - super(service, activeModal, toastService, 'document type') + super(service, activeModal, toastService) + } + + getCreateTitle() { + return $localize`Create new document type` + } + + getEditTitle() { + return $localize`Edit document type` + } + + getSaveErrorMessage(error: string) { + return $localize`Could not save document type: ${error}` } getForm(): FormGroup { diff --git a/src-ui/src/app/components/manage/document-type-list/document-type-list.component.html b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.html index d2ffab400..e18d3ec00 100644 --- a/src-ui/src/app/components/manage/document-type-list/document-type-list.component.html +++ b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.html @@ -1,7 +1,5 @@ - - + +
@@ -12,10 +10,10 @@
NameMatchingDocument countLast correspondenceActionsNameMatchingDocument countLast correspondenceActions
- - - - + + + + @@ -28,21 +26,18 @@ diff --git a/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts index d18a19226..68c8b6f91 100644 --- a/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts +++ b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts @@ -1,5 +1,4 @@ import { Component } from '@angular/core'; -import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type'; import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; @@ -16,20 +15,17 @@ import { DocumentTypeEditDialogComponent } from './document-type-edit-dialog/doc export class DocumentTypeListComponent extends GenericListComponent { constructor(service: DocumentTypeService, modalService: NgbModal, - private router: Router, private list: DocumentListViewService ) { super(service, modalService, DocumentTypeEditDialogComponent) } - getObjectName(object: PaperlessDocumentType) { - return `document type '${object.name}'` + getDeleteMessage(object: PaperlessDocumentType) { + return $localize`Do you really want to delete the document type "${object.name}"?` } + filterDocuments(object: PaperlessDocumentType) { - this.list.documentListView.filter_rules = [ - {rule_type: FILTER_DOCUMENT_TYPE, value: object.id.toString()} - ] - this.router.navigate(["documents"]) + this.list.quickFilter([{rule_type: FILTER_DOCUMENT_TYPE, value: object.id.toString()}]) } } diff --git a/src-ui/src/app/components/manage/generic-list/generic-list.component.ts b/src-ui/src/app/components/manage/generic-list/generic-list.component.ts index 783c22b36..1f9cc65f9 100644 --- a/src-ui/src/app/components/manage/generic-list/generic-list.component.ts +++ b/src-ui/src/app/components/manage/generic-list/generic-list.component.ts @@ -28,7 +28,7 @@ export abstract class GenericListComponent implements On getMatching(o: MatchingModel) { if (o.matching_algorithm == MATCH_AUTO) { - return "Automatic" + return $localize`Automatic` } else if (o.match && o.match.length > 0) { return `${o.match} (${MATCHING_ALGORITHMS.find(a => a.id == o.matching_algorithm).name})` } else { @@ -84,17 +84,17 @@ export abstract class GenericListComponent implements On }) } - getObjectName(object: T) { - return object.toString() + getDeleteMessage(object: T) { + return $localize`Do you really want to delete this element?` } openDeleteDialog(object: T) { var activeModal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) - activeModal.componentInstance.title = "Confirm delete" - activeModal.componentInstance.messageBold = `Do you really want to delete ${this.getObjectName(object)}?` - activeModal.componentInstance.message = "Associated documents will not be deleted." + activeModal.componentInstance.title = $localize`Confirm delete` + activeModal.componentInstance.messageBold = this.getDeleteMessage(object) + activeModal.componentInstance.message = $localize`Associated documents will not be deleted.` activeModal.componentInstance.btnClass = "btn-danger" - activeModal.componentInstance.btnCaption = "Delete" + activeModal.componentInstance.btnCaption = $localize`Delete` activeModal.componentInstance.confirmClicked.subscribe(() => { this.service.delete(object).subscribe(_ => { activeModal.close() diff --git a/src-ui/src/app/components/manage/logs/logs.component.html b/src-ui/src/app/components/manage/logs/logs.component.html index 6af482c66..8c2cbae34 100644 --- a/src-ui/src/app/components/manage/logs/logs.component.html +++ b/src-ui/src/app/components/manage/logs/logs.component.html @@ -1,11 +1,12 @@ - +
+ +
-
No saved views defined.
+
No saved views defined.
diff --git a/src-ui/src/app/components/manage/settings/settings.component.ts b/src-ui/src/app/components/manage/settings/settings.component.ts index f839010b1..c26c63384 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.ts @@ -1,10 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; -import { GENERAL_SETTINGS } from 'src/app/data/storage-keys'; import { DocumentListViewService } from 'src/app/services/document-list-view.service'; import { SavedViewService } from 'src/app/services/rest/saved-view.service'; -import { Toast, ToastService } from 'src/app/services/toast.service'; +import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; +import { ToastService } from 'src/app/services/toast.service'; @Component({ selector: 'app-settings', @@ -16,14 +16,17 @@ export class SettingsComponent implements OnInit { savedViewGroup = new FormGroup({}) settingsForm = new FormGroup({ - 'documentListItemPerPage': new FormControl(+localStorage.getItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE) || GENERAL_SETTINGS.DOCUMENT_LIST_SIZE_DEFAULT), + 'bulkEditConfirmationDialogs': new FormControl(this.settings.get(SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS)), + 'bulkEditApplyOnClose': new FormControl(this.settings.get(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE)), + 'documentListItemPerPage': new FormControl(this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)), 'savedViews': this.savedViewGroup }) constructor( public savedViewService: SavedViewService, private documentListViewService: DocumentListViewService, - private toastService: ToastService + private toastService: ToastService, + private settings: SettingsService ) { } savedViews: PaperlessSavedView[] @@ -46,14 +49,16 @@ export class SettingsComponent implements OnInit { this.savedViewService.delete(savedView).subscribe(() => { this.savedViewGroup.removeControl(savedView.id.toString()) this.savedViews.splice(this.savedViews.indexOf(savedView), 1) - this.toastService.showToast(Toast.make("Information", `Saved view "${savedView.name} deleted.`)) + this.toastService.showInfo($localize`Saved view "${savedView.name} deleted.`) }) } private saveLocalSettings() { - localStorage.setItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage) + this.settings.set(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE, this.settingsForm.value.bulkEditApplyOnClose) + this.settings.set(SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS, this.settingsForm.value.bulkEditConfirmationDialogs) + this.settings.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage) this.documentListViewService.updatePageSize() - this.toastService.showToast(Toast.make("Information", "Settings saved successfully.")) + this.toastService.showInfo($localize`Settings saved successfully.`) } saveSettings() { @@ -65,7 +70,7 @@ export class SettingsComponent implements OnInit { this.savedViewService.patchMany(x).subscribe(s => { this.saveLocalSettings() }, error => { - this.toastService.showToast(Toast.makeError(`Error while storing settings on server: ${JSON.stringify(error.error)}`)) + this.toastService.showError($localize`Error while storing settings on server: ${JSON.stringify(error.error)}`) }) } else { this.saveLocalSettings() diff --git a/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.html b/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.html index 138d3e7cd..bbb96fb6b 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.html +++ b/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.html @@ -10,7 +10,7 @@
- + {{item.name}} @@ -18,13 +18,13 @@
- - - - + + + + diff --git a/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.ts b/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.ts index bb0162608..ceca19142 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component.ts @@ -14,7 +14,19 @@ import { ToastService } from 'src/app/services/toast.service'; export class TagEditDialogComponent extends EditDialogComponent { constructor(service: TagService, activeModal: NgbActiveModal, toastService: ToastService) { - super(service, activeModal, toastService, 'tag') + super(service, activeModal, toastService) + } + + getCreateTitle() { + return $localize`Create new tag` + } + + getEditTitle() { + return $localize`Edit tag` + } + + getSaveErrorMessage(error: string) { + return $localize`Could not save tag: ${error}` } getForm(): FormGroup { diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.html b/src-ui/src/app/components/manage/tag-list/tag-list.component.html index bbe2c6dd2..43126f7b2 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.html +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.html @@ -1,7 +1,5 @@ - - + +
@@ -12,11 +10,11 @@
NameMatchingDocument countActionsNameMatchingDocument countActions
- - - - - + + + + + @@ -31,21 +29,18 @@ diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts index e3f151550..2c70ffef1 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts @@ -1,5 +1,4 @@ import { Component } from '@angular/core'; -import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { FILTER_HAS_TAG } from 'src/app/data/filter-rule-type'; import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag'; @@ -16,7 +15,6 @@ import { TagEditDialogComponent } from './tag-edit-dialog/tag-edit-dialog.compon export class TagListComponent extends GenericListComponent { constructor(tagService: TagService, modalService: NgbModal, - private router: Router, private list: DocumentListViewService ) { super(tagService, modalService, TagEditDialogComponent) @@ -26,14 +24,12 @@ export class TagListComponent extends GenericListComponent { return TAG_COLOURS.find(c => c.id == id) } - getObjectName(object: PaperlessTag) { - return `tag '${object.name}'` + getDeleteMessage(object: PaperlessTag) { + return $localize`Do you really want to delete the tag "${object.name}"?` } filterDocuments(object: PaperlessTag) { - this.list.documentListView.filter_rules = [ - {rule_type: FILTER_HAS_TAG, value: object.id.toString()} - ] - this.router.navigate(["documents"]) + this.list.quickFilter([{rule_type: FILTER_HAS_TAG, value: object.id.toString()}]) + } } diff --git a/src-ui/src/app/components/not-found/not-found.component.html b/src-ui/src/app/components/not-found/not-found.component.html index 6b21cf3ba..4d7e0f7e0 100644 --- a/src-ui/src/app/components/not-found/not-found.component.html +++ b/src-ui/src/app/components/not-found/not-found.component.html @@ -4,5 +4,5 @@ -

404 Not Found

+

404 Not Found

\ No newline at end of file diff --git a/src-ui/src/app/components/search/search.component.html b/src-ui/src/app/components/search/search.component.html index de6f0133f..547c8a475 100644 --- a/src-ui/src/app/components/search/search.component.html +++ b/src-ui/src/app/components/search/search.component.html @@ -1,23 +1,21 @@ - + -
Invalid search query: {{errorMessage}}
+
Invalid search query: {{errorMessage}}
-

- Showing documents similar to - {{more_like_doc?.original_file_name}} +

+ Showing documents similar to {{more_like_doc?.original_file_name}}

- Search string: {{query}} + Search query: {{query}} - - Did you mean "{{correctedQuery}}"? + - Did you mean "{{correctedQuery}}"? -

-

{{resultCount}} result(s)

+

{resultCount, plural, =0 {No results} =1 {One result} other {{{resultCount}} results}}

0) { this.documentService.listAllFilteredIds(this.filterRules).subscribe(ids => { let subset = new Set() @@ -239,7 +249,7 @@ export class DocumentListViewService { } } - constructor(private documentService: DocumentService) { + constructor(private documentService: DocumentService, private settings: SettingsService, private router: Router) { let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) if (documentListViewConfigJson) { try { diff --git a/src-ui/src/app/services/open-documents.service.ts b/src-ui/src/app/services/open-documents.service.ts index c91031f83..9e5746c10 100644 --- a/src-ui/src/app/services/open-documents.service.ts +++ b/src-ui/src/app/services/open-documents.service.ts @@ -28,6 +28,9 @@ export class OpenDocumentsService { if (index > -1) { this.documentService.get(id).subscribe(doc => { this.openDocuments[index] = doc + }, error => { + this.openDocuments.splice(index, 1) + this.save() }) } } diff --git a/src-ui/src/app/services/rest/abstract-paperless-service.ts b/src-ui/src/app/services/rest/abstract-paperless-service.ts index f57956754..8ad1a2141 100644 --- a/src-ui/src/app/services/rest/abstract-paperless-service.ts +++ b/src-ui/src/app/services/rest/abstract-paperless-service.ts @@ -74,27 +74,31 @@ export abstract class AbstractPaperlessService { ) } + clearCache() { + this._listAll = null + } + get(id: number): Observable { return this.http.get(this.getResourceUrl(id)) } create(o: T): Observable { - this._listAll = null + this.clearCache() return this.http.post(this.getResourceUrl(), o) } delete(o: T): Observable { - this._listAll = null + this.clearCache() return this.http.delete(this.getResourceUrl(o.id)) } update(o: T): Observable { - this._listAll = null + this.clearCache() return this.http.put(this.getResourceUrl(o.id), o) } patch(o: T): Observable { - this._listAll = null + this.clearCache() return this.http.patch(this.getResourceUrl(o.id), o) } diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index c132fa856..c42510270 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { PaperlessDocument } from 'src/app/data/paperless-document'; import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata'; import { AbstractPaperlessService } from './abstract-paperless-service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Results } from 'src/app/data/results'; import { FilterRule } from 'src/app/data/filter-rule'; @@ -13,15 +13,26 @@ import { TagService } from './tag.service'; import { FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type'; export const DOCUMENT_SORT_FIELDS = [ - { field: "correspondent__name", name: "Correspondent" }, - { field: "document_type__name", name: "Document type" }, - { field: 'title', name: 'Title' }, - { field: 'archive_serial_number', name: 'ASN' }, - { field: 'created', name: 'Created' }, - { field: 'added', name: 'Added' }, - { field: 'modified', name: 'Modified' } + { field: "correspondent__name", name: $localize`Correspondent` }, + { field: "document_type__name", name: $localize`Document type` }, + { field: 'title', name: $localize`Title` }, + { field: 'archive_serial_number', name: $localize`ASN` }, + { field: 'created', name: $localize`Created` }, + { field: 'added', name: $localize`Added` }, + { field: 'modified', name: $localize`Modified` } ] +export interface SelectionDataItem { + id: number + document_count: number +} + +export interface SelectionData { + selected_correspondents: SelectionDataItem[] + selected_tags: SelectionDataItem[] + selected_document_types: SelectionDataItem[] +} + @Injectable({ providedIn: 'root' }) @@ -38,6 +49,8 @@ export class DocumentService extends AbstractPaperlessService let ruleType = FILTER_RULE_TYPES.find(t => t.id == rule.rule_type) if (ruleType.multi) { params[ruleType.filtervar] = params[ruleType.filtervar] ? params[ruleType.filtervar] + "," + rule.value : rule.value + } else if (ruleType.isnull_filtervar && rule.value == null) { + params[ruleType.isnull_filtervar] = true } else { params[ruleType.filtervar] = rule.value } @@ -112,4 +125,8 @@ export class DocumentService extends AbstractPaperlessService }) } + getSelectionData(ids: number[]): Observable { + return this.http.post(this.getResourceUrl(null, 'selection_data'), {"documents": ids}) + } + } diff --git a/src-ui/src/app/services/settings.service.spec.ts b/src-ui/src/app/services/settings.service.spec.ts new file mode 100644 index 000000000..359cb6b7a --- /dev/null +++ b/src-ui/src/app/services/settings.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SettingsService } from './settings.service'; + +describe('SettingsService', () => { + let service: SettingsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SettingsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src-ui/src/app/services/settings.service.ts b/src-ui/src/app/services/settings.service.ts new file mode 100644 index 000000000..00e6ff639 --- /dev/null +++ b/src-ui/src/app/services/settings.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; + +export interface PaperlessSettings { + key: string + type: string + default: any +} + +export const SETTINGS_KEYS = { + BULK_EDIT_CONFIRMATION_DIALOGS: 'general-settings:bulk-edit:confirmation-dialogs', + BULK_EDIT_APPLY_ON_CLOSE: 'general-settings:bulk-edit:apply-on-close', + DOCUMENT_LIST_SIZE: 'general-settings:documentListSize', +} + +const SETTINGS: PaperlessSettings[] = [ + {key: SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS, type: "boolean", default: true}, + {key: SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE, type: "boolean", default: false}, + {key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE, type: "number", default: 50} +] + +@Injectable({ + providedIn: 'root' +}) +export class SettingsService { + + constructor() { } + + get(key: string): any { + let setting = SETTINGS.find(s => s.key == key) + + if (!setting) { + return null + } + + let value = localStorage.getItem(key) + + if (value != null) { + switch (setting.type) { + case "boolean": + return JSON.parse(value) + case "number": + return +value + case "string": + return value + default: + return value + } + } else { + return setting.default + } + } + + set(key: string, value: any) { + localStorage.setItem(key, value.toString()) + } + + unset(key: string) { + localStorage.removeItem(key) + } +} diff --git a/src-ui/src/app/services/toast.service.ts b/src-ui/src/app/services/toast.service.ts index a3ce060a9..86d66eee6 100644 --- a/src-ui/src/app/services/toast.service.ts +++ b/src-ui/src/app/services/toast.service.ts @@ -1,30 +1,13 @@ import { Injectable } from '@angular/core'; import { Subject, zip } from 'rxjs'; -export class Toast { - - static make(title: string, content: string, classname?: string, delay?: number): Toast { - let t = new Toast() - t.title = title - t.content = content - t.classname = classname - if (delay) { - t.delay = delay - } - return t - } - - static makeError(content: string) { - return Toast.make("Error", content, null, 10000) - } +export interface Toast { title: string - classname: string - content: string - delay: number = 5000 + delay: number } @@ -39,11 +22,19 @@ export class ToastService { private toastsSubject: Subject = new Subject() - showToast(toast: Toast) { + show(toast: Toast) { this.toasts.push(toast) this.toastsSubject.next(this.toasts) } + showError(content: string, delay: number = 10000) { + this.show({title: $localize`Error`, content: content, delay: delay}) + } + + showInfo(content: string, delay: number = 5000) { + this.show({title: $localize`Information`, content: content, delay: delay}) + } + closeToast(toast: Toast) { let index = this.toasts.findIndex(t => t == toast) if (index > -1) { diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index ab6b07c73..7b707f014 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -2,5 +2,5 @@ export const environment = { production: true, apiBaseUrl: "/api/", appTitle: "Paperless-ng", - version: "0.9.9" + version: "0.9.10" }; diff --git a/src-ui/src/index.html b/src-ui/src/index.html index f82399ce6..08ab3d931 100644 --- a/src-ui/src/index.html +++ b/src-ui/src/index.html @@ -5,6 +5,8 @@ Paperless-ng + + diff --git a/src-ui/src/manifest.webmanifest b/src-ui/src/manifest.webmanifest new file mode 100644 index 000000000..60151bb5c --- /dev/null +++ b/src-ui/src/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "background_color": "white", + "description": "A supercharged version of paperless: scan, index and archive all your physical documents", + "display": "fullscreen", + "icons": [ + { + "src": "favicon.ico", + "sizes": "128x128" + } + ], + "name": "Paperless NG", + "short_name": "Paperless NG", + "start_url": "/" +} diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss index 6e09db630..7e9a9377a 100644 --- a/src-ui/src/styles.scss +++ b/src-ui/src/styles.scss @@ -100,3 +100,13 @@ body { padding-top: 1px; } } + +@supports (-webkit-touch-callout: none) { + input[type="number"], + input[type="search"], + input[type="text"], + select:focus, + textarea { + font-size: 16px; + } +} diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index aa5b8ea3f..c0c80a795 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -1,6 +1,10 @@ +import itertools + from django.db.models import Q from django_q.tasks import async_task +from whoosh.writing import AsyncWriter +from documents import index from documents.models import Document, Correspondent, DocumentType @@ -13,7 +17,8 @@ def set_correspondent(doc_ids, correspondent): affected_docs = [doc.id for doc in qs] qs.update(correspondent=correspondent) - async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs) + async_task( + "documents.tasks.bulk_update_documents", document_ids=affected_docs) return "OK" @@ -27,7 +32,8 @@ def set_document_type(doc_ids, document_type): affected_docs = [doc.id for doc in qs] qs.update(document_type=document_type) - async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs) + async_task( + "documents.tasks.bulk_update_documents", document_ids=affected_docs) return "OK" @@ -44,7 +50,8 @@ def add_tag(doc_ids, tag): document_id=doc, tag_id=tag) for doc in affected_docs ]) - async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs) + async_task( + "documents.tasks.bulk_update_documents", document_ids=affected_docs) return "OK" @@ -61,7 +68,30 @@ def remove_tag(doc_ids, tag): Q(tag_id=tag) ).delete() - async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs) + async_task( + "documents.tasks.bulk_update_documents", document_ids=affected_docs) + + return "OK" + + +def modify_tags(doc_ids, add_tags, remove_tags): + qs = Document.objects.filter(id__in=doc_ids) + affected_docs = [doc.id for doc in qs] + + DocumentTagRelationship = Document.tags.through + + DocumentTagRelationship.objects.filter( + document_id__in=affected_docs, + tag_id__in=remove_tags, + ).delete() + + DocumentTagRelationship.objects.bulk_create([DocumentTagRelationship( + document_id=doc, tag_id=tag) for (doc, tag) in itertools.product( + affected_docs, add_tags) + ], ignore_conflicts=True) + + async_task( + "documents.tasks.bulk_update_documents", document_ids=affected_docs) return "OK" @@ -69,4 +99,9 @@ def remove_tag(doc_ids, tag): def delete(doc_ids): Document.objects.filter(id__in=doc_ids).delete() + ix = index.open_index() + with AsyncWriter(ix) as writer: + for id in doc_ids: + index.remove_document_by_id(writer, id) + return "OK" diff --git a/src/documents/consumer.py b/src/documents/consumer.py index ab4912a36..5a06194b7 100755 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -95,19 +95,21 @@ def try_consume_file(self, self.pre_check_directories() self.pre_check_duplicate() - self.log("info", "Consuming {}".format(self.filename)) + self.log("info", f"Consuming {self.filename}") # Determine the parser class. mime_type = magic.from_file(self.path, mime=True) + self.log("debug", f"Detected mime type: {mime_type}") + parser_class = get_parser_class_for_mime_type(mime_type) if not parser_class: - raise ConsumerError(f"No parsers abvailable for {self.filename}") + raise ConsumerError( + f"Unsupported mime type {mime_type} of file {self.filename}") else: self.log("debug", - f"Parser: {parser_class.__name__} " - f"based on mime type {mime_type}") + f"Parser: {parser_class.__name__}") # Notify all listeners that we're going to do some work. @@ -156,7 +158,7 @@ def try_consume_file(self, try: classifier = DocumentClassifier() classifier.reload() - except (FileNotFoundError, IncompatibleClassifierVersionError) as e: + except (OSError, EOFError, IncompatibleClassifierVersionError) as e: self.log( "warning", f"Cannot classify documents: {e}.") diff --git a/src/documents/filters.py b/src/documents/filters.py index b3c92eba3..4614d292b 100755 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -98,12 +98,14 @@ class Meta: "added": DATE_KWARGS, "modified": DATE_KWARGS, + "correspondent": ["isnull"], "correspondent__id": ID_KWARGS, "correspondent__name": CHAR_KWARGS, "tags__id": ID_KWARGS, "tags__name": CHAR_KWARGS, + "document_type": ["isnull"], "document_type__id": ID_KWARGS, "document_type__name": CHAR_KWARGS, diff --git a/src/documents/index.py b/src/documents/index.py index 308ee932e..51197e252 100644 --- a/src/documents/index.py +++ b/src/documents/index.py @@ -87,11 +87,6 @@ def open_index(recreate=False): def update_document(writer, doc): - # TODO: this line caused many issues all around, since: - # We need to make sure that this method does not get called with - # deserialized documents (i.e, document objects that don't come from - # Django's ORM interfaces directly. - logger.debug("Indexing {}...".format(doc)) tags = ",".join([t.name for t in doc.tags.all()]) writer.update_document( id=doc.pk, @@ -107,9 +102,11 @@ def update_document(writer, doc): def remove_document(writer, doc): - # TODO: see above. - logger.debug("Removing {} from index...".format(doc)) - writer.delete_by_term('id', doc.pk) + remove_document_by_id(writer, doc.pk) + + +def remove_document_by_id(writer, doc_id): + writer.delete_by_term('id', doc_id) def add_or_update_document(document): diff --git a/src/documents/management/commands/document_retagger.py b/src/documents/management/commands/document_retagger.py index cf014dc6f..0fb9782c1 100755 --- a/src/documents/management/commands/document_retagger.py +++ b/src/documents/management/commands/document_retagger.py @@ -73,7 +73,7 @@ def handle(self, *args, **options): classifier = DocumentClassifier() try: classifier.reload() - except (FileNotFoundError, IncompatibleClassifierVersionError) as e: + except (OSError, EOFError, IncompatibleClassifierVersionError) as e: logging.getLogger(__name__).warning( f"Cannot classify documents: {e}.") classifier = None diff --git a/src/documents/management/commands/document_thumbnails.py b/src/documents/management/commands/document_thumbnails.py new file mode 100644 index 000000000..01df14624 --- /dev/null +++ b/src/documents/management/commands/document_thumbnails.py @@ -0,0 +1,68 @@ +import logging +import multiprocessing +import shutil + +import tqdm +from django import db +from django.core.management.base import BaseCommand + +from documents.models import Document +from ...mixins import Renderable +from ...parsers import get_parser_class_for_mime_type + + +def _process_document(doc_in): + document = Document.objects.get(id=doc_in) + parser = get_parser_class_for_mime_type(document.mime_type)( + logging_group=None) + try: + thumb = parser.get_optimised_thumbnail( + document.source_path, document.mime_type) + + shutil.move(thumb, document.thumbnail_path) + finally: + parser.cleanup() + + +class Command(Renderable, BaseCommand): + + help = """ + This will regenerate the thumbnails for all documents. + """.replace(" ", "") + + def __init__(self, *args, **kwargs): + self.verbosity = 0 + BaseCommand.__init__(self, *args, **kwargs) + + def add_arguments(self, parser): + parser.add_argument( + "-d", "--document", + default=None, + type=int, + required=False, + help="Specify the ID of a document, and this command will only " + "run on this specific document." + ) + + def handle(self, *args, **options): + + self.verbosity = options["verbosity"] + + logging.getLogger().handlers[0].level = logging.ERROR + + if options['document']: + documents = Document.objects.filter(pk=options['document']) + else: + documents = Document.objects.all() + + ids = [doc.id for doc in documents] + + # Note to future self: this prevents django from reusing database + # conncetions between processes, which is bad and does not work + # with postgres. + db.connections.close_all() + + with multiprocessing.Pool() as pool: + list(tqdm.tqdm( + pool.imap_unordered(_process_document, ids), total=len(ids) + )) diff --git a/src/documents/parsers.py b/src/documents/parsers.py index cbbb912de..725e605a2 100644 --- a/src/documents/parsers.py +++ b/src/documents/parsers.py @@ -117,6 +117,7 @@ def run_convert(input_file, trim=False, type=None, depth=None, + auto_orient=False, extra=None, logging_group=None): @@ -134,6 +135,7 @@ def run_convert(input_file, args += ['-trim'] if trim else [] args += ['-type', str(type)] if type else [] args += ['-depth', str(depth)] if depth else [] + args += ['-auto-orient'] if auto_orient else [] args += [input_file, output_file] logger.debug("Execute: " + " ".join(args), extra={'group': logging_group}) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index f9fde9ac8..66f5f883f 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -217,6 +217,7 @@ class BulkEditSerializer(serializers.Serializer): "set_document_type", "add_tag", "remove_tag", + "modify_tags", "delete" ], label="Method", @@ -225,11 +226,31 @@ class BulkEditSerializer(serializers.Serializer): parameters = serializers.DictField(allow_empty=True) - def validate_documents(self, documents): + def _validate_document_id_list(self, documents, name="documents"): + if not type(documents) == list: + raise serializers.ValidationError(f"{name} must be a list") + if not all([type(i) == int for i in documents]): + raise serializers.ValidationError( + f"{name} must be a list of integers") count = Document.objects.filter(id__in=documents).count() if not count == len(documents): raise serializers.ValidationError( - "Some documents don't exist or were specified twice.") + f"Some documents in {name} don't exist or were " + f"specified twice.") + + def _validate_tag_id_list(self, tags, name="tags"): + if not type(tags) == list: + raise serializers.ValidationError(f"{name} must be a list") + if not all([type(i) == int for i in tags]): + raise serializers.ValidationError( + f"{name} must be a list of integers") + count = Tag.objects.filter(id__in=tags).count() + if not count == len(tags): + raise serializers.ValidationError( + f"Some tags in {name} don't exist or were specified twice.") + + def validate_documents(self, documents): + self._validate_document_id_list(documents) return documents def validate_method(self, method): @@ -241,13 +262,76 @@ def validate_method(self, method): return bulk_edit.add_tag elif method == "remove_tag": return bulk_edit.remove_tag + elif method == "modify_tags": + return bulk_edit.modify_tags elif method == "delete": return bulk_edit.delete else: raise serializers.ValidationError("Unsupported method.") + def _validate_parameters_tags(self, parameters): + if 'tag' in parameters: + tag_id = parameters['tag'] + try: + Tag.objects.get(id=tag_id) + except Tag.DoesNotExist: + raise serializers.ValidationError("Tag does not exist") + else: + raise serializers.ValidationError("tag not specified") + + def _validate_parameters_document_type(self, parameters): + if 'document_type' in parameters: + document_type_id = parameters['document_type'] + if document_type_id is None: + # None is ok + return + try: + DocumentType.objects.get(id=document_type_id) + except DocumentType.DoesNotExist: + raise serializers.ValidationError( + "Document type does not exist") + else: + raise serializers.ValidationError("document_type not specified") + + def _validate_parameters_correspondent(self, parameters): + if 'correspondent' in parameters: + correspondent_id = parameters['correspondent'] + if correspondent_id is None: + return + try: + Correspondent.objects.get(id=correspondent_id) + except Correspondent.DoesNotExist: + raise serializers.ValidationError( + "Correspondent does not exist") + else: + raise serializers.ValidationError("correspondent not specified") + + def _validate_parameters_modify_tags(self, parameters): + if "add_tags" in parameters: + self._validate_tag_id_list(parameters['add_tags'], "add_tags") + else: + raise serializers.ValidationError("add_tags not specified") + + if "remove_tags" in parameters: + self._validate_tag_id_list(parameters['remove_tags'], + "remove_tags") + else: + raise serializers.ValidationError("remove_tags not specified") + def validate(self, attrs): + method = attrs['method'] + parameters = attrs['parameters'] + + if method == bulk_edit.set_correspondent: + self._validate_parameters_correspondent(parameters) + elif method == bulk_edit.set_document_type: + self._validate_parameters_document_type(parameters) + elif method == bulk_edit.add_tag or method == bulk_edit.remove_tag: + self._validate_parameters_tags(parameters) + elif method == bulk_edit.modify_tags: + self._validate_parameters_modify_tags(parameters) + return attrs @@ -322,3 +406,11 @@ def validate_tags(self, tags): return [tag.id for tag in tags] else: return None + + +class SelectionDataSerializer(serializers.Serializer): + + documents = serializers.ListField( + required=True, + child=serializers.IntegerField() + ) diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 586897585..f2743c212 100755 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -276,13 +276,6 @@ def update_filename_and_move_files(sender, instance, **kwargs): Document.objects.filter(pk=instance.pk).update( filename=new_filename) - logging.getLogger(__name__).debug( - f"Moved file {old_source_path} to {new_source_path}.") - - if instance.archive_checksum: - logging.getLogger(__name__).debug( - f"Moved file {old_archive_path} to {new_archive_path}.") - except OSError as e: instance.filename = old_filename # this happens when we can't move a file. If that's the case for diff --git a/src/documents/tasks.py b/src/documents/tasks.py index fafe6e10f..38ff532b5 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -35,9 +35,9 @@ def train_classifier(): try: # load the classifier, since we might not have to train it again. classifier.reload() - except (FileNotFoundError, IncompatibleClassifierVersionError): + except (OSError, EOFError, IncompatibleClassifierVersionError): # This is what we're going to fix here. - pass + classifier = DocumentClassifier() try: if classifier.train(): @@ -90,7 +90,14 @@ def sanity_check(): return "No issues detected." -def bulk_rename_files(document_ids): - qs = Document.objects.filter(id__in=document_ids) - for doc in qs: +def bulk_update_documents(document_ids): + documents = Document.objects.filter(id__in=document_ids) + + ix = index.open_index() + + for doc in documents: post_save.send(Document, instance=doc, created=False) + + with AsyncWriter(ix) as writer: + for doc in documents: + index.update_document(writer, doc) diff --git a/src/documents/templates/index.html b/src/documents/templates/index.html index 47a352cd5..83544b5e4 100644 --- a/src/documents/templates/index.html +++ b/src/documents/templates/index.html @@ -12,7 +12,9 @@ - + + + Loading... diff --git a/src/documents/tests/samples/test_with_bom.pdf b/src/documents/tests/samples/test_with_bom.pdf new file mode 100644 index 000000000..c4a46701c Binary files /dev/null and b/src/documents/tests/samples/test_with_bom.pdf differ diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index bce2a433d..dbe8f881c 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -743,6 +743,20 @@ def test_remove_tag(self): args, kwargs = self.async_task.call_args self.assertCountEqual(kwargs['document_ids'], [self.doc4.id]) + def test_modify_tags(self): + tag_unrelated = Tag.objects.create(name="unrelated") + self.doc2.tags.add(tag_unrelated) + self.doc3.tags.add(tag_unrelated) + bulk_edit.modify_tags([self.doc2.id, self.doc3.id], add_tags=[self.t2.id], remove_tags=[self.t1.id]) + + self.assertCountEqual(list(self.doc2.tags.all()), [self.t2, tag_unrelated]) + self.assertCountEqual(list(self.doc3.tags.all()), [self.t2, tag_unrelated]) + + self.async_task.assert_called_once() + args, kwargs = self.async_task.call_args + # TODO: doc3 should not be affected, but the query for that is rather complicated + self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id]) + def test_delete(self): self.assertEqual(Document.objects.count(), 5) bulk_edit.delete([self.doc1.id, self.doc2.id]) @@ -833,6 +847,21 @@ def test_api_remove_tag(self, m): self.assertEqual(args[0], [self.doc1.id]) self.assertEqual(kwargs['tag'], self.t1.id) + @mock.patch("documents.serialisers.bulk_edit.modify_tags") + def test_api_modify_tags(self, m): + m.return_value = "OK" + response = self.client.post("/api/documents/bulk_edit/", json.dumps({ + "documents": [self.doc1.id, self.doc3.id], + "method": "modify_tags", + "parameters": {"add_tags": [self.t1.id], "remove_tags": [self.t2.id]} + }), content_type='application/json') + self.assertEqual(response.status_code, 200) + m.assert_called_once() + args, kwargs = m.call_args + self.assertListEqual(args[0], [self.doc1.id, self.doc3.id]) + self.assertEqual(kwargs['add_tags'], [self.t1.id]) + self.assertEqual(kwargs['remove_tags'], [self.t2.id]) + @mock.patch("documents.serialisers.bulk_edit.delete") def test_api_delete(self, m): m.return_value = "OK" @@ -891,13 +920,82 @@ def test_api_invalid_document_type(self): doc2 = Document.objects.get(id=self.doc2.id) self.assertEqual(doc2.document_type, self.dt1) - def test_api_invalid_tag(self): + def test_api_add_invalid_tag(self): self.assertEqual(list(self.doc2.tags.all()), [self.t1]) response = self.client.post("/api/documents/bulk_edit/", json.dumps({ "documents": [self.doc2.id], "method": "add_tag", - "parameters": {'document_type': 345657} + "parameters": {'tag': 345657} + }), content_type='application/json') + self.assertEqual(response.status_code, 400) + + self.assertEqual(list(self.doc2.tags.all()), [self.t1]) + + def test_api_delete_invalid_tag(self): + self.assertEqual(list(self.doc2.tags.all()), [self.t1]) + response = self.client.post("/api/documents/bulk_edit/", json.dumps({ + "documents": [self.doc2.id], + "method": "remove_tag", + "parameters": {'tag': 345657} }), content_type='application/json') self.assertEqual(response.status_code, 400) self.assertEqual(list(self.doc2.tags.all()), [self.t1]) + + def test_api_modify_invalid_tags(self): + self.assertEqual(list(self.doc2.tags.all()), [self.t1]) + response = self.client.post("/api/documents/bulk_edit/", json.dumps({ + "documents": [self.doc2.id], + "method": "modify_tags", + "parameters": {'add_tags': [self.t2.id, 1657], "remove_tags": [1123123]} + }), content_type='application/json') + self.assertEqual(response.status_code, 400) + + def test_api_selection_data_empty(self): + response = self.client.post("/api/documents/selection_data/", json.dumps({ + "documents": [] + }), content_type='application/json') + self.assertEqual(response.status_code, 200) + for field, Entity in [('selected_correspondents', Correspondent), ('selected_tags', Tag), ('selected_document_types', DocumentType)]: + self.assertEqual(len(response.data[field]), Entity.objects.count()) + for correspondent in response.data[field]: + self.assertEqual(correspondent['document_count'], 0) + self.assertCountEqual( + map(lambda c: c['id'], response.data[field]), + map(lambda c: c['id'], Entity.objects.values('id'))) + + def test_api_selection_data(self): + response = self.client.post("/api/documents/selection_data/", json.dumps({ + "documents": [self.doc1.id, self.doc2.id, self.doc4.id, self.doc5.id] + }), content_type='application/json') + self.assertEqual(response.status_code, 200) + + self.assertCountEqual(response.data['selected_correspondents'], [{"id": self.c1.id, "document_count": 1}, {"id": self.c2.id, "document_count": 0}]) + self.assertCountEqual(response.data['selected_tags'], [{"id": self.t1.id, "document_count": 2}, {"id": self.t2.id, "document_count": 1}]) + self.assertCountEqual(response.data['selected_document_types'], [{"id": self.c1.id, "document_count": 1}, {"id": self.c2.id, "document_count": 0}]) + + +class TestApiAuth(APITestCase): + + def test_auth_required(self): + + d = Document.objects.create(title="Test") + + self.assertEqual(self.client.get("/api/documents/").status_code, 401) + + self.assertEqual(self.client.get(f"/api/documents/{d.id}/").status_code, 401) + self.assertEqual(self.client.get(f"/api/documents/{d.id}/download/").status_code, 401) + self.assertEqual(self.client.get(f"/api/documents/{d.id}/preview/").status_code, 401) + self.assertEqual(self.client.get(f"/api/documents/{d.id}/thumb/").status_code, 401) + + self.assertEqual(self.client.get("/api/tags/").status_code, 401) + self.assertEqual(self.client.get("/api/correspondents/").status_code, 401) + self.assertEqual(self.client.get("/api/document_types/").status_code, 401) + + self.assertEqual(self.client.get("/api/logs/").status_code, 401) + self.assertEqual(self.client.get("/api/saved_views/").status_code, 401) + + self.assertEqual(self.client.get("/api/search/").status_code, 401) + self.assertEqual(self.client.get("/api/search/auto_complete/").status_code, 401) + self.assertEqual(self.client.get("/api/documents/bulk_edit/").status_code, 401) + self.assertEqual(self.client.get("/api/documents/selection_data/").status_code, 401) diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index f53981850..795ca7f95 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -350,7 +350,7 @@ def testNoParsers(self, m): try: self.consumer.try_consume_file(self.get_test_file()) except ConsumerError as e: - self.assertTrue("No parsers abvailable for" in str(e)) + self.assertEqual("Unsupported mime type application/pdf of file sample.pdf", str(e)) return self.fail("Should throw exception") diff --git a/src/documents/tests/test_tasks.py b/src/documents/tests/test_tasks.py index 6d04e58e1..653590707 100644 --- a/src/documents/tests/test_tasks.py +++ b/src/documents/tests/test_tasks.py @@ -1,10 +1,12 @@ from datetime import datetime +from unittest import mock from django.test import TestCase from django.utils import timezone from documents import tasks from documents.models import Document +from documents.sanity_checker import SanityError, SanityFailedError from documents.tests.utils import DirectoriesMixin @@ -22,3 +24,19 @@ def test_index_optimize(self): def test_train_classifier(self): tasks.train_classifier() + + @mock.patch("documents.tasks.sanity_checker.check_sanity") + def test_sanity_check(self, m): + m.return_value = [] + tasks.sanity_check() + m.assert_called_once() + m.reset_mock() + m.return_value = [SanityError("")] + self.assertRaises(SanityFailedError, tasks.sanity_check) + m.assert_called_once() + + def test_culk_update_documents(self): + doc1 = Document.objects.create(title="test", content="my document", checksum="wow", added=timezone.now(), + created=timezone.now(), modified=timezone.now()) + + tasks.bulk_update_documents([doc1.pk]) diff --git a/src/documents/views.py b/src/documents/views.py index 8f6ec7f13..32b88a18f 100755 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -4,7 +4,8 @@ from time import mktime from django.conf import settings -from django.db.models import Count, Max +from django.db.models import Count, Max, Case, When, IntegerField +from django.db.models.functions import Lower from django.http import HttpResponse, HttpResponseBadRequest, Http404 from django.views.decorators.cache import cache_control from django.views.generic import TemplateView @@ -48,7 +49,7 @@ DocumentTypeSerializer, PostDocumentSerializer, SavedViewSerializer, - BulkEditSerializer + BulkEditSerializer, SelectionDataSerializer ) @@ -68,7 +69,7 @@ class CorrespondentViewSet(ModelViewSet): queryset = Correspondent.objects.annotate( document_count=Count('documents'), - last_correspondence=Max('documents__created')).order_by('name') + last_correspondence=Max('documents__created')).order_by(Lower('name')) serializer_class = CorrespondentSerializer pagination_class = StandardPagination @@ -87,7 +88,7 @@ class TagViewSet(ModelViewSet): model = Tag queryset = Tag.objects.annotate( - document_count=Count('documents')).order_by('name') + document_count=Count('documents')).order_by(Lower('name')) serializer_class = TagSerializer pagination_class = StandardPagination @@ -101,7 +102,7 @@ class DocumentTypeViewSet(ModelViewSet): model = DocumentType queryset = DocumentType.objects.annotate( - document_count=Count('documents')).order_by('name') + document_count=Count('documents')).order_by(Lower('name')) serializer_class = DocumentTypeSerializer pagination_class = StandardPagination @@ -372,6 +373,63 @@ def post(self, request, *args, **kwargs): return Response("OK") +class SelectionDataView(APIView): + + permission_classes = (IsAuthenticated,) + serializer_class = SelectionDataSerializer + parser_classes = (parsers.MultiPartParser, parsers.JSONParser) + + def get_serializer_context(self): + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self + } + + def get_serializer(self, *args, **kwargs): + kwargs['context'] = self.get_serializer_context() + return self.serializer_class(*args, **kwargs) + + def post(self, request, format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + ids = serializer.validated_data.get('documents') + + correspondents = Correspondent.objects.annotate( + document_count=Count(Case( + When(documents__id__in=ids, then=1), + output_field=IntegerField() + ))) + + tags = Tag.objects.annotate(document_count=Count(Case( + When(documents__id__in=ids, then=1), + output_field=IntegerField() + ))) + + types = DocumentType.objects.annotate(document_count=Count(Case( + When(documents__id__in=ids, then=1), + output_field=IntegerField() + ))) + + r = Response({ + "selected_correspondents": [{ + "id": t.id, + "document_count": t.document_count + } for t in correspondents], + "selected_tags": [{ + "id": t.id, + "document_count": t.document_count + } for t in tags], + "selected_document_types": [{ + "id": t.id, + "document_count": t.document_count + } for t in types] + }) + + return r + + class SearchView(APIView): permission_classes = (IsAuthenticated,) diff --git a/src/paperless/settings.py b/src/paperless/settings.py index c6f7c9357..5af1be85e 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -69,6 +69,8 @@ def __get_boolean(key, default="NO"): # Application Definition # ############################################################################### +env_apps = os.getenv("PAPERLESS_APPS").split(",") if os.getenv("PAPERLESS_APPS") else [] + INSTALLED_APPS = [ "whitenoise.runserver_nostatic", @@ -95,7 +97,7 @@ def __get_boolean(key, default="NO"): "django_q", -] +] + env_apps REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ @@ -420,3 +422,5 @@ def default_threads_per_worker(): # TODO: this should not have a prefix. # Specify the filename format for out files PAPERLESS_FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT") + +THUMBNAIL_FONT_NAME = os.getenv("PAPERLESS_THUMBNAIL_FONT_NAME", "/usr/share/fonts/liberation/LiberationSerif-Regular.ttf") diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 831eb02b4..39e99a7a4 100755 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -19,7 +19,8 @@ StatisticsView, PostDocumentView, SavedViewViewSet, - BulkEditView + BulkEditView, + SelectionDataView ) from paperless.views import FaviconView @@ -53,10 +54,12 @@ re_path(r"^documents/post_document/", PostDocumentView.as_view(), name="post_document"), - re_path(r"^documents/bulk_edit/", BulkEditView.as_view(), name="bulk_edit"), + re_path(r"^documents/selection_data/", SelectionDataView.as_view(), + name="selection_data"), + path('token/', views.obtain_auth_token) ] + api_router.urls)), diff --git a/src/paperless/version.py b/src/paperless/version.py index b1dfc590c..facb097fc 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1 +1 @@ -__version__ = (0, 9, 9) +__version__ = (0, 9, 10) diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index 3c200362d..537807400 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -3,9 +3,9 @@ from datetime import timedelta, date import magic +import pathvalidate from django.conf import settings from django.db import DatabaseError -from django.utils.text import slugify from django_q.tasks import async_task from imap_tools import MailBox, MailBoxUnencrypted, AND, MailMessageFlags, \ MailboxFolderSelectError @@ -294,7 +294,7 @@ def handle_message(self, message, rule): async_task( "documents.tasks.consume_file", path=temp_filename, - override_filename=att.filename, + override_filename=pathvalidate.sanitize_filename(att.filename), # NOQA: E501 override_title=title, override_correspondent_id=correspondent.id if correspondent else None, # NOQA: E501 override_document_type_id=doc_type.id if doc_type else None, # NOQA: E501 diff --git a/src/paperless_tesseract/parsers.py b/src/paperless_tesseract/parsers.py index 80e200f27..4da5af2c0 100644 --- a/src/paperless_tesseract/parsers.py +++ b/src/paperless_tesseract/parsers.py @@ -60,6 +60,7 @@ def get_thumbnail(self, document_path, mime_type): alpha="remove", strip=True, trim=False, + auto_orient=True, input_file="{}[0]".format(document_path), output_file=out_path, logging_group=self.logging_group) @@ -84,6 +85,7 @@ def get_thumbnail(self, document_path, mime_type): alpha="remove", strip=True, trim=False, + auto_orient=True, input_file=gs_out_path, output_file=out_path, logging_group=self.logging_group) diff --git a/src/paperless_text/parsers.py b/src/paperless_text/parsers.py index 030c2c2c2..a38bd7a91 100644 --- a/src/paperless_text/parsers.py +++ b/src/paperless_text/parsers.py @@ -1,10 +1,9 @@ import os -import subprocess from PIL import ImageDraw, ImageFont, Image from django.conf import settings -from documents.parsers import DocumentParser, ParseError +from documents.parsers import DocumentParser class TextDocumentParser(DocumentParser): @@ -23,7 +22,8 @@ def read_text(): img = Image.new("RGB", (500, 700), color="white") draw = ImageDraw.Draw(img) font = ImageFont.truetype( - "/usr/share/fonts/liberation/LiberationSerif-Regular.ttf", 20, + font=settings.THUMBNAIL_FONT_NAME, + size=20, layout_engine=ImageFont.LAYOUT_BASIC) draw.text((5, 5), read_text(), font=font, fill="black")
NameColourMatchingDocument countActionsNameColorMatchingDocument countActions