From 2bbeb8ffe0d212eab0542e218fb6358bb2579be1 Mon Sep 17 00:00:00 2001
From: Jonas Winkler <dev@jpwinkler.de>
Date: Sun, 6 Dec 2020 23:30:51 +0100
Subject: [PATCH 01/37] Update CONTRIBUTING.md

---
 CONTRIBUTING.md | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bd6080d35..a8fb1f8e9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,3 +24,7 @@ feature-X branches is for experimental stuff that will eventually be merged into
 I'm trying to get most of paperless tested, so please do the same for your code! I know its a hassle, but it makes sure that your code works now and will allow us to detect regressions easily.
 
 To test your code, execute `pytest` in the src/ directory. Executing that in the project root is no good. This also generates a html coverage report, which you can use to see if you missed anything important during testing.
+
+## More info:
+
+... is available in the documentation. https://paperless-ng.readthedocs.io/en/latest/extending.html

From 5753c83618a66c68b254499177cfac9354b7c517 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Tue, 8 Dec 2020 21:20:05 +0100
Subject: [PATCH 02/37] version bump

---
 docker/hub/docker-compose.postgres.yml | 2 +-
 docker/hub/docker-compose.sqlite.yml   | 2 +-
 src/paperless/version.py               | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/docker/hub/docker-compose.postgres.yml b/docker/hub/docker-compose.postgres.yml
index 295d981e1..24f0e118f 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.5
+    image: jonaswinkler/paperless-ng:0.9.6
     restart: always
     depends_on:
       - db
diff --git a/docker/hub/docker-compose.sqlite.yml b/docker/hub/docker-compose.sqlite.yml
index 80df40596..6ae619fd6 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.5
+    image: jonaswinkler/paperless-ng:0.9.6
     restart: always
     depends_on:
       - broker
diff --git a/src/paperless/version.py b/src/paperless/version.py
index 26e46fea8..527e0668d 100644
--- a/src/paperless/version.py
+++ b/src/paperless/version.py
@@ -1 +1 @@
-__version__ = (0, 9, 5)
+__version__ = (0, 9, 6)

From 74a99cf33084a0688930f912cd5b2fedb938d527 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 00:04:37 +0100
Subject: [PATCH 03/37] removed slugs entirely, since their only purpose was
 purely cosmetic anyway.

---
 src/documents/admin.py                        |  8 +-----
 src/documents/consumer.py                     |  2 +-
 .../management/commands/document_consumer.py  |  5 +---
 .../migrations/1006_auto_20201208_2209.py     | 25 +++++++++++++++++++
 src/documents/models.py                       | 11 ++------
 src/documents/serialisers.py                  | 19 +++++++++++---
 src/documents/signals/handlers.py             |  4 +--
 src/documents/tests/test_consumer.py          | 12 ++++-----
 src/paperless_mail/mail.py                    |  5 +---
 9 files changed, 55 insertions(+), 36 deletions(-)
 create mode 100644 src/documents/migrations/1006_auto_20201208_2209.py

diff --git a/src/documents/admin.py b/src/documents/admin.py
index 2a4fb0031..055a6fd93 100755
--- a/src/documents/admin.py
+++ b/src/documents/admin.py
@@ -17,8 +17,6 @@ class CorrespondentAdmin(admin.ModelAdmin):
     list_filter = ("matching_algorithm",)
     list_editable = ("match", "matching_algorithm")
 
-    readonly_fields = ("slug",)
-
 
 class TagAdmin(admin.ModelAdmin):
 
@@ -31,8 +29,6 @@ class TagAdmin(admin.ModelAdmin):
     list_filter = ("colour", "matching_algorithm")
     list_editable = ("colour", "match", "matching_algorithm")
 
-    readonly_fields = ("slug", )
-
 
 class DocumentTypeAdmin(admin.ModelAdmin):
 
@@ -44,8 +40,6 @@ class DocumentTypeAdmin(admin.ModelAdmin):
     list_filter = ("matching_algorithm",)
     list_editable = ("match", "matching_algorithm")
 
-    readonly_fields = ("slug",)
-
 
 class DocumentAdmin(admin.ModelAdmin):
 
@@ -106,7 +100,7 @@ class DocumentAdmin(admin.ModelAdmin):
         for tag in obj.tags.all():
             r += self._html_tag(
                 "span",
-                tag.slug + ", "
+                tag.name + ", "
             )
         return r
 
diff --git a/src/documents/consumer.py b/src/documents/consumer.py
index f52dd5a7d..19ca3ed7e 100755
--- a/src/documents/consumer.py
+++ b/src/documents/consumer.py
@@ -259,7 +259,7 @@ class Consumer(LoggingMixin):
 
         relevant_tags = set(file_info.tags)
         if relevant_tags:
-            tag_names = ", ".join([t.slug for t in relevant_tags])
+            tag_names = ", ".join([t.name for t in relevant_tags])
             self.log("debug", "Tagging with {}".format(tag_names))
             document.tags.add(*relevant_tags)
 
diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py
index 5cecd6bf9..b2f689aed 100644
--- a/src/documents/management/commands/document_consumer.py
+++ b/src/documents/management/commands/document_consumer.py
@@ -29,10 +29,7 @@ def _tags_from_path(filepath):
     path_parts = Path(filepath).relative_to(
                 settings.CONSUMPTION_DIR).parent.parts
     for part in path_parts:
-        tag_ids.add(Tag.objects.get_or_create(
-            slug=slugify(part),
-            defaults={"name": part},
-        )[0].pk)
+        tag_ids.add(Tag.objects.get_or_create(name=part)[0].pk)
 
     return tag_ids
 
diff --git a/src/documents/migrations/1006_auto_20201208_2209.py b/src/documents/migrations/1006_auto_20201208_2209.py
new file mode 100644
index 000000000..49f8c8dfe
--- /dev/null
+++ b/src/documents/migrations/1006_auto_20201208_2209.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1.4 on 2020-12-08 22:09
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('documents', '1005_checksums'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='correspondent',
+            name='slug',
+        ),
+        migrations.RemoveField(
+            model_name='documenttype',
+            name='slug',
+        ),
+        migrations.RemoveField(
+            model_name='tag',
+            name='slug',
+        ),
+    ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 366cb215d..f0678a843 100755
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -36,7 +36,6 @@ class MatchingModel(models.Model):
     )
 
     name = models.CharField(max_length=128, unique=True)
-    slug = models.SlugField(blank=True, editable=False)
 
     match = models.CharField(max_length=256, blank=True)
     matching_algorithm = models.PositiveIntegerField(
@@ -69,7 +68,6 @@ class MatchingModel(models.Model):
     def save(self, *args, **kwargs):
 
         self.match = self.match.lower()
-        self.slug = slugify(self.name)
 
         models.Model.save(self, *args, **kwargs)
 
@@ -384,9 +382,7 @@ class FileInfo:
     def _get_correspondent(cls, name):
         if not name:
             return None
-        return Correspondent.objects.get_or_create(name=name, defaults={
-            "slug": slugify(name)
-        })[0]
+        return Correspondent.objects.get_or_create(name=name)[0]
 
     @classmethod
     def _get_title(cls, title):
@@ -396,10 +392,7 @@ class FileInfo:
     def _get_tags(cls, tags):
         r = []
         for t in tags.split(","):
-            r.append(Tag.objects.get_or_create(
-                slug=slugify(t),
-                defaults={"name": t}
-            )[0])
+            r.append(Tag.objects.get_or_create(name=t)[0])
         return tuple(r)
 
     @classmethod
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 5aedeeb58..600645061 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1,4 +1,5 @@
 import magic
+from django.utils.text import slugify
 from pathvalidate import validate_filename, ValidationError
 from rest_framework import serializers
 from rest_framework.fields import SerializerMethodField
@@ -7,12 +8,16 @@ from .models import Correspondent, Tag, Document, Log, DocumentType
 from .parsers import is_mime_type_supported
 
 
-class CorrespondentSerializer(serializers.HyperlinkedModelSerializer):
+class CorrespondentSerializer(serializers.ModelSerializer):
 
     document_count = serializers.IntegerField(read_only=True)
 
     last_correspondence = serializers.DateTimeField(read_only=True)
 
+    def get_slug(self, obj):
+        return slugify(obj.name)
+    slug = SerializerMethodField()
+
     class Meta:
         model = Correspondent
         fields = (
@@ -27,10 +32,14 @@ class CorrespondentSerializer(serializers.HyperlinkedModelSerializer):
         )
 
 
-class DocumentTypeSerializer(serializers.HyperlinkedModelSerializer):
+class DocumentTypeSerializer(serializers.ModelSerializer):
 
     document_count = serializers.IntegerField(read_only=True)
 
+    def get_slug(self, obj):
+        return slugify(obj.name)
+    slug = SerializerMethodField()
+
     class Meta:
         model = DocumentType
         fields = (
@@ -44,10 +53,14 @@ class DocumentTypeSerializer(serializers.HyperlinkedModelSerializer):
         )
 
 
-class TagSerializer(serializers.HyperlinkedModelSerializer):
+class TagSerializer(serializers.ModelSerializer):
 
     document_count = serializers.IntegerField(read_only=True)
 
+    def get_slug(self, obj):
+        return slugify(obj.name)
+    slug = SerializerMethodField()
+
     class Meta:
         model = Tag
         fields = (
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 8a9ce18d7..8121072bf 100755
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -136,7 +136,7 @@ def set_tags(sender,
 
     message = 'Tagging "{}" with "{}"'
     logger(
-        message.format(document, ", ".join([t.slug for t in relevant_tags])),
+        message.format(document, ", ".join([t.name for t in relevant_tags])),
         logging_group
     )
 
@@ -165,7 +165,7 @@ def run_post_consume_script(sender, document, **kwargs):
         reverse("document-download", kwargs={"pk": document.pk}),
         reverse("document-thumb", kwargs={"pk": document.pk}),
         str(document.correspondent),
-        str(",".join(document.tags.all().values_list("slug", flat=True)))
+        str(",".join(document.tags.all().values_list("name", flat=True)))
     )).wait()
 
 
diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py
index f828d3e11..b4b19be4c 100644
--- a/src/documents/tests/test_consumer.py
+++ b/src/documents/tests/test_consumer.py
@@ -27,7 +27,7 @@ class TestAttributes(TestCase):
 
         self.assertEqual(file_info.title, title, filename)
 
-        self.assertEqual(tuple([t.slug for t in file_info.tags]), tags, filename)
+        self.assertEqual(tuple([t.name for t in file_info.tags]), tags, filename)
 
     def test_guess_attributes_from_name0(self):
         self._test_guess_attributes_from_name(
@@ -188,7 +188,7 @@ class TestFieldPermutations(TestCase):
             self.assertEqual(info.tags, (), filename)
         else:
             self.assertEqual(
-                [t.slug for t in info.tags], tags.split(','),
+                [t.name for t in info.tags], tags.split(','),
                 filename
             )
 
@@ -342,8 +342,8 @@ class TestFieldPermutations(TestCase):
             info = FileInfo.from_filename(filename)
             self.assertEqual(info.title, "0001")
             self.assertEqual(len(info.tags), 2)
-            self.assertEqual(info.tags[0].slug, "tag1")
-            self.assertEqual(info.tags[1].slug, "tag2")
+            self.assertEqual(info.tags[0].name, "tag1")
+            self.assertEqual(info.tags[1].name, "tag2")
             self.assertIsNone(info.created)
 
         # Complex transformation with date in replacement string
@@ -356,8 +356,8 @@ class TestFieldPermutations(TestCase):
             info = FileInfo.from_filename(filename)
             self.assertEqual(info.title, "0001")
             self.assertEqual(len(info.tags), 2)
-            self.assertEqual(info.tags[0].slug, "tag1")
-            self.assertEqual(info.tags[1].slug, "tag2")
+            self.assertEqual(info.tags[0].name, "tag1")
+            self.assertEqual(info.tags[1].name, "tag2")
             self.assertEqual(info.created.year, 2019)
             self.assertEqual(info.created.month, 9)
             self.assertEqual(info.created.day, 8)
diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py
index 08f7365da..a82c34f15 100644
--- a/src/paperless_mail/mail.py
+++ b/src/paperless_mail/mail.py
@@ -103,10 +103,7 @@ class MailAccountHandler(LoggingMixin):
 
     def _correspondent_from_name(self, name):
         try:
-            return Correspondent.objects.get_or_create(
-                name=name, defaults={
-                    "slug": slugify(name)
-                })[0]
+            return Correspondent.objects.get_or_create(name=name)[0]
         except DatabaseError as e:
             self.log(
                 "error",

From 0a0d462938032f70d7dcc4485474f8311475e40f Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 00:07:22 +0100
Subject: [PATCH 04/37] tags from folders: case insensitive

---
 src/documents/management/commands/document_consumer.py | 4 +++-
 src/documents/tests/test_management_consumer.py        | 2 +-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py
index b2f689aed..8ac60aa6d 100644
--- a/src/documents/management/commands/document_consumer.py
+++ b/src/documents/management/commands/document_consumer.py
@@ -29,7 +29,9 @@ def _tags_from_path(filepath):
     path_parts = Path(filepath).relative_to(
                 settings.CONSUMPTION_DIR).parent.parts
     for part in path_parts:
-        tag_ids.add(Tag.objects.get_or_create(name=part)[0].pk)
+        tag_ids.add(Tag.objects.get_or_create(name__iexact=part, defaults={
+            "name": part
+        })[0].pk)
 
     return tag_ids
 
diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py
index 6973fdacf..b6a61a167 100644
--- a/src/documents/tests/test_management_consumer.py
+++ b/src/documents/tests/test_management_consumer.py
@@ -230,7 +230,7 @@ class TestConsumerTags(DirectoriesMixin, ConsumerMixin, TransactionTestCase):
 
         tag_names = ("existingTag", "Space Tag")
         # Create a Tag prior to consuming a file using it in path
-        tag_ids = [Tag.objects.create(name=tag_names[0]).pk,]
+        tag_ids = [Tag.objects.create(name="existingtag").pk,]
 
         self.t_start()
 

From 2be0ba9f72f407eec35cad51b47c276fa2e3f917 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 13:27:02 +0100
Subject: [PATCH 05/37] fixed test case. fixed bug with the decryption logic.

---
 .../management/commands/decrypt_documents.py     |  3 ++-
 src/documents/tests/test_management_archiver.py  | 16 +++++++---------
 src/documents/tests/test_management_decrypt.py   | 10 +++++-----
 src/documents/tests/test_management_exporter.py  | 12 +++++-------
 4 files changed, 19 insertions(+), 22 deletions(-)

diff --git a/src/documents/management/commands/decrypt_documents.py b/src/documents/management/commands/decrypt_documents.py
index 2287bfa72..918f1a175 100644
--- a/src/documents/management/commands/decrypt_documents.py
+++ b/src/documents/management/commands/decrypt_documents.py
@@ -82,7 +82,8 @@ class Command(BaseCommand):
             with open(document.thumbnail_path, "wb") as f:
                 f.write(raw_thumb)
 
-            document.save(update_fields=("storage_type", "filename"))
+            Document.objects.filter(id=document.id).update(
+                storage_type=document.storage_type, filename=document.filename)
 
             for path in old_paths:
                 os.unlink(path)
diff --git a/src/documents/tests/test_management_archiver.py b/src/documents/tests/test_management_archiver.py
index fdb588acf..0828f05ff 100644
--- a/src/documents/tests/test_management_archiver.py
+++ b/src/documents/tests/test_management_archiver.py
@@ -16,25 +16,23 @@ sample_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf")
 class TestArchiver(DirectoriesMixin, TestCase):
 
     def make_models(self):
-        self.d1 = Document.objects.create(checksum="A", title="A", content="first document", pk=1, mime_type="application/pdf")
-        #self.d2 = Document.objects.create(checksum="B", title="B", content="second document")
-        #self.d3 = Document.objects.create(checksum="C", title="C", content="unrelated document")
+        return Document.objects.create(checksum="A", title="A", content="first document", mime_type="application/pdf")
 
     def test_archiver(self):
 
-        shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, "0000001.pdf"))
-        self.make_models()
+        doc = self.make_models()
+        shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf"))
 
         call_command('document_archiver')
 
     def test_handle_document(self):
 
-        shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, "0000001.pdf"))
-        self.make_models()
+        doc = self.make_models()
+        shutil.copy(sample_file, os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf"))
 
-        handle_document(self.d1.pk)
+        handle_document(doc.pk)
 
-        doc = Document.objects.get(id=self.d1.id)
+        doc = Document.objects.get(id=doc.id)
 
         self.assertIsNotNone(doc.checksum)
         self.assertTrue(os.path.isfile(doc.archive_path))
diff --git a/src/documents/tests/test_management_decrypt.py b/src/documents/tests/test_management_decrypt.py
index f68ea7cc1..1d64b1105 100644
--- a/src/documents/tests/test_management_decrypt.py
+++ b/src/documents/tests/test_management_decrypt.py
@@ -35,20 +35,20 @@ class TestDecryptDocuments(TestCase):
             PASSPHRASE="test"
         ).enable()
 
-        shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "originals", "0000002.pdf.gpg"), os.path.join(originals_dir, "0000002.pdf.gpg"))
-        shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "thumbnails", "0000002.png.gpg"), os.path.join(thumb_dir, "0000002.png.gpg"))
+        doc = Document.objects.create(checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg",  mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG)
 
-        Document.objects.create(checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", id=2, mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG)
+        shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "originals", "0000002.pdf.gpg"), os.path.join(originals_dir, "0000002.pdf.gpg"))
+        shutil.copy(os.path.join(os.path.dirname(__file__), "samples", "documents", "thumbnails", f"0000002.png.gpg"), os.path.join(thumb_dir, f"{doc.id:07}.png.gpg"))
 
         call_command('decrypt_documents')
 
-        doc = Document.objects.get(id=2)
+        doc.refresh_from_db()
 
         self.assertEqual(doc.storage_type, Document.STORAGE_TYPE_UNENCRYPTED)
         self.assertEqual(doc.filename, "0000002.pdf")
         self.assertTrue(os.path.isfile(os.path.join(originals_dir, "0000002.pdf")))
         self.assertTrue(os.path.isfile(doc.source_path))
-        self.assertTrue(os.path.isfile(os.path.join(thumb_dir, "0000002.png")))
+        self.assertTrue(os.path.isfile(os.path.join(thumb_dir, f"{doc.id:07}.png")))
         self.assertTrue(os.path.isfile(doc.thumbnail_path))
 
         with doc.source_file as f:
diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py
index ab9733dc4..22d6fc7f6 100644
--- a/src/documents/tests/test_management_exporter.py
+++ b/src/documents/tests/test_management_exporter.py
@@ -24,13 +24,14 @@ class TestExportImport(DirectoriesMixin, TestCase):
 
         file = os.path.join(self.dirs.originals_dir, "0000001.pdf")
 
-        Document.objects.create(content="Content", checksum="42995833e01aea9b3edee44bbfdd7ce1", archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", title="wow", filename="0000001.pdf", id=1, mime_type="application/pdf")
-        Document.objects.create(content="Content", checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", id=2, mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG)
+        Document.objects.create(content="Content", checksum="42995833e01aea9b3edee44bbfdd7ce1", archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", title="wow", filename="0000001.pdf", mime_type="application/pdf")
+        Document.objects.create(content="Content", checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG)
         Tag.objects.create(name="t")
         DocumentType.objects.create(name="dt")
         Correspondent.objects.create(name="c")
 
         target = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, target)
 
         call_command('document_exporter', target)
 
@@ -66,9 +67,6 @@ class TestExportImport(DirectoriesMixin, TestCase):
     def test_export_missing_files(self):
 
         target = tempfile.mkdtemp()
-        Document.objects.create(checksum="AAAAAAAAAAAAAAAAA", title="wow", filename="0000004.pdf", id=3, mime_type="application/pdf")
+        self.addCleanup(shutil.rmtree, target)
+        Document.objects.create(checksum="AAAAAAAAAAAAAAAAA", title="wow", filename="0000004.pdf", mime_type="application/pdf")
         self.assertRaises(FileNotFoundError, call_command, 'document_exporter', target)
-
-    def test_duplicate_titles(self):
-        # TODO
-        pass

From 8ca97924be54974725ae26f7fe4768197c559262 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 13:44:37 +0100
Subject: [PATCH 06/37] shadows

---
 .../app/components/common/input/tags/tags.component.html    | 2 +-
 .../widgets/widget-frame/widget-frame.component.html        | 2 +-
 .../document-detail/document-detail.component.html          | 2 +-
 .../components/document-list/document-list.component.html   | 6 +++---
 4 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/src-ui/src/app/components/common/input/tags/tags.component.html b/src-ui/src/app/components/common/input/tags/tags.component.html
index b2ad0944f..8029dd860 100644
--- a/src-ui/src/app/components/common/input/tags/tags.component.html
+++ b/src-ui/src/app/components/common/input/tags/tags.component.html
@@ -8,7 +8,7 @@
 
     <div class="input-group-append" ngbDropdown placement="top-right">
       <button class="btn btn-outline-secondary" type="button" ngbDropdownToggle></button>
-      <div ngbDropdownMenu class="scrollable-menu">
+      <div ngbDropdownMenu class="scrollable-menu shadow">
         <button type="button" *ngFor="let tag of tags" ngbDropdownItem (click)="addTag(tag.id)">
           <app-tag [tag]="tag"></app-tag>
         </button>
diff --git a/src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.html b/src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.html
index d0f637935..1d7d2d906 100644
--- a/src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.html
+++ b/src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.html
@@ -1,4 +1,4 @@
-<div class="card mb-3 shadow">
+<div class="card mb-3 shadow-sm">
   <div class="card-header">
     <div class="d-flex justify-content-between align-items-center">
       <h5 class="card-title mb-0">{{title}}</h5>
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index 9f4c72cdd..e0b5c6da9 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -17,7 +17,7 @@
 
         <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version">
             <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
-            <div class="dropdown-menu" ngbDropdownMenu>
+            <div class="dropdown-menu shadow" ngbDropdownMenu>
                 <a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a>
             </div>
         </div>
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 8608ed92b..1a8c7a781 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
@@ -24,7 +24,7 @@
   <div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortDirection">
     <div ngbDropdown class="btn-group">
       <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>Sort by</button>
-      <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
+      <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow">
         <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field"
           [class.active]="list.sortField == f.field">{{f.name}}</button>
       </div>
@@ -53,7 +53,7 @@
 
     <div class="btn-group" ngbDropdown role="group">
       <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
-      <div class="dropdown-menu" ngbDropdownMenu>
+      <div class="dropdown-menu" ngbDropdownMenu class="shadow">
         <ng-container *ngIf="!list.savedViewId" >
           <button ngbDropdownItem *ngFor="let config of savedViewConfigService.getConfigs()" (click)="loadViewConfig(config)">{{config.title}}</button>
           <div class="dropdown-divider" *ngIf="savedViewConfigService.getConfigs().length > 0"></div>
@@ -85,7 +85,7 @@
   </app-document-card-large>
 </div>
 
-<table class="table table-sm border shadow" *ngIf="displayMode == 'details'">
+<table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'">
   <thead>
     <th class="d-none d-lg-table-cell">ASN</th>
     <th class="d-none d-md-table-cell">Correspondent</th>

From 6003122b0684450993efd7c82c7cace927473e08 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 22:16:57 +0100
Subject: [PATCH 07/37] fixes #112

---
 src/documents/consumer.py | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/src/documents/consumer.py b/src/documents/consumer.py
index 19ca3ed7e..e4da51f1d 100755
--- a/src/documents/consumer.py
+++ b/src/documents/consumer.py
@@ -248,7 +248,7 @@ class Consumer(LoggingMixin):
         with open(self.path, "rb") as f:
             document = Document.objects.create(
                 correspondent=file_info.correspondent,
-                title=file_info.title,
+                title=(self.override_title or file_info.title)[:127],
                 content=text,
                 mime_type=mime_type,
                 checksum=hashlib.md5(f.read()).hexdigest(),
@@ -265,12 +265,11 @@ class Consumer(LoggingMixin):
 
         self.apply_overrides(document)
 
+        document.save()
+
         return document
 
     def apply_overrides(self, document):
-        if self.override_title:
-            document.title = self.override_title
-
         if self.override_correspondent_id:
             document.correspondent = Correspondent.objects.get(
                 pk=self.override_correspondent_id)

From 70cbdbf23b026ce09c54e2fc3d4e832f80355c41 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 22:17:17 +0100
Subject: [PATCH 08/37] locking media directory while deleting files

---
 src/documents/signals/handlers.py | 43 ++++++++++++++++---------------
 1 file changed, 22 insertions(+), 21 deletions(-)

diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 8121072bf..4fbbe8f8a 100755
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -171,29 +171,30 @@ def run_post_consume_script(sender, document, **kwargs):
 
 @receiver(models.signals.post_delete, sender=Document)
 def cleanup_document_deletion(sender, instance, using, **kwargs):
-    for f in (instance.source_path,
-              instance.archive_path,
-              instance.thumbnail_path):
-        if os.path.isfile(f):
-            try:
-                os.unlink(f)
-                logging.getLogger(__name__).debug(
-                    f"Deleted file {f}.")
-            except OSError as e:
-                logging.getLogger(__name__).warning(
-                    f"While deleting document {str(instance)}, the file "
-                    f"{f} could not be deleted: {e}"
-                )
+    with FileLock(settings.MEDIA_LOCK):
+        for f in (instance.source_path,
+                  instance.archive_path,
+                  instance.thumbnail_path):
+            if os.path.isfile(f):
+                try:
+                    os.unlink(f)
+                    logging.getLogger(__name__).debug(
+                        f"Deleted file {f}.")
+                except OSError as e:
+                    logging.getLogger(__name__).warning(
+                        f"While deleting document {str(instance)}, the file "
+                        f"{f} could not be deleted: {e}"
+                    )
 
-    delete_empty_directories(
-        os.path.dirname(instance.source_path),
-        root=settings.ORIGINALS_DIR
-    )
+        delete_empty_directories(
+            os.path.dirname(instance.source_path),
+            root=settings.ORIGINALS_DIR
+        )
 
-    delete_empty_directories(
-        os.path.dirname(instance.archive_path),
-        root=settings.ARCHIVE_DIR
-    )
+        delete_empty_directories(
+            os.path.dirname(instance.archive_path),
+            root=settings.ARCHIVE_DIR
+        )
 
 
 def validate_move(instance, old_path, new_path):

From 20c46278dcfcb00770b34476acd15f2245da0b0a Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 22:18:03 +0100
Subject: [PATCH 09/37] removed a janky test case that caused other test cases
 to fail

---
 src/documents/tests/test_file_handling.py | 20 --------------------
 1 file changed, 20 deletions(-)

diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py
index 6d407a7ab..719b0078a 100644
--- a/src/documents/tests/test_file_handling.py
+++ b/src/documents/tests/test_file_handling.py
@@ -3,7 +3,6 @@ import hashlib
 import os
 import random
 import uuid
-from concurrent.futures.thread import ThreadPoolExecutor
 from pathlib import Path
 from unittest import mock
 
@@ -15,7 +14,6 @@ from .utils import DirectoriesMixin
 from ..file_handling import generate_filename, create_source_path_directory, delete_empty_directories, \
     generate_unique_filename
 from ..models import Document, Correspondent
-from ..sanity_checker import check_sanity
 
 
 class TestFileHandling(DirectoriesMixin, TestCase):
@@ -573,21 +571,3 @@ def run():
     for i in range(30):
         doc.title = str(random.randrange(1, 5))
         doc.save()
-
-
-class TestSuperMassive(DirectoriesMixin, TestCase):
-
-    @override_settings(PAPERLESS_FILENAME_FORMAT="{title}")
-    def test_super_massive(self):
-        # try to save as many documents in parallel as possible.
-        # try to make the system fail.
-
-        with ThreadPoolExecutor(max_workers=16) as executor:
-            results = [executor.submit(run) for i in range(16)]
-
-        for r in results:
-            if r.exception():
-                raise r.exception()
-
-        # nope, everything still good. Thank you, lockfiles.
-        self.assertEqual(len(check_sanity()), 0)

From 0b1b9de3cc925708cf3479974087418541602811 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 22:38:52 +0100
Subject: [PATCH 10/37] layout fix

---
 .../document-card-small/document-card-small.component.html      | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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 95cf2e191..da469ebc4 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,6 +1,6 @@
 <div class="col p-2 h-100" style="width: 16rem;">
   <div class="card h-100 shadow-sm">
-    <div class=" border-bottom pr-1">
+    <div class="border-bottom">
       <img class="card-img doc-img" [src]="getThumbUrl()">
       <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1">
         <div *ngFor="let t of getTagsLimited$() | async">

From 2b57b8065654800d3ddd86201210f7d091b4ba25 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Wed, 9 Dec 2020 23:45:53 +0100
Subject: [PATCH 11/37] fixes #113

---
 docker/docker-entrypoint.sh | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
index e2338842b..4832675ab 100644
--- a/docker/docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -114,13 +114,13 @@ install_languages() {
     done
 }
 
-initialize
-
 # Install additional languages if specified
 if [[ ! -z "$PAPERLESS_OCR_LANGUAGES"  ]]; then
 		install_languages "$PAPERLESS_OCR_LANGUAGES"
 fi
 
+initialize
+
 if [[ "$1" != "/"* ]]; then
 	exec sudo -HEu paperless python3 manage.py "$@"
 else

From 46c0ab943f22157378ea20243d093f6b8ae87ad1 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 00:02:45 +0100
Subject: [PATCH 12/37] added a progress bar to the reindex command.

---
 src/documents/tasks.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index 65d767efc..8c9b00dd6 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -1,5 +1,6 @@
 import logging
 
+import tqdm
 from django.conf import settings
 from whoosh.writing import AsyncWriter
 
@@ -23,7 +24,7 @@ def index_reindex():
     ix = index.open_index(recreate=True)
 
     with AsyncWriter(ix) as writer:
-        for document in documents:
+        for document in tqdm.tqdm(documents):
             index.update_document(writer, document)
 
 

From b3daf0efc33106f1e92122d5d50bf06d7596a84d Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 00:10:36 +0100
Subject: [PATCH 13/37] added progress bar to the document renamer.

---
 src/documents/management/commands/document_renamer.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/documents/management/commands/document_renamer.py b/src/documents/management/commands/document_renamer.py
index ba9e74de5..5d7d0d90c 100644
--- a/src/documents/management/commands/document_renamer.py
+++ b/src/documents/management/commands/document_renamer.py
@@ -1,3 +1,6 @@
+import logging
+
+import tqdm
 from django.core.management.base import BaseCommand
 
 from documents.models import Document
@@ -18,6 +21,8 @@ class Command(Renderable, BaseCommand):
 
         self.verbosity = options["verbosity"]
 
-        for document in Document.objects.all():
+        logging.getLogger().handlers[0].level = logging.ERROR
+
+        for document in tqdm.tqdm(Document.objects.all()):
             # Saving the document again will generate a new filename and rename
             document.save()

From 3f03cbf66c446ecb04ebea84491eb78d912a56aa Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 00:29:47 +0100
Subject: [PATCH 14/37] excluded the lockfile from the sanity checker.

---
 src/documents/sanity_checker.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/documents/sanity_checker.py b/src/documents/sanity_checker.py
index e3c4b1aec..bc0b689d4 100644
--- a/src/documents/sanity_checker.py
+++ b/src/documents/sanity_checker.py
@@ -46,6 +46,10 @@ def check_sanity():
         for f in files:
             present_files.append(os.path.normpath(os.path.join(root, f)))
 
+    lockfile = os.path.normpath(settings.MEDIA_LOCK)
+    if lockfile in present_files:
+        present_files.remove(lockfile)
+
     for doc in Document.objects.all():
         # Check sanity of the thumbnail
         if not os.path.isfile(doc.thumbnail_path):

From 2df1894683ae3b4aa4a64e216436b31062332234 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 00:30:35 +0100
Subject: [PATCH 15/37] changelog

---
 docs/changelog.rst | 1 +
 1 file changed, 1 insertion(+)

diff --git a/docs/changelog.rst b/docs/changelog.rst
index 96578ac75..ce5cfe59a 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -37,6 +37,7 @@ This release focusses primarily on many small issues with the UI.
     conserve the original correspondents, types and titles as much as possible.
   * The filename formatter does not include the document ID in filenames anymore. It will
     rather append ``_01``, ``_02``, etc when it detects duplicate filenames.
+  * The docker image was trying check for installed languages before actually installing them.
 
 .. note::
 

From 24d8a50f0174ff78fbbae9eda6eea0eca1fa80aa Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 00:54:37 +0100
Subject: [PATCH 16/37] fixed an issue with the docker entrypoint script.

---
 docker/docker-entrypoint.sh | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
index 4832675ab..13a0ba035 100644
--- a/docker/docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -25,6 +25,11 @@ wait_for_postgres() {
 	host="${PAPERLESS_DBHOST}"
 	port="${PAPERLESS_DBPORT}"
 
+	if [[ -z $port ]] ;
+	then
+		port="5432"
+	fi
+
 	while !</dev/tcp/$host/$port ;
 	do
 

From 69c6d682194708736818fe523b7e05f9234a3619 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 00:59:03 +0100
Subject: [PATCH 17/37] a print() command somehow sneaked past my commit
 checks.

---
 src/documents/filters.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/documents/filters.py b/src/documents/filters.py
index 64ef826ce..b3c92eba3 100755
--- a/src/documents/filters.py
+++ b/src/documents/filters.py
@@ -51,7 +51,6 @@ class TagsFilter(Filter):
             return qs
 
         for tag_id in tag_ids:
-            print(self.exclude, tag_id)
             if self.exclude:
                 qs = qs.exclude(tags__id=tag_id)
             else:

From 476beacd7f9d5f28c9a437b8ed761e6e67f226ff Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 01:12:30 +0100
Subject: [PATCH 18/37] changelog

---
 docs/changelog.rst | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/changelog.rst b/docs/changelog.rst
index ce5cfe59a..a50fc31d5 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -32,12 +32,12 @@ This release focusses primarily on many small issues with the UI.
 * Other
 
   * Fixed an issue with the docker image when a non-standard PostgreSQL port was used.
+  * The docker image was trying check for installed languages before actually installing them.
   * ``FILENAME_FORMAT`` placeholder for document types.
   * The filename formatter is now less restrictive with file names and tries to
     conserve the original correspondents, types and titles as much as possible.
   * The filename formatter does not include the document ID in filenames anymore. It will
     rather append ``_01``, ``_02``, etc when it detects duplicate filenames.
-  * The docker image was trying check for installed languages before actually installing them.
 
 .. note::
 

From 3584f732a78ee2edfa125f0f4070dd1399f7c3d2 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 02:14:26 +0100
Subject: [PATCH 19/37] added another library that's required to get this
 running on raspberry pi

---
 docker/local/Dockerfile | 1 +
 1 file changed, 1 insertion(+)

diff --git a/docker/local/Dockerfile b/docker/local/Dockerfile
index 461b9e4fc..d6e77da1d 100644
--- a/docker/local/Dockerfile
+++ b/docker/local/Dockerfile
@@ -20,6 +20,7 @@ RUN apt-get update \
 		libpq-dev \
 		libqpdf-dev \
 		libxml2 \
+		libxslt-dev \
 		optipng \
 		pngquant \
 		qpdf \

From 0cc22017deb8695e45cb99fc49e587cdb87d438f Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 02:24:36 +0100
Subject: [PATCH 20/37] revert last commit.

---
 docker/local/Dockerfile | 1 -
 1 file changed, 1 deletion(-)

diff --git a/docker/local/Dockerfile b/docker/local/Dockerfile
index d6e77da1d..461b9e4fc 100644
--- a/docker/local/Dockerfile
+++ b/docker/local/Dockerfile
@@ -20,7 +20,6 @@ RUN apt-get update \
 		libpq-dev \
 		libqpdf-dev \
 		libxml2 \
-		libxslt-dev \
 		optipng \
 		pngquant \
 		qpdf \

From 2f7bb01f3494175ef480b47d7e3ba5be7fab16a0 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 14:57:53 +0100
Subject: [PATCH 21/37] moved metadata extraction to the parsers

---
 src/documents/parsers.py           |  4 ++++
 src/documents/views.py             | 36 +++++++-----------------------
 src/paperless_tesseract/parsers.py | 28 +++++++++++++++++++++++
 3 files changed, 40 insertions(+), 28 deletions(-)

diff --git a/src/documents/parsers.py b/src/documents/parsers.py
index 36ede3cce..228e2c86e 100644
--- a/src/documents/parsers.py
+++ b/src/documents/parsers.py
@@ -210,6 +210,7 @@ class DocumentParser(LoggingMixin):
     def __init__(self, logging_group):
         super().__init__()
         self.logging_group = logging_group
+        os.makedirs(settings.SCRATCH_DIR, exist_ok=True)
         self.tempdir = tempfile.mkdtemp(
             prefix="paperless-", dir=settings.SCRATCH_DIR)
 
@@ -217,6 +218,9 @@ class DocumentParser(LoggingMixin):
         self.text = None
         self.date = None
 
+    def extract_metadata(self, document_path, mime_type):
+        return []
+
     def parse(self, document_path, mime_type):
         raise NotImplementedError()
 
diff --git a/src/documents/views.py b/src/documents/views.py
index 8dbb61dc7..b42ae1f96 100755
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -1,11 +1,8 @@
-import logging
 import os
-import re
 import tempfile
 from datetime import datetime
 from time import mktime
 
-import pikepdf
 from django.conf import settings
 from django.db.models import Count, Max
 from django.http import HttpResponse, HttpResponseBadRequest, Http404
@@ -42,6 +39,7 @@ from .filters import (
     LogFilterSet
 )
 from .models import Correspondent, Document, Log, Tag, DocumentType
+from .parsers import get_parser_class_for_mime_type
 from .serialisers import (
     CorrespondentSerializer,
     DocumentSerializer,
@@ -163,34 +161,16 @@ class DocumentViewSet(RetrieveModelMixin,
             disposition, filename)
         return response
 
-    def get_metadata(self, file, type):
+    def get_metadata(self, file, mime_type):
         if not os.path.isfile(file):
             return None
 
-        namespace_pattern = re.compile(r"\{(.*)\}(.*)")
-
-        result = []
-        if type == 'application/pdf':
-            pdf = pikepdf.open(file)
-            meta = pdf.open_metadata()
-            for key, value in meta.items():
-                if isinstance(value, list):
-                    value = " ".join([str(e) for e in value])
-                value = str(value)
-                try:
-                    m = namespace_pattern.match(key)
-                    result.append({
-                        "namespace": m.group(1),
-                        "prefix": meta.REVERSE_NS[m.group(1)],
-                        "key": m.group(2),
-                        "value": value
-                    })
-                except Exception as e:
-                    logging.getLogger(__name__).warning(
-                        f"Error while reading metadata {key}: {value}. Error: "
-                        f"{e}"
-                    )
-        return result
+        parser_class = get_parser_class_for_mime_type(mime_type)
+        if parser_class:
+            parser = parser_class(logging_group=None)
+            return parser.extract_metadata(file, mime_type)
+        else:
+            return []
 
     @action(methods=['get'], detail=True)
     def metadata(self, request, pk=None):
diff --git a/src/paperless_tesseract/parsers.py b/src/paperless_tesseract/parsers.py
index ebd706cdd..1cf6a769c 100644
--- a/src/paperless_tesseract/parsers.py
+++ b/src/paperless_tesseract/parsers.py
@@ -5,6 +5,7 @@ import subprocess
 
 import ocrmypdf
 import pdftotext
+import pikepdf
 from PIL import Image
 from django.conf import settings
 from ocrmypdf import InputFileError, EncryptedPdfError
@@ -18,6 +19,33 @@ class RasterisedDocumentParser(DocumentParser):
     image, whether it's a PDF, or other graphical format (JPEG, TIFF, etc.)
     """
 
+    def extract_metadata(self, document_path, mime_type):
+        namespace_pattern = re.compile(r"\{(.*)\}(.*)")
+
+        result = []
+        if mime_type == 'application/pdf':
+            pdf = pikepdf.open(document_path)
+            meta = pdf.open_metadata()
+            for key, value in meta.items():
+                if isinstance(value, list):
+                    value = " ".join([str(e) for e in value])
+                value = str(value)
+                try:
+                    m = namespace_pattern.match(key)
+                    result.append({
+                        "namespace": m.group(1),
+                        "prefix": meta.REVERSE_NS[m.group(1)],
+                        "key": m.group(2),
+                        "value": value
+                    })
+                except Exception as e:
+                    self.log(
+                        "warning",
+                        f"Error while reading metadata {key}: {value}. Error: "
+                        f"{e}"
+                    )
+        return result
+
     def get_thumbnail(self, document_path, mime_type):
         """
         The thumbnail of a PDF is just a 500px wide image of the first page.

From defa80d05ac264df2cbbd8d2485d2d7a999a19c5 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Thu, 10 Dec 2020 16:25:27 +0100
Subject: [PATCH 22/37] fixes #91

---
 src/documents/serialisers.py    |  7 -------
 src/documents/tests/test_api.py | 10 ----------
 2 files changed, 17 deletions(-)

diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 600645061..db0e610d1 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1,6 +1,5 @@
 import magic
 from django.utils.text import slugify
-from pathvalidate import validate_filename, ValidationError
 from rest_framework import serializers
 from rest_framework.fields import SerializerMethodField
 
@@ -179,12 +178,6 @@ class PostDocumentSerializer(serializers.Serializer):
     )
 
     def validate_document(self, document):
-
-        try:
-            validate_filename(document.name)
-        except ValidationError:
-            raise serializers.ValidationError("Invalid filename.")
-
         document_data = document.file.read()
         mime_type = magic.from_buffer(document_data, mime=True)
 
diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py
index 572667406..ab1716366 100644
--- a/src/documents/tests/test_api.py
+++ b/src/documents/tests/test_api.py
@@ -403,16 +403,6 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
         self.assertEqual(response.status_code, 400)
         m.assert_not_called()
 
-    @mock.patch("documents.views.async_task")
-    @mock.patch("documents.serialisers.validate_filename")
-    def test_upload_invalid_filename(self, validate_filename, async_task):
-        validate_filename.side_effect = ValidationError()
-        with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f:
-            response = self.client.post("/api/documents/post_document/", {"document": f})
-        self.assertEqual(response.status_code, 400)
-
-        async_task.assert_not_called()
-
     @mock.patch("documents.views.async_task")
     def test_upload_with_title(self, async_task):
         with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f:

From 3a82b7806ac229b7c943de4b6aa02ec023ad863b Mon Sep 17 00:00:00 2001
From: Jonas Winkler <dev@jpwinkler.de>
Date: Thu, 10 Dec 2020 16:28:02 +0100
Subject: [PATCH 23/37] Update README.md

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index e754669a8..41f85af19 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@ Here's what you get:
 # Features
 
 * Performs OCR on your documents, adds selectable text to image only documents and adds tags, correspondents and document types to your documents.
+* Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and can be configured freely.
 * Single page application front end. Should be pretty snappy. Will be mobile friendly in the future.
 	* Includes a dashboard that shows basic statistics and has document upload.
 	* Filtering by tags, correspondents, types, and more.

From b452816a29791e31c531035c76ff15a8b15de514 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Fri, 11 Dec 2020 17:49:32 +0100
Subject: [PATCH 24/37] fixes #122

---
 docs/configuration.rst    | 10 ++++++++++
 paperless.conf.example    |  1 +
 src/paperless/settings.py |  6 ++++++
 3 files changed, 17 insertions(+)

diff --git a/docs/configuration.rst b/docs/configuration.rst
index 2ec34f803..d3f47215b 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -152,6 +152,16 @@ PAPERLESS_AUTO_LOGIN_USERNAME=<username>
 
     Defaults to none, which disables this feature.
 
+
+PAPERLESS_COOKIE_PREFIX=<str>
+    Specify a prefix that is added to the cookies used by paperless to identify
+    the currently logged in user. This is useful for when you're running two
+    instances of paperless on the same host.
+
+    After changing this, you will have to login again.
+
+    Defaults to ``""``, which does not alter the cookie names.
+
 .. _configuration-ocr:
 
 OCR settings
diff --git a/paperless.conf.example b/paperless.conf.example
index 32c0e56b4..910fc22a0 100644
--- a/paperless.conf.example
+++ b/paperless.conf.example
@@ -30,6 +30,7 @@
 #PAPERLESS_FORCE_SCRIPT_NAME=
 #PAPERLESS_STATIC_URL=/static/
 #PAPERLESS_AUTO_LOGIN_USERNAME=
+#PAPERLESS_COOKIE_PREFIX=
 
 # OCR settings
 
diff --git a/src/paperless/settings.py b/src/paperless/settings.py
index cf0c3e28d..1a6b80a0c 100644
--- a/src/paperless/settings.py
+++ b/src/paperless/settings.py
@@ -210,6 +210,12 @@ AUTH_PASSWORD_VALIDATORS = [
 
 DATA_UPLOAD_MAX_NUMBER_FIELDS = None
 
+COOKIE_PREFIX = os.getenv("PAPERLESS_COOKIE_PREFIX", "")
+
+CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken"
+SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
+LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
+
 ###############################################################################
 # Database                                                                    #
 ###############################################################################

From 0b7ffa31d1f3953a37314555823a396d4241b922 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Fri, 11 Dec 2020 17:57:56 +0100
Subject: [PATCH 25/37] fixes #115

---
 src-ui/src/app/app.module.ts                     |  4 +++-
 .../document-card-large.component.html           |  2 +-
 .../document-card-small.component.html           |  2 +-
 .../document-list/document-list.component.html   |  2 +-
 src-ui/src/app/pipes/document-title.pipe.spec.ts |  8 ++++++++
 src-ui/src/app/pipes/document-title.pipe.ts      | 16 ++++++++++++++++
 6 files changed, 30 insertions(+), 4 deletions(-)
 create mode 100644 src-ui/src/app/pipes/document-title.pipe.spec.ts
 create mode 100644 src-ui/src/app/pipes/document-title.pipe.ts

diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index ad12c9c47..675c882a7 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -48,6 +48,7 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
 import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component';
 import { YesNoPipe } from './pipes/yes-no.pipe';
 import { FileSizePipe } from './pipes/file-size.pipe';
+import { DocumentTitlePipe } from './pipes/document-title.pipe';
 
 @NgModule({
   declarations: [
@@ -88,7 +89,8 @@ import { FileSizePipe } from './pipes/file-size.pipe';
     WidgetFrameComponent,
     WelcomeWidgetComponent,
     YesNoPipe,
-    FileSizePipe
+    FileSizePipe,
+    DocumentTitlePipe
   ],
   imports: [
     BrowserModule,
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 bfc59b526..8f3fced66 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
@@ -12,7 +12,7 @@
               <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>
               <ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>:
             </ng-container>
-            {{document.title}}
+            {{document.title | documentTitle}}
             <app-tag [tag]="t" linkTitle="Filter by tag" *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id)" [clickable]="clickTag.observers.length"></app-tag>
           </h5>
           <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5>
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 da469ebc4..86e28442c 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
@@ -17,7 +17,7 @@
         <ng-container *ngIf="document.correspondent">
           <a [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>:
         </ng-container>
-        {{document.title}}
+        {{document.title | documentTitle}}
       </p>
     </div>
     <div class="card-footer">
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 1a8c7a781..c4fa0d4d7 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
@@ -105,7 +105,7 @@
         </ng-container>
       </td>
       <td>
-        <a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title}}</a>
+        <a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
         <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="filterByTag(t.id)"></app-tag>
       </td>
       <td class="d-none d-xl-table-cell">
diff --git a/src-ui/src/app/pipes/document-title.pipe.spec.ts b/src-ui/src/app/pipes/document-title.pipe.spec.ts
new file mode 100644
index 000000000..29835abd6
--- /dev/null
+++ b/src-ui/src/app/pipes/document-title.pipe.spec.ts
@@ -0,0 +1,8 @@
+import { DocumentTitlePipe } from './document-title.pipe';
+
+describe('DocumentTitlePipe', () => {
+  it('create an instance', () => {
+    const pipe = new DocumentTitlePipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src-ui/src/app/pipes/document-title.pipe.ts b/src-ui/src/app/pipes/document-title.pipe.ts
new file mode 100644
index 000000000..09445f595
--- /dev/null
+++ b/src-ui/src/app/pipes/document-title.pipe.ts
@@ -0,0 +1,16 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'documentTitle'
+})
+export class DocumentTitlePipe implements PipeTransform {
+
+  transform(value: string): unknown {
+    if (value) {
+      return value
+    } else {
+      return "(no title)"
+    }
+  }
+
+}

From de5d360d52d21e3695a929841402cbb22e2fe8bc Mon Sep 17 00:00:00 2001
From: Michael Shamoon <4887959+nikonratm@users.noreply.github.com>
Date: Fri, 11 Dec 2020 14:22:02 -0800
Subject: [PATCH 26/37] Use ng2-pdf-viewer

And remove now-unused safeUrl pipe
---
 src-ui/package-lock.json                      | 63 ++++++++++++++++++-
 src-ui/package.json                           |  1 +
 src-ui/src/app/app.module.ts                  |  6 +-
 .../document-detail.component.html            | 12 ++--
 .../document-detail.component.scss            |  8 +++
 src-ui/src/app/pipes/safe.pipe.spec.ts        |  8 ---
 src-ui/src/app/pipes/safe.pipe.ts             | 19 ------
 7 files changed, 78 insertions(+), 39 deletions(-)
 delete mode 100644 src-ui/src/app/pipes/safe.pipe.spec.ts
 delete mode 100644 src-ui/src/app/pipes/safe.pipe.ts

diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json
index b6b66e1c6..5eca0b3c0 100644
--- a/src-ui/package-lock.json
+++ b/src-ui/package-lock.json
@@ -2215,6 +2215,11 @@
       "integrity": "sha512-UV1/ZJMC+HcP902wWdpC43cAcGu0IQk/I5bXjP2aSuCjsk3cE74mDvFrLKga7oDC170ugOAYBwfT4DSQW3akDA==",
       "dev": true
     },
+    "@types/pdfjs-dist": {
+      "version": "2.1.7",
+      "resolved": "https://registry.npmjs.org/@types/pdfjs-dist/-/pdfjs-dist-2.1.7.tgz",
+      "integrity": "sha512-nQIwcPUhkAIyn7x9NS0lR/qxYfd5unRtfGkMjvpgF4Sh28IXftRymaNmFKTTdejDNY25NDGSIyjwj/BRwAPexg=="
+    },
     "@types/q": {
       "version": "1.5.4",
       "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz",
@@ -3023,6 +3028,16 @@
       "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
       "dev": true
     },
+    "bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
     "blob": {
       "version": "0.0.5",
       "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
@@ -5508,6 +5523,13 @@
         "schema-utils": "^2.6.5"
       }
     },
+    "file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+      "dev": true,
+      "optional": true
+    },
     "fill-range": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -8208,6 +8230,13 @@
       "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
       "dev": true
     },
+    "nan": {
+      "version": "2.14.2",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
+      "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
+      "dev": true,
+      "optional": true
+    },
     "nanomatch": {
       "version": "1.2.13",
       "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -8260,6 +8289,23 @@
         "moment": "2.18.1"
       }
     },
+    "ng2-pdf-viewer": {
+      "version": "6.3.2",
+      "resolved": "https://registry.npmjs.org/ng2-pdf-viewer/-/ng2-pdf-viewer-6.3.2.tgz",
+      "integrity": "sha512-H2tBhDd+Lq6CUzK2g54HsCcZDR2wTn1sDjYqKY3yF0Ydasl2R5ppCKynZBU/zge4EKvmHglJI120FbQMpJKDYQ==",
+      "requires": {
+        "@types/pdfjs-dist": "^2.1.4",
+        "pdfjs-dist": "^2.4.456",
+        "tslib": "^1.10.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "1.14.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+          "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+        }
+      }
+    },
     "ngx-cookie-service": {
       "version": "10.1.1",
       "resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-10.1.1.tgz",
@@ -9270,6 +9316,11 @@
         "sha.js": "^2.4.8"
       }
     },
+    "pdfjs-dist": {
+      "version": "2.5.207",
+      "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.5.207.tgz",
+      "integrity": "sha512-xGDUhnCYPfHy+unMXCLCJtlpZaaZ17Ew3WIL0tnSgKFUZXHAPD49GO9xScyszSsQMoutNDgRb+rfBXIaX/lJbw=="
+    },
     "performance-now": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -13228,7 +13279,11 @@
           "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
           "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
           "dev": true,
-          "optional": true
+          "optional": true,
+          "requires": {
+            "bindings": "^1.5.0",
+            "nan": "^2.12.1"
+          }
         },
         "glob-parent": {
           "version": "3.1.0",
@@ -13832,7 +13887,11 @@
           "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
           "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
           "dev": true,
-          "optional": true
+          "optional": true,
+          "requires": {
+            "bindings": "^1.5.0",
+            "nan": "^2.12.1"
+          }
         },
         "glob-parent": {
           "version": "3.1.0",
diff --git a/src-ui/package.json b/src-ui/package.json
index af3334db9..6293f2672 100644
--- a/src-ui/package.json
+++ b/src-ui/package.json
@@ -23,6 +23,7 @@
     "@ng-bootstrap/ng-bootstrap": "^8.0.0",
     "bootstrap": "^4.5.0",
     "ng-bootstrap": "^1.6.3",
+    "ng2-pdf-viewer": "^6.3.2",
     "ngx-cookie-service": "^10.1.1",
     "ngx-file-drop": "^10.0.0",
     "ngx-infinite-scroll": "^9.1.0",
diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index 7f2e8414e..40c0991e7 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -14,7 +14,6 @@ import { LogsComponent } from './components/manage/logs/logs.component';
 import { SettingsComponent } from './components/manage/settings/settings.component';
 import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 import { DatePipe } from '@angular/common';
-import { SafePipe } from './pipes/safe.pipe';
 import { NotFoundComponent } from './components/not-found/not-found.component';
 import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component';
 import { DeleteDialogComponent } from './components/common/delete-dialog/delete-dialog.component';
@@ -45,6 +44,7 @@ import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-v
 import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component';
 import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component';
 import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component';
+import { PdfViewerModule } from 'ng2-pdf-viewer';
 
 @NgModule({
   declarations: [
@@ -57,7 +57,6 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
     DocumentTypeListComponent,
     LogsComponent,
     SettingsComponent,
-    SafePipe,
     NotFoundComponent,
     CorrespondentEditDialogComponent,
     DeleteDialogComponent,
@@ -92,7 +91,8 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
     FormsModule,
     ReactiveFormsModule,
     NgxFileDropModule,
-    InfiniteScrollModule
+    InfiniteScrollModule,
+    PdfViewerModule
   ],
   providers: [
     DatePipe,
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index 5a5563571..93aec64bc 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -14,14 +14,14 @@
             </svg>
             <span class="d-none d-lg-inline"> Download</span>
         </a>
-    
+
         <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.paperless__has_archive_version">
           <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
           <div class="dropdown-menu" ngbDropdownMenu>
             <a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a>
           </div>
         </div>
-    
+
       </div>
 
 
@@ -66,10 +66,8 @@
     </div>
 
     <div class="col-xl">
-        <object [data]="previewUrl | safe" type="application/pdf" width="100%" height="100%">
-            <p>Your browser does not support PDFs.
-                <a href="previewUrl">Download the PDF</a>.</p>
-        </object>
-
+      <div class="pdf-viewer-container">
+        <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="false"></pdf-viewer>
+      </div>
     </div>
 </div>
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.scss b/src-ui/src/app/components/document-detail/document-detail.component.scss
index e69de29bb..b4d720018 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.scss
+++ b/src-ui/src/app/components/document-detail/document-detail.component.scss
@@ -0,0 +1,8 @@
+.pdf-viewer-container {
+  height: calc(100vh - 160px);
+  top: 70px;
+  position: sticky;
+  padding: 10px;
+  background-color: gray;
+  overflow-y: scroll;
+}
diff --git a/src-ui/src/app/pipes/safe.pipe.spec.ts b/src-ui/src/app/pipes/safe.pipe.spec.ts
deleted file mode 100644
index 49ee0ad14..000000000
--- a/src-ui/src/app/pipes/safe.pipe.spec.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { SafePipe } from './safe.pipe';
-
-describe('SafePipe', () => {
-  it('create an instance', () => {
-    const pipe = new SafePipe();
-    expect(pipe).toBeTruthy();
-  });
-});
diff --git a/src-ui/src/app/pipes/safe.pipe.ts b/src-ui/src/app/pipes/safe.pipe.ts
deleted file mode 100644
index f2d77a72d..000000000
--- a/src-ui/src/app/pipes/safe.pipe.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core';
-import { DomSanitizer } from '@angular/platform-browser';
-
-@Pipe({
-  name: 'safe'
-})
-export class SafePipe implements PipeTransform {
-
-  constructor(private sanitizer: DomSanitizer) { }
-
-  transform(url) {
-    if (url == null) {
-      return this.sanitizer.bypassSecurityTrustResourceUrl("")
-    } else {
-      return this.sanitizer.bypassSecurityTrustResourceUrl(url);
-    }
-  }
-
-}
\ No newline at end of file

From 4f14e0f425957d52ecb213880f37691d575e4165 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sat, 12 Dec 2020 01:19:22 +0100
Subject: [PATCH 27/37] fixes #125

---
 src/documents/signals/handlers.py               | 12 ++++++++----
 src/documents/tests/test_management_retagger.py | 14 +++++++++++++-
 2 files changed, 21 insertions(+), 5 deletions(-)

diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 4fbbe8f8a..27aa37908 100755
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -7,6 +7,7 @@ from django.contrib.admin.models import ADDITION, LogEntry
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.db import models, DatabaseError
+from django.db.models import Q
 from django.dispatch import receiver
 from django.utils import timezone
 from filelock import FileLock
@@ -121,11 +122,14 @@ def set_tags(sender,
              classifier=None,
              replace=False,
              **kwargs):
+
     if replace:
-        document.tags.clear()
-        current_tags = set([])
-    else:
-        current_tags = set(document.tags.all())
+        document.tags.exclude(
+            Q(is_inbox_tag=True) |
+            (Q(match="") & ~Q(matching_algorithm=Tag.MATCH_AUTO))
+        ).delete()
+
+    current_tags = set(document.tags.all())
 
     matched_tags = matching.match_tags(document.content, classifier)
 
diff --git a/src/documents/tests/test_management_retagger.py b/src/documents/tests/test_management_retagger.py
index 2346b6527..2397b0cc8 100644
--- a/src/documents/tests/test_management_retagger.py
+++ b/src/documents/tests/test_management_retagger.py
@@ -14,6 +14,11 @@ class TestRetagger(DirectoriesMixin, TestCase):
 
         self.tag_first = Tag.objects.create(name="tag1", match="first", matching_algorithm=Tag.MATCH_ANY)
         self.tag_second = Tag.objects.create(name="tag2", match="second", matching_algorithm=Tag.MATCH_ANY)
+        self.tag_inbox = Tag.objects.create(name="test", is_inbox_tag=True)
+        self.tag_no_match = Tag.objects.create(name="test2")
+
+        self.d3.tags.add(self.tag_inbox)
+        self.d3.tags.add(self.tag_no_match)
 
         self.correspondent_first = Correspondent.objects.create(
             name="c1", match="first", matching_algorithm=Correspondent.MATCH_ANY)
@@ -38,7 +43,7 @@ class TestRetagger(DirectoriesMixin, TestCase):
 
         self.assertEqual(d_first.tags.count(), 1)
         self.assertEqual(d_second.tags.count(), 1)
-        self.assertEqual(d_unrelated.tags.count(), 0)
+        self.assertEqual(d_unrelated.tags.count(), 2)
 
         self.assertEqual(d_first.tags.first(), self.tag_first)
         self.assertEqual(d_second.tags.first(), self.tag_second)
@@ -56,3 +61,10 @@ class TestRetagger(DirectoriesMixin, TestCase):
 
         self.assertEqual(d_first.correspondent, self.correspondent_first)
         self.assertEqual(d_second.correspondent, self.correspondent_second)
+
+    def test_force_preserve_inbox(self):
+        call_command('document_retagger', '--tags', '--overwrite')
+
+        d_first, d_second, d_unrelated = self.get_updated_docs()
+
+        self.assertCountEqual([tag.id for tag in d_unrelated.tags.all()], [self.tag_inbox.id, self.tag_no_match.id])

From ebb39b13f089e94cf224530636b854eb5be40b4c Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sat, 12 Dec 2020 01:23:26 +0100
Subject: [PATCH 28/37] tests

---
 src/documents/tests/test_management_retagger.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/documents/tests/test_management_retagger.py b/src/documents/tests/test_management_retagger.py
index 2397b0cc8..2d2533341 100644
--- a/src/documents/tests/test_management_retagger.py
+++ b/src/documents/tests/test_management_retagger.py
@@ -67,4 +67,6 @@ class TestRetagger(DirectoriesMixin, TestCase):
 
         d_first, d_second, d_unrelated = self.get_updated_docs()
 
+        self.assertCountEqual([tag.id for tag in d_first.tags.all()], [self.tag_first.id])
+        self.assertCountEqual([tag.id for tag in d_second.tags.all()], [self.tag_second.id])
         self.assertCountEqual([tag.id for tag in d_unrelated.tags.all()], [self.tag_inbox.id, self.tag_no_match.id])

From bf9051e44dba70616007e45636c9a5cc47d415e1 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sat, 12 Dec 2020 02:06:43 +0100
Subject: [PATCH 29/37] made a serious mistake. fixed.

---
 src/documents/signals/handlers.py               | 6 +++---
 src/documents/tests/test_management_retagger.py | 8 +++++++-
 2 files changed, 10 insertions(+), 4 deletions(-)

diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 27aa37908..586897585 100755
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -124,9 +124,9 @@ def set_tags(sender,
              **kwargs):
 
     if replace:
-        document.tags.exclude(
-            Q(is_inbox_tag=True) |
-            (Q(match="") & ~Q(matching_algorithm=Tag.MATCH_AUTO))
+        Document.tags.through.objects.filter(document=document).exclude(
+            Q(tag__is_inbox_tag=True)).exclude(
+            Q(tag__match="") & ~Q(tag__matching_algorithm=Tag.MATCH_AUTO)
         ).delete()
 
     current_tags = set(document.tags.all())
diff --git a/src/documents/tests/test_management_retagger.py b/src/documents/tests/test_management_retagger.py
index 2d2533341..907a23d09 100644
--- a/src/documents/tests/test_management_retagger.py
+++ b/src/documents/tests/test_management_retagger.py
@@ -20,6 +20,7 @@ class TestRetagger(DirectoriesMixin, TestCase):
         self.d3.tags.add(self.tag_inbox)
         self.d3.tags.add(self.tag_no_match)
 
+
         self.correspondent_first = Correspondent.objects.create(
             name="c1", match="first", matching_algorithm=Correspondent.MATCH_ANY)
         self.correspondent_second = Correspondent.objects.create(
@@ -62,11 +63,16 @@ class TestRetagger(DirectoriesMixin, TestCase):
         self.assertEqual(d_first.correspondent, self.correspondent_first)
         self.assertEqual(d_second.correspondent, self.correspondent_second)
 
-    def test_force_preserve_inbox(self):
+    def test_overwrite_preserve_inbox(self):
+        self.d1.tags.add(self.tag_second)
+
         call_command('document_retagger', '--tags', '--overwrite')
 
         d_first, d_second, d_unrelated = self.get_updated_docs()
 
+        self.assertIsNotNone(Tag.objects.get(id=self.tag_second.id))
+
         self.assertCountEqual([tag.id for tag in d_first.tags.all()], [self.tag_first.id])
         self.assertCountEqual([tag.id for tag in d_second.tags.all()], [self.tag_second.id])
         self.assertCountEqual([tag.id for tag in d_unrelated.tags.all()], [self.tag_inbox.id, self.tag_no_match.id])
+

From beff45a8353cfcfe692aed51c294ecb1a2d25004 Mon Sep 17 00:00:00 2001
From: Michael Shamoon <4887959+nikonratm@users.noreply.github.com>
Date: Sat, 12 Dec 2020 08:43:03 -0800
Subject: [PATCH 30/37] Fix PDF column width layout issues

---
 .../document-detail/document-detail.component.html          | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index bec8a59a2..bdd1132fa 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -35,7 +35,7 @@
 
 
 <div class="row">
-    <div class="col-xl">
+    <div class="col-md-6 col-xl-4">
 
         <form [formGroup]='documentForm' (ngSubmit)="save()">
 
@@ -171,9 +171,9 @@
         </form>
     </div>
 
-    <div class="col-xl">
+    <div class="col-md-6 col-xl-8">
       <div class="pdf-viewer-container">
         <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="false"></pdf-viewer>
       </div>
     </div>
-</div>
\ No newline at end of file
+</div>

From f6a50ee7c6bbd9ebc1218aadd1ef73610f4ed549 Mon Sep 17 00:00:00 2001
From: Michael Shamoon <4887959+nikonratm@users.noreply.github.com>
Date: Sat, 12 Dec 2020 08:50:52 -0800
Subject: [PATCH 31/37] Bottom margin on columns for mobile stacking

---
 .../components/document-detail/document-detail.component.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index bdd1132fa..6f1aacdf5 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -35,7 +35,7 @@
 
 
 <div class="row">
-    <div class="col-md-6 col-xl-4">
+    <div class="col-md-6 col-xl-4 mb-4">
 
         <form [formGroup]='documentForm' (ngSubmit)="save()">
 
@@ -171,7 +171,7 @@
         </form>
     </div>
 
-    <div class="col-md-6 col-xl-8">
+    <div class="col-md-6 col-xl-8 mb-3">
       <div class="pdf-viewer-container">
         <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="false"></pdf-viewer>
       </div>

From a0631413d64a427ee682998569fd5fe79f3dc0de Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sat, 12 Dec 2020 18:25:15 +0100
Subject: [PATCH 32/37] fixes bauerj/paperless_app#23 and most of all other
 scanner apps out there.

---
 src/paperless_tesseract/parsers.py           | 24 ++++++++++++++++++++
 src/paperless_tesseract/tests/test_parser.py | 15 +++++++++++-
 2 files changed, 38 insertions(+), 1 deletion(-)

diff --git a/src/paperless_tesseract/parsers.py b/src/paperless_tesseract/parsers.py
index 1cf6a769c..80e200f27 100644
--- a/src/paperless_tesseract/parsers.py
+++ b/src/paperless_tesseract/parsers.py
@@ -110,6 +110,24 @@ class RasterisedDocumentParser(DocumentParser):
                 f"Error while getting DPI from image {image}: {e}")
             return None
 
+    def calculate_a4_dpi(self, image):
+        try:
+            with Image.open(image) as im:
+                width, height = im.size
+                # divide image width by A4 width (210mm) in inches.
+                dpi = int(width / (21 / 2.54))
+                self.log(
+                    'debug',
+                    f"Estimated DPI {dpi} based on image width {width}"
+                )
+                return dpi
+
+        except Exception as e:
+            self.log(
+                'warning',
+                f"Error while calculating DPI for image {image}: {e}")
+            return None
+
     def parse(self, document_path, mime_type):
         mode = settings.OCR_MODE
 
@@ -162,6 +180,7 @@ class RasterisedDocumentParser(DocumentParser):
 
         if self.is_image(mime_type):
             dpi = self.get_dpi(document_path)
+            a4_dpi = self.calculate_a4_dpi(document_path)
             if dpi:
                 self.log(
                     "debug",
@@ -170,6 +189,8 @@ class RasterisedDocumentParser(DocumentParser):
                 ocr_args['image_dpi'] = dpi
             elif settings.OCR_IMAGE_DPI:
                 ocr_args['image_dpi'] = settings.OCR_IMAGE_DPI
+            elif a4_dpi:
+                ocr_args['image_dpi'] = a4_dpi
             else:
                 raise ParseError(
                     f"Cannot produce archive PDF for image {document_path}, "
@@ -241,6 +262,9 @@ def strip_excess_whitespace(text):
 
 def get_text_from_pdf(pdf_file):
 
+    if not os.path.isfile(pdf_file):
+        return None
+
     with open(pdf_file, "rb") as f:
         try:
             pdf = pdftotext.PDF(f)
diff --git a/src/paperless_tesseract/tests/test_parser.py b/src/paperless_tesseract/tests/test_parser.py
index 8834ec755..7be176663 100644
--- a/src/paperless_tesseract/tests/test_parser.py
+++ b/src/paperless_tesseract/tests/test_parser.py
@@ -164,8 +164,21 @@ class TestParser(DirectoriesMixin, TestCase):
 
         self.assertRaises(ParseError, f)
 
+    @mock.patch("paperless_tesseract.parsers.ocrmypdf.ocr")
+    def test_image_calc_a4_dpi(self, m):
+        parser = RasterisedDocumentParser(None)
 
-    def test_image_no_dpi_fail(self):
+        parser.parse(os.path.join(self.SAMPLE_FILES, "simple-no-dpi.png"), "image/png")
+
+        m.assert_called_once()
+
+        args, kwargs = m.call_args
+
+        self.assertEqual(kwargs['image_dpi'], 62)
+
+    @mock.patch("paperless_tesseract.parsers.RasterisedDocumentParser.calculate_a4_dpi")
+    def test_image_dpi_fail(self, m):
+        m.return_value = None
         parser = RasterisedDocumentParser(None)
 
         def f():

From f5cc5fbaa30d24a50c6d6f187010b30e0ad75fd3 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sat, 12 Dec 2020 20:32:11 +0100
Subject: [PATCH 33/37] made the file renamer somewhat faster.

---
 src/documents/management/commands/document_renamer.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/documents/management/commands/document_renamer.py b/src/documents/management/commands/document_renamer.py
index 5d7d0d90c..745d2d03d 100644
--- a/src/documents/management/commands/document_renamer.py
+++ b/src/documents/management/commands/document_renamer.py
@@ -2,6 +2,7 @@ import logging
 
 import tqdm
 from django.core.management.base import BaseCommand
+from django.db.models.signals import post_save
 
 from documents.models import Document
 from ...mixins import Renderable
@@ -24,5 +25,4 @@ class Command(Renderable, BaseCommand):
         logging.getLogger().handlers[0].level = logging.ERROR
 
         for document in tqdm.tqdm(Document.objects.all()):
-            # Saving the document again will generate a new filename and rename
-            document.save()
+            post_save.send(Document, instance=document)

From 1c4d19198f245c33430b3599deeb266b0875c98a Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sat, 12 Dec 2020 22:56:44 +0100
Subject: [PATCH 34/37] a couple adjustments for the document viewer.

---
 .../document-detail/document-detail.component.html          | 6 +++---
 .../document-detail/document-detail.component.scss          | 2 --
 .../components/document-detail/document-detail.component.ts | 4 ++++
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index 6f1aacdf5..f9f6e57ef 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -35,7 +35,7 @@
 
 
 <div class="row">
-    <div class="col-md-6 col-xl-4 mb-4">
+    <div class="col mb-4">
 
         <form [formGroup]='documentForm' (ngSubmit)="save()">
 
@@ -172,8 +172,8 @@
     </div>
 
     <div class="col-md-6 col-xl-8 mb-3">
-      <div class="pdf-viewer-container">
-        <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="false"></pdf-viewer>
+      <div class="pdf-viewer-container" *ngIf="getContentType() == 'application/pdf'">
+        <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true"></pdf-viewer>
       </div>
     </div>
 </div>
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.scss b/src-ui/src/app/components/document-detail/document-detail.component.scss
index b4d720018..998653bab 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.scss
+++ b/src-ui/src/app/components/document-detail/document-detail.component.scss
@@ -2,7 +2,5 @@
   height: calc(100vh - 160px);
   top: 70px;
   position: sticky;
-  padding: 10px;
   background-color: gray;
-  overflow-y: scroll;
 }
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 329077693..c80a8b1ce 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
@@ -59,6 +59,10 @@ export class DocumentDetailComponent implements OnInit {
     private documentListViewService: DocumentListViewService,
     private titleService: Title) { }
 
+  getContentType() {
+    return this.metadata?.has_archive_version ? 'application/pdf' : this.metadata?.original_mime_type
+  }
+
   ngOnInit(): void {
     this.documentForm.valueChanges.subscribe(wow => {
       Object.assign(this.document, this.documentForm.value)

From 30853e963efd78c6bbd81e33b0b24879c0655cd1 Mon Sep 17 00:00:00 2001
From: rYR79435 <60985157+rYR79435@users.noreply.github.com>
Date: Sun, 13 Dec 2020 13:30:30 +0100
Subject: [PATCH 35/37] Open GitHub and Documentation links in a new tab

---
 src-ui/src/app/components/app-frame/app-frame.component.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

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 3f326afdd..1cedeefde 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
@@ -132,7 +132,7 @@
         </h6>
         <ul class="nav flex-column mb-2">
           <li class="nav-item">
-            <a class="nav-link" href="https://paperless-ng.readthedocs.io/en/latest/">
+            <a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://paperless-ng.readthedocs.io/en/latest/">
               <svg class="sidebaricon" fill="currentColor">
                 <use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
               </svg>
@@ -140,7 +140,7 @@
             </a>
           </li>
           <li class="nav-item">
-            <a class="nav-link" href="https://github.com/jonaswinkler/paperless-ng">
+            <a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng">
               <svg class="sidebaricon" fill="currentColor">
                 <use xlink:href="assets/bootstrap-icons.svg#link"/>
               </svg>

From 5bea5e75c0457ad957d6816530feabcb0bc7dad5 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sun, 13 Dec 2020 14:28:37 +0100
Subject: [PATCH 36/37] Refactored delete dialog into a more generic confirm
 dialog

---
 src-ui/src/app/app.module.ts                  |  4 +-
 .../confirm-dialog.component.html}            |  6 +--
 .../confirm-dialog.component.scss}            |  0
 .../confirm-dialog.component.spec.ts}         | 12 +++---
 .../confirm-dialog.component.ts               | 37 +++++++++++++++++++
 .../delete-dialog/delete-dialog.component.ts  | 31 ----------------
 .../document-detail.component.ts              | 13 ++++---
 .../generic-list/generic-list.component.ts    | 13 ++++---
 8 files changed, 64 insertions(+), 52 deletions(-)
 rename src-ui/src/app/components/common/{delete-dialog/delete-dialog.component.html => confirm-dialog/confirm-dialog.component.html} (67%)
 rename src-ui/src/app/components/common/{delete-dialog/delete-dialog.component.scss => confirm-dialog/confirm-dialog.component.scss} (100%)
 rename src-ui/src/app/components/common/{delete-dialog/delete-dialog.component.spec.ts => confirm-dialog/confirm-dialog.component.spec.ts} (52%)
 create mode 100644 src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.ts
 delete mode 100644 src-ui/src/app/components/common/delete-dialog/delete-dialog.component.ts

diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index 0ee36b478..a1ae10d14 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -16,7 +16,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 import { DatePipe } from '@angular/common';
 import { NotFoundComponent } from './components/not-found/not-found.component';
 import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component';
-import { DeleteDialogComponent } from './components/common/delete-dialog/delete-dialog.component';
+import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component';
 import { CorrespondentEditDialogComponent } from './components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
 import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
 import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
@@ -63,7 +63,7 @@ import { DocumentTitlePipe } from './pipes/document-title.pipe';
     SettingsComponent,
     NotFoundComponent,
     CorrespondentEditDialogComponent,
-    DeleteDialogComponent,
+    ConfirmDialogComponent,
     TagEditDialogComponent,
     DocumentTypeEditDialogComponent,
     TagComponent,
diff --git a/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.html
similarity index 67%
rename from src-ui/src/app/components/common/delete-dialog/delete-dialog.component.html
rename to src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.html
index 2de507549..53b613244 100644
--- a/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.html
+++ b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.html
@@ -5,10 +5,10 @@
       </button>
     </div>
     <div class="modal-body">
-      <p><b>{{message}}</b></p>
-      <p *ngIf="message2">{{message2}}</p>
+      <p *ngIf="messageBold"><b>{{messageBold}}</b></p>
+      <p *ngIf="message">{{message}}</p>
     </div>
     <div class="modal-footer">
       <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
-      <button type="button" class="btn btn-danger" (click)="deleteClicked.emit()">Delete</button>
+      <button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()">{{btnCaption}}</button>
     </div>
\ No newline at end of file
diff --git a/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.scss
similarity index 100%
rename from src-ui/src/app/components/common/delete-dialog/delete-dialog.component.scss
rename to src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.scss
diff --git a/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts
similarity index 52%
rename from src-ui/src/app/components/common/delete-dialog/delete-dialog.component.spec.ts
rename to src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts
index 33c7d6e88..fe08dc57a 100644
--- a/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.spec.ts
+++ b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.spec.ts
@@ -1,20 +1,20 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 
-import { DeleteDialogComponent } from './delete-dialog.component';
+import { ConfirmDialogComponent } from './confirm-dialog.component';
 
-describe('DeleteDialogComponent', () => {
-  let component: DeleteDialogComponent;
-  let fixture: ComponentFixture<DeleteDialogComponent>;
+describe('ConfirmDialogComponent', () => {
+  let component: ConfirmDialogComponent;
+  let fixture: ComponentFixture<ConfirmDialogComponent>;
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
-      declarations: [ DeleteDialogComponent ]
+      declarations: [ ConfirmDialogComponent ]
     })
     .compileComponents();
   });
 
   beforeEach(() => {
-    fixture = TestBed.createComponent(DeleteDialogComponent);
+    fixture = TestBed.createComponent(ConfirmDialogComponent);
     component = fixture.componentInstance;
     fixture.detectChanges();
   });
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
new file mode 100644
index 000000000..e207f4598
--- /dev/null
+++ b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.ts
@@ -0,0 +1,37 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+  selector: 'app-confirm-dialog',
+  templateUrl: './confirm-dialog.component.html',
+  styleUrls: ['./confirm-dialog.component.scss']
+})
+export class ConfirmDialogComponent implements OnInit {
+
+  constructor(public activeModal: NgbActiveModal) { }
+
+  @Output()
+  public confirmClicked = new EventEmitter()
+
+  @Input()
+  title = "Confirmation"
+
+  @Input()
+  messageBold
+
+  @Input()
+  message
+
+  @Input()
+  btnClass = "btn-primary"
+
+  @Input()
+  btnCaption = "Confirm"
+
+  ngOnInit(): void {
+  }
+
+  cancelClicked() {
+    this.activeModal.close()
+  }
+}
diff --git a/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.ts b/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.ts
deleted file mode 100644
index 20114c78c..000000000
--- a/src-ui/src/app/components/common/delete-dialog/delete-dialog.component.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-
-@Component({
-  selector: 'app-delete-dialog',
-  templateUrl: './delete-dialog.component.html',
-  styleUrls: ['./delete-dialog.component.scss']
-})
-export class DeleteDialogComponent implements OnInit {
-
-  constructor(public activeModal: NgbActiveModal) { }
-
-  @Output()
-  public deleteClicked = new EventEmitter()
-
-  @Input()
-  title = "Delete confirmation"
-
-  @Input()
-  message = "Do you really want to delete this?"
-
-  @Input()
-  message2
-
-  ngOnInit(): void {
-  }
-
-  cancelClicked() {
-    this.activeModal.close()
-  }
-}
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 c80a8b1ce..4aac9c769 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
@@ -13,7 +13,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
 import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
 import { DocumentService } from 'src/app/services/rest/document.service';
 import { environment } from 'src/environments/environment';
-import { DeleteDialogComponent } from '../common/delete-dialog/delete-dialog.component';
+import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component';
 import { CorrespondentEditDialogComponent } from '../manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
 import { DocumentTypeEditDialogComponent } from '../manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
 
@@ -155,10 +155,13 @@ export class DocumentDetailComponent implements OnInit {
   }
 
   delete() {
-    let modal = this.modalService.open(DeleteDialogComponent, {backdrop: 'static'})
-    modal.componentInstance.message = `Do you really want to delete document '${this.document.title}'?`
-    modal.componentInstance.message2 = `The files for this document will be deleted permanently. This operation cannot be undone.`
-    modal.componentInstance.deleteClicked.subscribe(() => {
+    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.btnClass = "btn-danger"
+    modal.componentInstance.btnCaption = "Delete document"
+    modal.componentInstance.confirmClicked.subscribe(() => {
       this.documentsService.delete(this.document).subscribe(() => {
         modal.close()  
         this.close()
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 d5477d010..59a5f09ed 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
@@ -4,7 +4,7 @@ import { MatchingModel, MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/mat
 import { ObjectWithId } from 'src/app/data/object-with-id';
 import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive';
 import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service';
-import { DeleteDialogComponent } from '../../common/delete-dialog/delete-dialog.component';
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component';
 
 @Directive()
 export abstract class GenericListComponent<T extends ObjectWithId> implements OnInit {
@@ -88,10 +88,13 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On
   }
 
   openDeleteDialog(object: T) {
-    var activeModal = this.modalService.open(DeleteDialogComponent, {backdrop: 'static'})
-    activeModal.componentInstance.message = `Do you really want to delete ${this.getObjectName(object)}?`
-    activeModal.componentInstance.message2 = "Associated documents will not be deleted."
-    activeModal.componentInstance.deleteClicked.subscribe(() => {
+    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.btnClass = "btn-danger"
+    activeModal.componentInstance.btnCaption = "Delete"
+    activeModal.componentInstance.confirmPressed.subscribe(() => {
       this.service.delete(object).subscribe(_ => {
         activeModal.close()
         this.reloadData()

From 3089b049cfaf8bbe1628671531ef185001db54e7 Mon Sep 17 00:00:00 2001
From: jonaswinkler <jonas.winkler@jpwinkler.de>
Date: Sun, 13 Dec 2020 14:56:44 +0100
Subject: [PATCH 37/37] refactored metadata views

---
 src-ui/src/app/app.module.ts                  |  4 +-
 .../document-detail.component.html            | 49 +------------------
 .../metadata-collapse.component.html          | 23 +++++++++
 .../metadata-collapse.component.scss          |  0
 .../metadata-collapse.component.spec.ts       | 25 ++++++++++
 .../metadata-collapse.component.ts            | 23 +++++++++
 6 files changed, 76 insertions(+), 48 deletions(-)
 create mode 100644 src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.html
 create mode 100644 src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.scss
 create mode 100644 src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts
 create mode 100644 src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts

diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index a1ae10d14..5b92364d2 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -49,6 +49,7 @@ import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-w
 import { YesNoPipe } from './pipes/yes-no.pipe';
 import { FileSizePipe } from './pipes/file-size.pipe';
 import { DocumentTitlePipe } from './pipes/document-title.pipe';
+import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component';
 
 @NgModule({
   declarations: [
@@ -89,7 +90,8 @@ import { DocumentTitlePipe } from './pipes/document-title.pipe';
     WelcomeWidgetComponent,
     YesNoPipe,
     FileSizePipe,
-    DocumentTitlePipe
+    DocumentTitlePipe,
+    MetadataCollapseComponent
   ],
   imports: [
     BrowserModule,
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index f9f6e57ef..c0114f709 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -110,53 +110,8 @@
                             </tbody>
                         </table>
 
-                        <h6 *ngIf="metadata?.original_metadata.length > 0">
-                            <button type="button" class="btn btn-outline-secondary btn-sm mr-2"
-                                (click)="expandOriginalMetadata = !expandOriginalMetadata" aria-controls="collapseExample">
-                                <svg class="buttonicon" fill="currentColor" *ngIf="!expandOriginalMetadata">
-                                    <use xlink:href="assets/bootstrap-icons.svg#caret-down" />
-                                </svg>
-                                <svg class="buttonicon" fill="currentColor" *ngIf="expandOriginalMetadata">
-                                    <use xlink:href="assets/bootstrap-icons.svg#caret-up" />
-                                </svg>
-                            </button>
-                            Original document metadata
-                        </h6>
-
-                        <div #collapse="ngbCollapse" [(ngbCollapse)]="!expandOriginalMetadata">
-                            <table class="table table-borderless">
-                                <tbody>
-                                    <tr *ngFor="let m of metadata?.original_metadata">
-                                        <td>{{m.prefix}}:{{m.key}}</td>
-                                        <td>{{m.value}}</td>
-                                    </tr>
-                                </tbody>
-                            </table>
-                        </div>
-
-                        <h6 *ngIf="metadata?.has_archive_version && metadata?.archive_metadata.length > 0">
-                            <button type="button" class="btn btn-outline-secondary btn-sm mr-2"
-                                (click)="expandArchivedMetadata = !expandArchivedMetadata" aria-controls="collapseExample">
-                                <svg class="buttonicon" fill="currentColor" *ngIf="!expandArchivedMetadata">
-                                    <use xlink:href="assets/bootstrap-icons.svg#caret-down" />
-                                </svg>
-                                <svg class="buttonicon" fill="currentColor" *ngIf="expandArchivedMetadata">
-                                    <use xlink:href="assets/bootstrap-icons.svg#caret-up" />
-                                </svg>
-                            </button>
-                            Archived document metadata
-                        </h6>
-
-                        <div #collapse="ngbCollapse" [(ngbCollapse)]="!expandArchivedMetadata">
-                            <table class="table table-borderless">
-                                <tbody>
-                                    <tr *ngFor="let m of metadata?.archive_metadata">
-                                        <td>{{m.prefix}}:{{m.key}}</td>
-                                        <td>{{m.value}}</td>
-                                    </tr>
-                                </tbody>
-                            </table>
-                        </div>
+                        <app-metadata-collapse title="Original document metadata" [metadata]="metadata.original_metadata" *ngIf="metadata?.original_metadata.length > 0"></app-metadata-collapse>
+                        <app-metadata-collapse title="Archived document metadata" [metadata]="metadata.archive_metadata" *ngIf="metadata?.archive_metadata.length > 0"></app-metadata-collapse>
 
                     </ng-template>
                 </li>
diff --git a/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.html b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.html
new file mode 100644
index 000000000..e8fda1d0b
--- /dev/null
+++ b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.html
@@ -0,0 +1,23 @@
+<h6>
+  <button type="button" class="btn btn-outline-secondary btn-sm mr-2"
+      (click)="expand = !expand">
+      <svg class="buttonicon" fill="currentColor" *ngIf="!expand">
+          <use xlink:href="assets/bootstrap-icons.svg#caret-down" />
+      </svg>
+      <svg class="buttonicon" fill="currentColor" *ngIf="expand">
+          <use xlink:href="assets/bootstrap-icons.svg#caret-up" />
+      </svg>
+  </button>
+  {{title}}
+</h6>
+
+<div #collapse="ngbCollapse" [(ngbCollapse)]="!expand">
+  <table class="table table-borderless">
+      <tbody>
+          <tr *ngFor="let m of metadata">
+              <td>{{m.prefix}}:{{m.key}}</td>
+              <td>{{m.value}}</td>
+          </tr>
+      </tbody>
+  </table>
+</div>
\ No newline at end of file
diff --git a/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.scss b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts
new file mode 100644
index 000000000..2bd96760b
--- /dev/null
+++ b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.spec.ts
@@ -0,0 +1,25 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MetadataCollapseComponent } from './metadata-collapse.component';
+
+describe('MetadataCollapseComponent', () => {
+  let component: MetadataCollapseComponent;
+  let fixture: ComponentFixture<MetadataCollapseComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ MetadataCollapseComponent ]
+    })
+    .compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MetadataCollapseComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
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
new file mode 100644
index 000000000..160274e41
--- /dev/null
+++ b/src-ui/src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts
@@ -0,0 +1,23 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+@Component({
+  selector: 'app-metadata-collapse',
+  templateUrl: './metadata-collapse.component.html',
+  styleUrls: ['./metadata-collapse.component.scss']
+})
+export class MetadataCollapseComponent implements OnInit {
+
+  constructor() { }
+
+  expand = false
+
+  @Input()
+  metadata
+
+  @Input()
+  title = "Metadata"
+
+  ngOnInit(): void {
+  }
+
+}