mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-01 18:37:42 -05:00
Compare commits
205 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
aa714bded3 | ||
![]() |
ecfae9dadd | ||
![]() |
69c04a209a | ||
![]() |
e4ec52ed29 | ||
![]() |
50aedc1094 | ||
![]() |
062f8e5a73 | ||
![]() |
c813c02025 | ||
![]() |
28f45d8f15 | ||
![]() |
8e339789fa | ||
![]() |
dec17a3b9b | ||
![]() |
6b60501dc7 | ||
![]() |
0e78f32009 | ||
![]() |
b13ec571f8 | ||
![]() |
9f5e6d1969 | ||
![]() |
b2e0a8c884 | ||
![]() |
e47b105185 | ||
![]() |
8bd82f5c69 | ||
![]() |
e528a587cc | ||
![]() |
1a526ac31e | ||
![]() |
22e56f09ba | ||
![]() |
af0eafbb3b | ||
![]() |
8e57d4e791 | ||
![]() |
b2a9cf4709 | ||
![]() |
beffde1051 | ||
![]() |
71c58c4b05 | ||
![]() |
02e67d25b4 | ||
![]() |
d208ab1e12 | ||
![]() |
55075619c1 | ||
![]() |
7dce57b9f7 | ||
![]() |
a0c74025e3 | ||
![]() |
31bea6a361 | ||
![]() |
5894060dc5 | ||
![]() |
b787983e42 | ||
![]() |
7e0aa7136a | ||
![]() |
56204933b0 | ||
![]() |
49be87fe37 | ||
![]() |
30185d560c | ||
![]() |
6e614eda5e | ||
![]() |
164755c755 | ||
![]() |
6d39dfeb3b | ||
![]() |
999b36473c | ||
![]() |
ff71b04848 | ||
![]() |
67d03c11b9 | ||
![]() |
533be7e96e | ||
![]() |
4ed56e4603 | ||
![]() |
45848f5e34 | ||
![]() |
cf619d9d31 | ||
![]() |
de87efc291 | ||
![]() |
889fe5890d | ||
![]() |
958acd8a36 | ||
![]() |
381a503947 | ||
![]() |
b7126030d1 | ||
![]() |
13d934dc6e | ||
![]() |
926e746005 | ||
![]() |
94c07839a4 | ||
![]() |
277e668e07 | ||
![]() |
10440ec820 | ||
![]() |
02c1d496d6 | ||
![]() |
98ab79ad5a | ||
![]() |
e1fef59e82 | ||
![]() |
32201dd034 | ||
![]() |
670b6d3629 | ||
![]() |
54d90a4c4b | ||
![]() |
9bfc92cf79 | ||
![]() |
a12ec00827 | ||
![]() |
8cc0336338 | ||
![]() |
bad7caa8b9 | ||
![]() |
251fc582e9 | ||
![]() |
245af65841 | ||
![]() |
ee7492cf52 | ||
![]() |
8e5c2a2b14 | ||
![]() |
1343e4c99f | ||
![]() |
89cb1211e7 | ||
![]() |
04bb7d4893 | ||
![]() |
1fafb9ace6 | ||
![]() |
ae51619243 | ||
![]() |
7ac101d84e | ||
![]() |
2c18f6268b | ||
![]() |
7d212f6e80 | ||
![]() |
3d8cd0f0d6 | ||
![]() |
3f719a21e0 | ||
![]() |
7a75d3f41b | ||
![]() |
1ddad84985 | ||
![]() |
bcdbc975d6 | ||
![]() |
a61ea3555a | ||
![]() |
2de546fd52 | ||
![]() |
d6894d3c64 | ||
![]() |
bb1725c7dd | ||
![]() |
6f684f8070 | ||
![]() |
7712230300 | ||
![]() |
7e4c5af158 | ||
![]() |
3089b049cf | ||
![]() |
5bea5e75c0 | ||
![]() |
30853e963e | ||
![]() |
1379c039b8 | ||
![]() |
37c21e518d | ||
![]() |
e215e11417 | ||
![]() |
1c4d19198f | ||
![]() |
e77be6a2ed | ||
![]() |
1fdf1ef337 | ||
![]() |
f5cc5fbaa3 | ||
![]() |
a0631413d6 | ||
![]() |
8ce4434ba9 | ||
![]() |
f6a50ee7c6 | ||
![]() |
beff45a835 | ||
![]() |
dfa1f29809 | ||
![]() |
2a57f9d6e5 | ||
![]() |
02871e1e22 | ||
![]() |
9cd40e96f4 | ||
![]() |
bf9051e44d | ||
![]() |
ebb39b13f0 | ||
![]() |
4f14e0f425 | ||
![]() |
e7cb358536 | ||
![]() |
1aa76f8483 | ||
![]() |
de5d360d52 | ||
![]() |
0b7ffa31d1 | ||
![]() |
b452816a29 | ||
![]() |
a37796d0cf | ||
![]() |
0b4c860354 | ||
![]() |
a4a08aa667 | ||
![]() |
c24bfd4d2b | ||
![]() |
ed480f62e3 | ||
![]() |
66aa7319ab | ||
![]() |
4146955f4a | ||
![]() |
57504b7ee6 | ||
![]() |
364df5c050 | ||
![]() |
f83185bfe4 | ||
![]() |
db02d68a5a | ||
![]() |
9a18954686 | ||
![]() |
edcb62476b | ||
![]() |
3a82b7806a | ||
![]() |
defa80d05a | ||
![]() |
2f7bb01f34 | ||
![]() |
25e1177198 | ||
![]() |
ab8a1cfded | ||
![]() |
fa5121082d | ||
![]() |
48e785f7b6 | ||
![]() |
0cc22017de | ||
![]() |
3584f732a7 | ||
![]() |
476beacd7f | ||
![]() |
69c6d68219 | ||
![]() |
24d8a50f01 | ||
![]() |
2df1894683 | ||
![]() |
3f03cbf66c | ||
![]() |
b3daf0efc3 | ||
![]() |
46c0ab943f | ||
![]() |
2b57b80656 | ||
![]() |
0b1b9de3cc | ||
![]() |
20c46278dc | ||
![]() |
70cbdbf23b | ||
![]() |
6003122b06 | ||
![]() |
8ca97924be | ||
![]() |
2be0ba9f72 | ||
![]() |
f0d86130ec | ||
![]() |
ed236460b5 | ||
![]() |
a4f7c5ddcb | ||
![]() |
4fbb814e5b | ||
![]() |
0f635d1bb2 | ||
![]() |
0d48aea308 | ||
![]() |
06a3fff2bc | ||
![]() |
c28f19c9cf | ||
![]() |
da87542a52 | ||
![]() |
23ba3be68f | ||
![]() |
f3fd0fcf72 | ||
![]() |
72706a335d | ||
![]() |
0a0d462938 | ||
![]() |
74a99cf330 | ||
![]() |
5753c83618 | ||
![]() |
550a74347c | ||
![]() |
e428a8a008 | ||
![]() |
001ab88fff | ||
![]() |
d3cf85b9e9 | ||
![]() |
91c722feff | ||
![]() |
871e22e3a3 | ||
![]() |
bf3b2249c5 | ||
![]() |
6613104b4f | ||
![]() |
0028fde2fd | ||
![]() |
ad527fe97c | ||
![]() |
9da11f29c7 | ||
![]() |
c240fa1883 | ||
![]() |
bb33ac5e9e | ||
![]() |
30f200ad39 | ||
![]() |
5321ff1f20 | ||
![]() |
c4a939dbcc | ||
![]() |
3f05fe45bb | ||
![]() |
dfd844124d | ||
![]() |
d4febbc40f | ||
![]() |
dc36e8566a | ||
![]() |
87fa118de0 | ||
![]() |
35c3d5c0b5 | ||
![]() |
9e46afafd7 | ||
![]() |
56acd4f320 | ||
![]() |
2bbeb8ffe0 | ||
![]() |
5e188c0203 | ||
![]() |
8b637214b4 | ||
![]() |
eede5595e9 | ||
![]() |
c1fc8b2dac | ||
![]() |
28622d700d | ||
![]() |
a079c310b4 | ||
![]() |
278f6da16a | ||
![]() |
45e39d04ae | ||
![]() |
e46353cee8 | ||
![]() |
65816a434c | ||
![]() |
e3104d34fa | ||
![]() |
891bd2de7f |
@@ -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
|
||||
|
2
Pipfile
2
Pipfile
@@ -19,6 +19,7 @@ django-extensions = "*"
|
||||
django-filter = "~=2.4.0"
|
||||
django-q = "~=1.3.4"
|
||||
djangorestframework = "~=3.12.2"
|
||||
filelock = "*"
|
||||
fuzzywuzzy = "*"
|
||||
gunicorn = "*"
|
||||
imap-tools = "*"
|
||||
@@ -26,6 +27,7 @@ langdetect = "*"
|
||||
pdftotext = "*"
|
||||
pathvalidate = "*"
|
||||
pillow = "*"
|
||||
pikepdf = "*"
|
||||
python-gnupg = "*"
|
||||
python-dotenv = "*"
|
||||
python-dateutil = "*"
|
||||
|
39
Pipfile.lock
generated
39
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "b10db53eb22d917723aa6107ff0970dc4e2aa886ee03d3ae08a994a856d57986"
|
||||
"sha256": "3d576f289958226a7583e4c471c7f8c11bff6933bf093185f623cfb381a92412"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -197,6 +197,14 @@
|
||||
"index": "pypi",
|
||||
"version": "==3.12.2"
|
||||
},
|
||||
"filelock": {
|
||||
"hashes": [
|
||||
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
|
||||
"sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.12"
|
||||
},
|
||||
"fuzzywuzzy": {
|
||||
"hashes": [
|
||||
"sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8",
|
||||
@@ -425,7 +433,7 @@
|
||||
"sha256:fe0ca120e3347c851c34a91041d574f3c588d832023906d8ae18d66d042e8a52",
|
||||
"sha256:fe8e0152672f24d8bfdecc725f97e9013f2de1b41849150959526ca3562bd3ef"
|
||||
],
|
||||
"markers": "python_version < '3.9'",
|
||||
"index": "pypi",
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pillow": {
|
||||
@@ -858,10 +866,10 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
|
||||
"sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
|
||||
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
||||
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
||||
],
|
||||
"version": "==2020.11.8"
|
||||
"version": "==2020.12.5"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
@@ -961,17 +969,18 @@
|
||||
},
|
||||
"faker": {
|
||||
"hashes": [
|
||||
"sha256:7bca5b074299ac6532be2f72979e6793f1a2403ca8105cb4cf0b385a964469c4",
|
||||
"sha256:fb21a76064847561033d8cab1cfd11af436ddf2c6fe72eb51b3cda51dff86bdc"
|
||||
"sha256:1fcb415562ee6e2395b041e85fa6901d4708d30b84d54015226fa754ed0822c3",
|
||||
"sha256:e8beccb398ee9b8cc1a91d9295121d66512b6753b4846eb1e7370545d46b3311"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==5.0.0"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.0.1"
|
||||
},
|
||||
"filelock": {
|
||||
"hashes": [
|
||||
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
|
||||
"sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.12"
|
||||
},
|
||||
"idna": {
|
||||
@@ -1100,11 +1109,11 @@
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0",
|
||||
"sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773"
|
||||
"sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716",
|
||||
"sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==2.7.2"
|
||||
"version": "==2.7.3"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
@@ -1313,11 +1322,11 @@
|
||||
},
|
||||
"virtualenv": {
|
||||
"hashes": [
|
||||
"sha256:07cff122e9d343140366055f31be4dcd61fd598c69d11cd33a9d9c8df4546dd7",
|
||||
"sha256:e0aac7525e880a429764cefd3aaaff54afb5d9f25c82627563603f5d7de5a6e5"
|
||||
"sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c",
|
||||
"sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.2.1"
|
||||
"version": "==20.2.2"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
|
26
README.md
26
README.md
@@ -7,7 +7,7 @@
|
||||
|
||||
[Paperless](https://github.com/the-paperless-project/paperless) is an application by Daniel Quinn and others that indexes your scanned documents and allows you to easily search for documents and store metadata alongside your documents.
|
||||
|
||||
Paperless-ng is a fork of the original project, adding a new interface and many other changes under the hood. For a detailed list of changes, see below.
|
||||
Paperless-ng is a fork of the original project, adding a new interface and many other changes under the hood. For a detailed list of changes, have a look at the changelog in the documentation.
|
||||
|
||||
This project is still in development and some things may not work as expected.
|
||||
|
||||
@@ -15,11 +15,13 @@ This project is still in development and some things may not work as expected.
|
||||
|
||||
Paperless does not control your scanner, it only helps you deal with what your scanner produces.
|
||||
|
||||
1. Buy a document scanner that can write to a place on your network. If you need some inspiration, have a look at the [scanner recommendations](https://paperless-ng.readthedocs.io/en/latest/scanners.html) page.
|
||||
2. Set it up to "scan to FTP" or something similar. It should be able to push scanned images to a server without you having to do anything. Of course if your scanner doesn't know how to automatically upload the file somewhere, you can always do that manually. Paperless doesn't care how the documents get into its local consumption directory.
|
||||
3. Have the target server run the Paperless consumption script to OCR the file and index it into a local database.
|
||||
4. Use the web frontend to sift through the database and find what you want.
|
||||
5. Download the PDF you need/want via the web interface and do whatever you like with it. You can even print it and send it as if it's the original. In most cases, no one will care or notice.
|
||||
1. Buy a document scanner that can write to a place on your network. If you need some inspiration, have a look at the [scanner recommendations](https://paperless-ng.readthedocs.io/en/latest/scanners.html) page. Set it up to "scan to FTP" or something similar. It should be able to push scanned images to a server without you having to do anything. Of course if your scanner doesn't know how to automatically upload the file somewhere, you can always do that manually. Paperless doesn't care how the documents get into its local consumption directory.
|
||||
|
||||
- Alternatively, you can use any of the mobile scanning apps out there. We have an app that allows you to share documents with paperless, if you're on Android. See the section on affiliated projects.
|
||||
|
||||
2. Wait for paperless to process your files. OCR is expensive, and depending on the power of your machine, this might take a bit of time.
|
||||
3. Use the web frontend to sift through the database and find what you want.
|
||||
4. Download the PDF you need/want via the web interface and do whatever you like with it. You can even print it and send it as if it's the original. In most cases, no one will care or notice.
|
||||
|
||||
Here's what you get:
|
||||
|
||||
@@ -28,6 +30,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.
|
||||
@@ -50,7 +53,6 @@ For a complete list of changes from paperless, check out the [changelog](https:/
|
||||
|
||||
- Make the front end nice (except mobile).
|
||||
- Test coverage at 90%.
|
||||
- Store archived documents with an embedded OCR text layer, while keeping originals available. Making good progress in the `feature-ocrmypdf` branch.
|
||||
- Fix whatever bugs I and you find.
|
||||
|
||||
## Roadmap for versions beyond 1.0
|
||||
@@ -77,7 +79,7 @@ The recommended way to deploy paperless is docker-compose. Don't clone the repos
|
||||
|
||||
Read the [documentation](https://paperless-ng.readthedocs.io/en/latest/setup.html#installation) on how to get started.
|
||||
|
||||
Alternatively, you can install the dependencies and setup apache and a database server yourself. The documenation has information about the individual components of paperless that you need to take care of.
|
||||
Alternatively, you can install the dependencies and setup apache and a database server yourself. The documenation has a step by step guide on how to do it.
|
||||
|
||||
# Migrating to paperless-ng
|
||||
|
||||
@@ -101,13 +103,15 @@ If you want to implement something big: Please start a discussion about that in
|
||||
|
||||
Paperless has been around a while now, and people are starting to build stuff on top of it. If you're one of those people, we can add your project to this list:
|
||||
|
||||
* [Paperless App](https://github.com/bauerj/paperless_app): An Android/iOS app for Paperless. We're working on making this compatible.
|
||||
* [Paperless App](https://github.com/bauerj/paperless_app): An Android/iOS app for Paperless. Updated to work with paperless-ng.
|
||||
* [Paperless Share](https://github.com/qcasey/paperless_share). Share any files from your Android application with paperless. Very simple, but works with all of the mobile scanning apps out there that allow you to share scanned documents.
|
||||
|
||||
These projects also exist, but their status and compatibility with paperless-ng is unknown.
|
||||
|
||||
* [Paperless Desktop](https://github.com/thomasbrueggemann/paperless-desktop): A desktop UI for your Paperless installation. Runs on Mac, Linux, and Windows.
|
||||
* [ansible-role-paperless](https://github.com/ovv/ansible-role-paperless): An easy way to get Paperless running via Ansible.
|
||||
* [paperless-cli](https://github.com/stgarf/paperless-cli): A golang command line binary to interact with a Paperless instance.
|
||||
|
||||
Compatibility with Paperless-ng is unknown.
|
||||
|
||||
# Important Note
|
||||
|
||||
Document scanners are typically used to scan sensitive documents. Things like your social insurance number, tax records, invoices, etc. Everything is stored in the clear without encryption by default (it needs to be searchable, so if someone has ideas on how to do that on encrypted data, I'm all ears). This means that Paperless should never be run on an untrusted host. Instead, I recommend that if you do want to use it, run it locally on a server in your own home.
|
||||
|
@@ -23,8 +23,14 @@ wait_for_postgres() {
|
||||
echo "Waiting for PostgreSQL to start..."
|
||||
|
||||
host="${PAPERLESS_DBHOST}"
|
||||
port="${PAPERLESS_DBPORT}"
|
||||
|
||||
while !</dev/tcp/$host/5432 ;
|
||||
if [[ -z $port ]] ;
|
||||
then
|
||||
port="5432"
|
||||
fi
|
||||
|
||||
while !</dev/tcp/$host/$port ;
|
||||
do
|
||||
|
||||
if [ $attempt_num -eq $max_attempts ]
|
||||
@@ -113,13 +119,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
|
||||
|
@@ -15,7 +15,7 @@ services:
|
||||
POSTGRES_PASSWORD: paperless
|
||||
|
||||
webserver:
|
||||
image: jonaswinkler/paperless-ng:0.9.5
|
||||
image: jonaswinkler/paperless-ng:0.9.7
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
|
@@ -5,7 +5,7 @@ services:
|
||||
restart: always
|
||||
|
||||
webserver:
|
||||
image: jonaswinkler/paperless-ng:0.9.5
|
||||
image: jonaswinkler/paperless-ng:0.9.7
|
||||
restart: always
|
||||
depends_on:
|
||||
- broker
|
||||
|
@@ -18,6 +18,7 @@ RUN apt-get update \
|
||||
libmagic-dev \
|
||||
libpoppler-cpp-dev \
|
||||
libpq-dev \
|
||||
libqpdf-dev \
|
||||
libxml2 \
|
||||
optipng \
|
||||
pngquant \
|
||||
@@ -34,7 +35,7 @@ RUN apt-get update \
|
||||
zlib1g \
|
||||
&& pip3 install --upgrade supervisor setuptools \
|
||||
&& pip install --no-cache-dir -r requirements.txt \
|
||||
&& apt-get -y purge build-essential \
|
||||
&& apt-get -y purge build-essential libqpdf-dev \
|
||||
&& apt-get -y autoremove --purge \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir /var/log/supervisord /var/run/supervisord
|
||||
|
@@ -119,8 +119,11 @@ Updating paperless without docker
|
||||
|
||||
After grabbing the new release and unpacking the contents, do the following:
|
||||
|
||||
1. Update python requirements. Paperless uses
|
||||
`Pipenv`_ for managing dependencies:
|
||||
1. Update dependencies. New paperless version may require additional
|
||||
dependencies. The dependencies required are listed in the section about
|
||||
:ref:`bare metal installations <setup-bare_metal>`.
|
||||
|
||||
2. Update python requirements. If you use Pipenv, this is done with the following steps.
|
||||
|
||||
.. code:: shell-session
|
||||
|
||||
@@ -132,14 +135,14 @@ After grabbing the new release and unpacking the contents, do the following:
|
||||
This creates a new virtual environment (or uses your existing environment)
|
||||
and installs all dependencies into it.
|
||||
|
||||
2. Collect static files.
|
||||
3. Collect static files.
|
||||
|
||||
.. code:: shell-session
|
||||
|
||||
$ cd src
|
||||
$ pipenv run python3 manage.py collectstatic --clear
|
||||
|
||||
3. Migrate the database.
|
||||
4. Migrate the database.
|
||||
|
||||
.. code:: shell-session
|
||||
|
||||
@@ -153,14 +156,14 @@ Management utilities
|
||||
Paperless comes with some management commands that perform various maintenance
|
||||
tasks on your paperless instance. You can invoke these commands either by
|
||||
|
||||
.. code:: bash
|
||||
.. code:: shell-session
|
||||
|
||||
$ cd /path/to/paperless
|
||||
$ docker-compose run --rm webserver <command> <arguments>
|
||||
|
||||
or
|
||||
|
||||
.. code:: bash
|
||||
.. code:: shell-session
|
||||
|
||||
$ cd /path/to/paperless/src
|
||||
$ pipenv run python manage.py <command> <arguments>
|
||||
@@ -366,7 +369,7 @@ is specified, the archiver will only process that document.
|
||||
.. note::
|
||||
|
||||
Some documents will cause errors and cannot be converted into PDF/A documents,
|
||||
such as encrypted PDF documents. The archiver will skip over these Documents
|
||||
such as encrypted PDF documents. The archiver will skip over these documents
|
||||
each time it sees them.
|
||||
|
||||
.. _utilities-encyption:
|
||||
|
@@ -263,10 +263,10 @@ using the identifier which it has assigned to each document. You will end up get
|
||||
files like ``0000123.pdf`` in your media directory. This isn't necessarily a bad
|
||||
thing, because you normally don't have to access these files manually. However, if
|
||||
you wish to name your files differently, you can do that by adjusting the
|
||||
``PAPERLESS_FILENAME_FORMAT`` settings variable.
|
||||
``PAPERLESS_FILENAME_FORMAT`` configuration option.
|
||||
|
||||
This variable allows you to configure the filename (folders are allowed!) using
|
||||
placeholders. For example, setting
|
||||
This variable allows you to configure the filename (folders are allowed) using
|
||||
placeholders. For example, configuring this to
|
||||
|
||||
.. code:: bash
|
||||
|
||||
@@ -277,17 +277,16 @@ will create a directory structure as follows:
|
||||
.. code::
|
||||
|
||||
2019/
|
||||
my_bank/
|
||||
statement-january-0000001.pdf
|
||||
statement-february-0000002.pdf
|
||||
My bank/
|
||||
Statement January.pdf
|
||||
Statement February.pdf
|
||||
2020/
|
||||
my_bank/
|
||||
statement-january-0000003.pdf
|
||||
shoe_store/
|
||||
my_new_shoes-0000004.pdf
|
||||
|
||||
Paperless appends the unique identifier of each document to the filename. This
|
||||
avoids filename clashes.
|
||||
My bank/
|
||||
Statement January.pdf
|
||||
Letter.pdf
|
||||
Letter_01.pdf
|
||||
Shoe store/
|
||||
My new shoes.pdf
|
||||
|
||||
.. danger::
|
||||
|
||||
@@ -298,6 +297,8 @@ avoids filename clashes.
|
||||
Paperless provides the following placeholders withing filenames:
|
||||
|
||||
* ``{correspondent}``: The name of the correspondent, or "none".
|
||||
* ``{document_type}``: The name of the document type, or "none".
|
||||
* ``{tag_list}``: A comma separated list of all tags assigned to the document.
|
||||
* ``{title}``: The title of the document.
|
||||
* ``{created}``: The full date and time the document was created.
|
||||
* ``{created_year}``: Year created only.
|
||||
@@ -307,10 +308,15 @@ Paperless provides the following placeholders withing filenames:
|
||||
* ``{added_year}``: Year added only.
|
||||
* ``{added_month}``: Month added only (number 1-12).
|
||||
* ``{added_day}``: Day added only (number 1-31).
|
||||
* ``{tags}``: I don't know how this works. Look at the source.
|
||||
|
||||
Paperless will convert all values for the placeholders into values which are safe
|
||||
for use in filenames.
|
||||
|
||||
Paperless will try to conserve the information from your database as much as possible.
|
||||
However, some characters that you can use in document titles and correspondent names (such
|
||||
as ``: \ /`` and a couple more) are not allowed in filenames and will be replaced with dashes.
|
||||
|
||||
If paperless detects that two documents share the same filename, paperless will automatically
|
||||
append ``_01``, ``_02``, etc to the filename. This happens if all the placeholders in a filename
|
||||
evaluate to the same value.
|
||||
|
||||
.. hint::
|
||||
|
||||
|
98
docs/api.rst
98
docs/api.rst
@@ -13,23 +13,55 @@ available filters and ordering fields.
|
||||
|
||||
The API provides 5 main endpoints:
|
||||
|
||||
* ``/api/documents/``: Full CRUD support, except POSTing new documents. See below.
|
||||
* ``/api/correspondents/``: Full CRUD support.
|
||||
* ``/api/document_types/``: Full CRUD support.
|
||||
* ``/api/documents/``: Full CRUD support, except POSTing new documents. See below.
|
||||
* ``/api/logs/``: Read-Only.
|
||||
* ``/api/tags/``: Full CRUD support.
|
||||
|
||||
All of these endpoints except for the logging endpoint
|
||||
All of these endpoints except for the logging endpoint
|
||||
allow you to fetch, edit and delete individual objects
|
||||
by appending their primary key to the path, for example ``/api/documents/454/``.
|
||||
|
||||
The objects served by the document endpoint contain the following fields:
|
||||
|
||||
* ``id``: ID of the document. Read-only.
|
||||
* ``title``: Title of the document.
|
||||
* ``content``: Plain text content of the document.
|
||||
* ``tags``: List of IDs of tags assigned to this document, or empty list.
|
||||
* ``document_type``: Document type of this document, or null.
|
||||
* ``correspondent``: Correspondent of this document or null.
|
||||
* ``created``: The date at which this document was created.
|
||||
* ``modified``: The date at which this document was last edited in paperless. Read-only.
|
||||
* ``added``: The date at which this document was added to paperless. Read-only.
|
||||
* ``archive_serial_number``: The identifier of this document in a physical document archive.
|
||||
* ``original_file_name``: Verbose filename of the original document. Read-only.
|
||||
* ``archived_file_name``: Verbose filename of the archived document. Read-only. Null if no archived document is available.
|
||||
|
||||
|
||||
Downloading documents
|
||||
#####################
|
||||
|
||||
In addition to that, the document endpoint offers these additional actions on
|
||||
individual documents:
|
||||
|
||||
* ``/api/documents/<pk>/download/``: Download the original document.
|
||||
* ``/api/documents/<pk>/thumb/``: Download the PNG thumbnail of a document.
|
||||
* ``/api/documents/<pk>/preview/``: Display the original document inline,
|
||||
* ``/api/documents/<pk>/download/``: Download the document.
|
||||
* ``/api/documents/<pk>/preview/``: Display the document inline,
|
||||
without downloading it.
|
||||
* ``/api/documents/<pk>/thumb/``: Download the PNG thumbnail of a document.
|
||||
|
||||
Paperless generates archived PDF/A documents from consumed files and stores both
|
||||
the original files as well as the archived files. By default, the endpoints
|
||||
for previews and downloads serve the archived file, if it is available.
|
||||
Otherwise, the original file is served.
|
||||
Some document cannot be archived.
|
||||
|
||||
The endpoints correctly serve the response header fields ``Content-Disposition``
|
||||
and ``Content-Type`` to indicate the filename for download and the type of content of
|
||||
the document.
|
||||
|
||||
In order to download or preview the original document when an archied document is available,
|
||||
supply the query parameter ``original=true``.
|
||||
|
||||
.. hint::
|
||||
|
||||
@@ -38,13 +70,43 @@ individual documents:
|
||||
are in place. However, if you use these old URLs to access documents, you
|
||||
should update your app or script to use the new URLs.
|
||||
|
||||
.. note::
|
||||
|
||||
The document endpoint provides tags, document types and correspondents as
|
||||
ids in their corresponding fields. These are writeable. Paperless also
|
||||
offers read-only objects for assigned tags, types and correspondents,
|
||||
however, these might be removed in the future. As for now, the front end
|
||||
requires them.
|
||||
Getting document metadata
|
||||
#########################
|
||||
|
||||
The api also has an endpoint to retrieve read-only metadata about specific documents. this
|
||||
information is not served along with the document objects, since it requires reading
|
||||
files and would therefore slow down document lists considerably.
|
||||
|
||||
Access the metadata of a document with an ID ``id`` at ``/api/documents/<id>/metadata/``.
|
||||
|
||||
The endpoint reports the following data:
|
||||
|
||||
* ``original_checksum``: MD5 checksum of the original document.
|
||||
* ``original_size``: Size of the original document, in bytes.
|
||||
* ``original_mime_type``: Mime type of the original document.
|
||||
* ``media_filename``: Current filename of the document, under which it is stored inside the media directory.
|
||||
* ``has_archive_version``: True, if this document is archived, false otherwise.
|
||||
* ``original_metadata``: A list of metadata associated with the original document. See below.
|
||||
* ``archive_checksum``: MD5 checksum of the archived document, or null.
|
||||
* ``archive_size``: Size of the archived document in bytes, or null.
|
||||
* ``archive_metadata``: Metadata associated with the archived document, or null. See below.
|
||||
|
||||
File metadata is reported as a list of objects in the following form:
|
||||
|
||||
.. code:: json
|
||||
|
||||
[
|
||||
{
|
||||
"namespace": "http://ns.adobe.com/pdf/1.3/",
|
||||
"prefix": "pdf",
|
||||
"key": "Producer",
|
||||
"value": "SparklePDF, Fancy edition"
|
||||
},
|
||||
]
|
||||
|
||||
``namespace`` and ``prefix`` can be null. The actual metadata reported depends on the file type and the metadata
|
||||
available in that specific document. Paperless only reports PDF metadata at this point.
|
||||
|
||||
Authorization
|
||||
#############
|
||||
@@ -54,11 +116,11 @@ The REST api provides three different forms of authentication.
|
||||
1. Basic authentication
|
||||
|
||||
Authorize by providing a HTTP header in the form
|
||||
|
||||
|
||||
.. code::
|
||||
|
||||
Authorization: Basic <credentials>
|
||||
|
||||
|
||||
where ``credentials`` is a base64-encoded string of ``<username>:<password>``
|
||||
|
||||
2. Session authentication
|
||||
@@ -79,7 +141,7 @@ The REST api provides three different forms of authentication.
|
||||
.. code::
|
||||
|
||||
Authorization: Token <token>
|
||||
|
||||
|
||||
Tokens can be managed and revoked in the paperless admin.
|
||||
|
||||
Searching for documents
|
||||
@@ -111,7 +173,7 @@ Result list object returned by the endpoint:
|
||||
"page_count": 1,
|
||||
"corrected_query": "",
|
||||
"results": [
|
||||
|
||||
|
||||
]
|
||||
}
|
||||
|
||||
@@ -131,12 +193,12 @@ Result object:
|
||||
{
|
||||
"id": 1,
|
||||
"highlights": [
|
||||
|
||||
|
||||
],
|
||||
"score": 6.34234,
|
||||
"rank": 23,
|
||||
"document": {
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +230,7 @@ Each fragment contains a list of strings, and some of them are marked as a highl
|
||||
{"text": " fragment with a highlight."}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
When ``term`` is present within a string, the word within ``text`` should be highlighted.
|
||||
|
@@ -5,6 +5,90 @@
|
||||
Changelog
|
||||
*********
|
||||
|
||||
|
||||
paperless-ng 0.9.7
|
||||
##################
|
||||
|
||||
|
||||
* Front end
|
||||
|
||||
* Thanks to the hard work of `Michael Shamoon`_, paperless now comes with a much more streamlined UI for
|
||||
filtering documents.
|
||||
|
||||
* `Michael Shamoon`_ replaced the document preview with another component. This should fix compatibility with Safari browsers.
|
||||
|
||||
* Added buttons to the management pages to quickly show all documents with one specific tag, correspondent, or title.
|
||||
|
||||
* Paperless now stores your saved views on the server and associates them with your user account.
|
||||
This means that you can access your views on multiple devices and have separate views for different users.
|
||||
You will have to recreate your views.
|
||||
|
||||
* The GitHub and documentation links now open in new tabs/windows. Thanks to `rYR79435`_.
|
||||
|
||||
* Paperless now generates default saved view names when saving views with certain filter rules.
|
||||
|
||||
* Added a small version indicator to the front end.
|
||||
|
||||
* Other additions and changes
|
||||
|
||||
* The new filename format field ``{tag_list}`` inserts a list of tags into the filename, separated by comma.
|
||||
* The ``document_retagger`` no longer removes inbox tags or tags without matching rules.
|
||||
* The new configuration option ``PAPERLESS_COOKIE_PREFIX`` allows you to run multiple instances of paperless on different ports.
|
||||
This option enables you to be logged in into multiple instances by specifying different cookie names for each instance.
|
||||
|
||||
* Fixes
|
||||
|
||||
* Sometimes paperless would assign dates in the future to newly consumed documents.
|
||||
* The filename format fields ``{created_month}`` and ``{created_day}`` now use a leading zero for single digit values.
|
||||
* The filename format field ``{tags}`` can no longer be used without arguments.
|
||||
* Paperless was not able to consume many images (especially images from mobile scanners) due to missing DPI information.
|
||||
Paperless now assumes A4 paper size for PDF generation if no DPI information is present.
|
||||
* Documents with empty titles could not be opened from the table view due to the link being empty.
|
||||
* Fixed an issue with filenames containing special characters such as ``:`` not being accepted for upload.
|
||||
* Fixed issues with thumbnail generation for plain text files.
|
||||
|
||||
|
||||
paperless-ng 0.9.6
|
||||
##################
|
||||
|
||||
This release focusses primarily on many small issues with the UI.
|
||||
|
||||
* Front end
|
||||
|
||||
* Paperless now has proper window titles.
|
||||
* Fixed an issue with the small cards when more than 7 tags were used.
|
||||
* Navigation of the "Show all" links adjusted. They navigate to the saved view now, if available in the sidebar.
|
||||
* Some indication on the document lists that a filter is active was added.
|
||||
* There's a new filter to filter for documents that do *not* have a certain tag.
|
||||
* The file upload box now shows upload progress.
|
||||
* The document edit page was reorganized.
|
||||
* The document edit page shows various information about a document.
|
||||
* An issue with the height of the preview was fixed.
|
||||
* Table issues with too long document titles fixed.
|
||||
|
||||
* API
|
||||
|
||||
* The API now serves file names with documents.
|
||||
* The API now serves various metadata about documents.
|
||||
* API documentation updated.
|
||||
|
||||
* 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.
|
||||
|
||||
.. note::
|
||||
|
||||
The changes to the filename format will apply to newly added documents and changed documents.
|
||||
If you want all files to reflect these changes, execute the ``document_renamer`` management
|
||||
command.
|
||||
|
||||
|
||||
paperless-ng 0.9.5
|
||||
##################
|
||||
|
||||
@@ -800,6 +884,8 @@ bulk of the work on this big change.
|
||||
|
||||
* Initial release
|
||||
|
||||
.. _rYR79435: https://github.com/rYR79435
|
||||
.. _Michael Shamoon: https://github.com/shamoon
|
||||
.. _jayme-github: http://github.com/jayme-github
|
||||
.. _Brian Conn: https://github.com/TheConnMan
|
||||
.. _Christopher Luu: https://github.com/nuudles
|
||||
|
@@ -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
|
||||
|
@@ -118,114 +118,80 @@ This will test and assemble everything and also build and tag a docker image.
|
||||
Extending Paperless
|
||||
===================
|
||||
|
||||
.. warning::
|
||||
Paperless does not have any fancy plugin systems and will probably never have. However,
|
||||
some parts of the application have been designed to allow easy integration of additional
|
||||
features without any modification to the base code.
|
||||
|
||||
This section is not updated to paperless-ng yet.
|
||||
Making custom parsers
|
||||
---------------------
|
||||
|
||||
For the most part, Paperless is monolithic, so extending it is often best
|
||||
managed by way of modifying the code directly and issuing a pull request on
|
||||
`GitHub`_. However, over time the project has been evolving to be a little
|
||||
more "pluggable" so that users can write their own stuff that talks to it.
|
||||
Paperless uses parsers to add documents to paperless. A parser is responsible for:
|
||||
|
||||
.. _GitHub: https://github.com/the-paperless-project/paperless
|
||||
* Retrieve the content from the original
|
||||
* Create a thumbnail
|
||||
* Optional: Retrieve a created date from the original
|
||||
* Optional: Create an archived document from the original
|
||||
|
||||
Custom parsers can be added to paperless to support more file types. In order to do that,
|
||||
you need to write the parser itself and announce its existence to paperless.
|
||||
|
||||
.. _extending-parsers:
|
||||
|
||||
Parsers
|
||||
-------
|
||||
|
||||
You can leverage Paperless' consumption model to have it consume files *other*
|
||||
than ones handled by default like ``.pdf``, ``.jpg``, and ``.tiff``. To do so,
|
||||
you simply follow Django's convention of creating a new app, with a few key
|
||||
requirements.
|
||||
|
||||
|
||||
.. _extending-parsers-parserspy:
|
||||
|
||||
parsers.py
|
||||
..........
|
||||
|
||||
In this file, you create a class that extends
|
||||
``documents.parsers.DocumentParser`` and go about implementing the three
|
||||
required methods:
|
||||
|
||||
* ``get_thumbnail()``: Returns the path to a file we can use as a thumbnail for
|
||||
this document.
|
||||
* ``get_text()``: Returns the text from the document and only the text.
|
||||
* ``get_date()``: If possible, this returns the date of the document, otherwise
|
||||
it should return ``None``.
|
||||
|
||||
|
||||
.. _extending-parsers-signalspy:
|
||||
|
||||
signals.py
|
||||
..........
|
||||
|
||||
At consumption time, Paperless emits a ``document_consumer_declaration``
|
||||
signal which your module has to react to in order to let the consumer know
|
||||
whether or not it's capable of handling a particular file. Think of it like
|
||||
this:
|
||||
|
||||
1. Consumer finds a file in the consumption directory.
|
||||
2. It asks all the available parsers: *"Hey, can you handle this file?"*
|
||||
3. Each parser responds with either ``None`` meaning they can't handle the
|
||||
file, or a dictionary in the following format:
|
||||
The parser itself must extend ``documents.parsers.DocumentParser`` and must implement the
|
||||
methods ``parse`` and ``get_thumbnail``. You can provide your own implementation to
|
||||
``get_date`` if you don't want to rely on paperless' default date guessing mechanisms.
|
||||
|
||||
.. code:: python
|
||||
|
||||
{
|
||||
"parser": <the class name>,
|
||||
"weight": <an integer>
|
||||
}
|
||||
class MyCustomParser(DocumentParser):
|
||||
|
||||
The consumer compares the ``weight`` values from all respondents and uses the
|
||||
class with the highest value to consume the document. The default parser,
|
||||
``RasterisedDocumentParser`` has a weight of ``0``.
|
||||
def parse(self, document_path, mime_type):
|
||||
# This method does not return anything. Rather, you should assign
|
||||
# whatever you got from the document to the following fields:
|
||||
|
||||
# The content of the document.
|
||||
self.text = "content"
|
||||
|
||||
# Optional: path to a PDF document that you created from the original.
|
||||
self.archive_path = os.path.join(self.tempdir, "archived.pdf")
|
||||
|
||||
.. _extending-parsers-appspy:
|
||||
# Optional: "created" date of the document.
|
||||
self.date = get_created_from_metadata(document_path)
|
||||
|
||||
apps.py
|
||||
.......
|
||||
def get_thumbnail(self, document_path, mime_type):
|
||||
# This should return the path to a thumbnail you created for this
|
||||
# document.
|
||||
return os.path.join(self.tempdir, "thumb.png")
|
||||
|
||||
This is a standard Django file, but you'll need to add some code to it to
|
||||
connect your parser to the ``document_consumer_declaration`` signal.
|
||||
If you encounter any issues during parsing, raise a ``documents.parsers.ParseError``.
|
||||
|
||||
The ``self.tempdir`` directory is a temporary directory that is guaranteed to be empty
|
||||
and removed after consumption finished. You can use that directory to store any
|
||||
intermediate files and also use it to store the thumbnail / archived document.
|
||||
|
||||
.. _extending-parsers-finally:
|
||||
|
||||
Finally
|
||||
.......
|
||||
|
||||
The last step is to update ``settings.py`` to include your new module.
|
||||
Eventually, this will be dynamic, but at the moment, you have to edit the
|
||||
``INSTALLED_APPS`` section manually. Simply add the path to your AppConfig to
|
||||
the list like this:
|
||||
After that, you need to announce your parser to paperless. You need to connect a
|
||||
handler to the ``document_consumer_declaration`` signal. Have a look in the file
|
||||
``src/paperless_tesseract/apps.py`` on how that's done. The handler is a method
|
||||
that returns information about your parser:
|
||||
|
||||
.. code:: python
|
||||
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
"my_module.apps.MyModuleConfig",
|
||||
...
|
||||
]
|
||||
def myparser_consumer_declaration(sender, **kwargs):
|
||||
return {
|
||||
"parser": MyCustomParser,
|
||||
"weight": 0,
|
||||
"mime_types": {
|
||||
"application/pdf": ".pdf",
|
||||
"image/jpeg": ".jpg",
|
||||
}
|
||||
}
|
||||
|
||||
Order doesn't matter, but generally it's a good idea to place your module lower
|
||||
in the list so that you don't end up accidentally overriding project defaults
|
||||
somewhere.
|
||||
* ``parser`` is a reference to a class that extends ``DocumentParser``.
|
||||
|
||||
* ``weight`` is used whenever two or more parsers are able to parse a file: The parser with
|
||||
the higher weight wins. This can be used to override the parsers provided by
|
||||
paperless.
|
||||
|
||||
.. _extending-parsers-example:
|
||||
|
||||
An Example
|
||||
..........
|
||||
|
||||
The core Paperless functionality is based on this design, so if you want to see
|
||||
what a parser module should look like, have a look at `parsers.py`_,
|
||||
`signals.py`_, and `apps.py`_ in the `paperless_tesseract`_ module.
|
||||
|
||||
.. _parsers.py: https://github.com/the-paperless-project/paperless/blob/master/src/paperless_tesseract/parsers.py
|
||||
.. _signals.py: https://github.com/the-paperless-project/paperless/blob/master/src/paperless_tesseract/signals.py
|
||||
.. _apps.py: https://github.com/the-paperless-project/paperless/blob/master/src/paperless_tesseract/apps.py
|
||||
.. _paperless_tesseract: https://github.com/the-paperless-project/paperless/blob/master/src/paperless_tesseract/
|
||||
* ``mime_types`` is a dictionary. The keys are the mime types your parser supports and the value
|
||||
is the default file extension that paperless should use when storing files and serving them for
|
||||
download. We could guess that from the file extensions, but some mime types have many extensions
|
||||
associated with them and the python methods responsible for guessing the extension do not always
|
||||
return the same value.
|
||||
|
@@ -73,11 +73,17 @@ in your browser and paperless has to do much less work to serve the data.
|
||||
|
||||
**Q:** *How do I install paperless-ng on Raspberry Pi?*
|
||||
|
||||
**A:** There is not docker image for ARM available. If you know how to build
|
||||
**A:** There is no docker image for ARM available. If you know how to build
|
||||
that automatically, I'm all ears. For now, you have to grab the latest release
|
||||
archive from the project page and build the image yourself. The release comes
|
||||
with the front end already compiled, so you don't have to do this on the Pi.
|
||||
|
||||
**Q:** *How do I run this on unRaid?*
|
||||
|
||||
**A:** Head over to `<https://github.com/selfhosters/unRAID-CA-templates>`_,
|
||||
`Uli Fahrer <https://github.com/Tooa>`_ created a container template for that.
|
||||
I don't exactly know how to use that though, since I don't use unRaid.
|
||||
|
||||
**Q:** *How do I run this on my toaster?*
|
||||
|
||||
**A:** I honestly don't know! As for all other devices that might be able
|
||||
|
@@ -57,9 +57,6 @@ Adding documents to paperless
|
||||
#############################
|
||||
|
||||
Once you've got Paperless setup, you need to start feeding documents into it.
|
||||
Currently, there are three options: the consumption directory, IMAP (email), and
|
||||
HTTP POST.
|
||||
|
||||
When adding documents to paperless, it will perform the following operations on
|
||||
your documents:
|
||||
|
||||
@@ -82,8 +79,7 @@ your documents:
|
||||
No matter which options you choose, Paperless will always store the original
|
||||
document that it found in the consumption directory or in the mail and
|
||||
will never overwrite that document. Archived versions are stored alongside the
|
||||
digital versions.
|
||||
|
||||
original versions.
|
||||
|
||||
|
||||
The consumption directory
|
||||
@@ -107,6 +103,23 @@ files from the scanner. Typically, you're looking at an FTP server like
|
||||
|
||||
.. TODO: hyperref to configuration of the location of this magic folder.
|
||||
|
||||
Dashboard upload
|
||||
================
|
||||
|
||||
The dashboard has a file drop field to upload documents to paperless. Simply drag a file
|
||||
onto this field or select a file with the file dialog. Multiple files are supported.
|
||||
|
||||
|
||||
Mobile upload
|
||||
=============
|
||||
|
||||
The mobile app over at `<https://github.com/qcasey/paperless_share>`_ allows Android users
|
||||
to share any documents with paperless. This can be combined with any of the mobile
|
||||
scanning apps out there, such as Office Lens.
|
||||
|
||||
Furthermore, there is the `Paperless App <https://github.com/bauerj/paperless_app>`_ as well,
|
||||
which no only has document upload, but also document editing and browsing.
|
||||
|
||||
.. _usage-email:
|
||||
|
||||
IMAP (Email)
|
||||
@@ -183,6 +196,63 @@ You can also submit a document using the REST API, see :ref:`api-file_uploads` f
|
||||
|
||||
.. _basic-searching:
|
||||
|
||||
|
||||
Best practices
|
||||
##############
|
||||
|
||||
Paperless offers a couple tools that help you organize your document collection. However,
|
||||
it is up to you to use them in a way that helps you organize documents and find specific
|
||||
documents when you need them. This section offers a couple ideas for managing your collection.
|
||||
|
||||
Document types allow you to classify documents according to what they are. You can define
|
||||
types such as "Receipt", "Invoice", or "Contract". If you used to collect all your receipts
|
||||
in a single binder, you can recreate that system in paperless by defining a document type,
|
||||
assigning documents to that type and then filtering by that type to only see all receipts.
|
||||
|
||||
Not all documents need document types. Sometimes its hard to determine what the type of a
|
||||
document is or it is hard to justify creating a document type that you only need once or twice.
|
||||
This is okay. As long as the types you define help you organize your collection in the way
|
||||
you want, paperless is doing its job.
|
||||
|
||||
Tags can be used in many different ways. Think of tags are more versatile folders or binders.
|
||||
If you have a binder for documents related to university / your car or health care, you can
|
||||
create these binders in paperless by creating tags and assigning them to relevant documents.
|
||||
Just as with documents, you can filter the document list by tags and only see documents of
|
||||
a certain topic.
|
||||
|
||||
With physical documents, you'll often need to decide which folder the document belongs to.
|
||||
The advantage of tags over folders and binders is that a single document can have multiple
|
||||
tags. A physical document cannot magically appear in two different folders, but with tags,
|
||||
this is entirely possible.
|
||||
|
||||
.. hint::
|
||||
|
||||
This can be used in many different ways. One example: Imagine you're working on a particular
|
||||
task, such as signing up for university. Usually you'll need to collect a bunch of different
|
||||
documents that are already sorted into various folders. With the tag system of paperless,
|
||||
you can create a new group of documents that are relevant to this task without destroying
|
||||
the already existing organization. When you're done with the task, you could delete the
|
||||
tag again, which would be equal to sorting documents back into the folder they belong into.
|
||||
Or keep the tag, up to you.
|
||||
|
||||
All of the logic above applies to correspondents as well. Attach them to documents if you
|
||||
feel that they help you organize your collection.
|
||||
|
||||
When you've started organizing your documents, create a couple saved views for document collections
|
||||
you regularly access. This is equal to having labeled physical binders on your desk, except
|
||||
that these saved views are dynamic and simply update themselves as you add documents to the system.
|
||||
|
||||
Here are a couple examples of tags and types that you could use in your collection.
|
||||
|
||||
* An ``inbox`` tag for newly added documents that you haven't manually edited yet.
|
||||
* A tag ``car`` for everything car related (repairs, registration, insurance, etc)
|
||||
* A tag ``todo`` for documents that you still need to do something with, such as reply, or
|
||||
perform some task online.
|
||||
* A tag ``bank account x`` for all bank statement related to that account.
|
||||
* A tag ``mail`` for anything that you added to paperless via its mail processing capabilities.
|
||||
* A tag ``missing_metadata`` when you still need to add some metadata to a document, but can't
|
||||
or don't want to do this right now.
|
||||
|
||||
Searching
|
||||
#########
|
||||
|
||||
|
@@ -30,6 +30,7 @@
|
||||
#PAPERLESS_FORCE_SCRIPT_NAME=
|
||||
#PAPERLESS_STATIC_URL=/static/
|
||||
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
||||
#PAPERLESS_COOKIE_PREFIX=
|
||||
|
||||
# OCR settings
|
||||
|
||||
|
@@ -5,6 +5,7 @@
|
||||
# adjust src/paperless/version.py
|
||||
# changelog in the documentation
|
||||
# adjust versions in docker/hub/*
|
||||
# adjust version in src-ui/src/environments/prod
|
||||
# If docker-compose was modified: all compose files are the same.
|
||||
|
||||
# Steps:
|
||||
|
63
src-ui/package-lock.json
generated
63
src-ui/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -14,10 +14,9 @@ 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';
|
||||
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';
|
||||
@@ -28,6 +27,9 @@ import { PageHeaderComponent } from './components/common/page-header/page-header
|
||||
import { AppFrameComponent } from './components/app-frame/app-frame.component';
|
||||
import { ToastsComponent } from './components/common/toasts/toasts.component';
|
||||
import { FilterEditorComponent } from './components/filter-editor/filter-editor.component';
|
||||
import { FilterDropdownComponent } from './components/filter-editor/filter-dropdown/filter-dropdown.component';
|
||||
import { FilterDropdownButtonComponent } from './components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component';
|
||||
import { FilterDropdownDateComponent } from './components/filter-editor/filter-dropdown-date/filter-dropdown-date.component';
|
||||
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component';
|
||||
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component';
|
||||
import { NgxFileDropModule } from 'ngx-file-drop';
|
||||
@@ -45,6 +47,13 @@ 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';
|
||||
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 { FilterPipe } from './pipes/filter.pipe';
|
||||
import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
||||
import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@@ -57,10 +66,9 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
|
||||
DocumentTypeListComponent,
|
||||
LogsComponent,
|
||||
SettingsComponent,
|
||||
SafePipe,
|
||||
NotFoundComponent,
|
||||
CorrespondentEditDialogComponent,
|
||||
DeleteDialogComponent,
|
||||
ConfirmDialogComponent,
|
||||
TagEditDialogComponent,
|
||||
DocumentTypeEditDialogComponent,
|
||||
TagComponent,
|
||||
@@ -70,6 +78,9 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
|
||||
AppFrameComponent,
|
||||
ToastsComponent,
|
||||
FilterEditorComponent,
|
||||
FilterDropdownComponent,
|
||||
FilterDropdownButtonComponent,
|
||||
FilterDropdownDateComponent,
|
||||
DocumentCardLargeComponent,
|
||||
DocumentCardSmallComponent,
|
||||
TextComponent,
|
||||
@@ -82,7 +93,13 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
|
||||
SavedViewWidgetComponent,
|
||||
StatisticsWidgetComponent,
|
||||
UploadFileWidgetComponent,
|
||||
WidgetFrameComponent
|
||||
WidgetFrameComponent,
|
||||
WelcomeWidgetComponent,
|
||||
YesNoPipe,
|
||||
FileSizePipe,
|
||||
FilterPipe,
|
||||
DocumentTitlePipe,
|
||||
MetadataCollapseComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
@@ -92,7 +109,8 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxFileDropModule,
|
||||
InfiniteScrollModule
|
||||
InfiniteScrollModule,
|
||||
PdfViewerModule
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
@@ -100,7 +118,9 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: CsrfInterceptor,
|
||||
multi: true
|
||||
}
|
||||
},
|
||||
FilterPipe,
|
||||
DocumentTitlePipe
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
|
@@ -17,6 +17,11 @@
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse" [ngbCollapse]="isMenuCollapsed">
|
||||
|
||||
<div style="position: absolute; bottom: 0; left: 0;" class="text-muted p-1">
|
||||
{{versionString}}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-sticky pt-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
@@ -37,16 +42,16 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='viewConfigService.getSideBarConfigs().length > 0'>
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'>
|
||||
<span>Saved views</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item w-100" *ngFor='let config of viewConfigService.getSideBarConfigs()'>
|
||||
<a class="nav-link text-truncate" routerLink="view/{{config.id}}" routerLinkActive="active" (click)="closeMenu()">
|
||||
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
|
||||
<a class="nav-link text-truncate" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#funnel"/>
|
||||
</svg>
|
||||
{{config.title}}
|
||||
{{view.name}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -60,7 +65,7 @@
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
||||
</svg>
|
||||
{{d.title}}
|
||||
{{d.title | documentTitle}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item w-100" *ngIf="openDocuments.length > 1">
|
||||
@@ -132,7 +137,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 +145,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>
|
||||
|
@@ -5,8 +5,9 @@ import { from, Observable, Subscription } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
|
||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service';
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
|
||||
import { SearchService } from 'src/app/services/rest/search.service';
|
||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component';
|
||||
|
||||
@Component({
|
||||
@@ -21,10 +22,12 @@ export class AppFrameComponent implements OnInit, OnDestroy {
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private openDocumentsService: OpenDocumentsService,
|
||||
private searchService: SearchService,
|
||||
public viewConfigService: SavedViewConfigService
|
||||
public savedViewService: SavedViewService
|
||||
) {
|
||||
}
|
||||
|
||||
versionString = `${environment.appTitle} ${environment.version}`
|
||||
|
||||
isMenuCollapsed: boolean = true
|
||||
|
||||
closeMenu() {
|
||||
|
@@ -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>
|
@@ -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();
|
||||
});
|
@@ -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()
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import { Component, Directive, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { Directive, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor } from '@angular/forms';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Directive()
|
||||
|
@@ -3,11 +3,10 @@
|
||||
<label for="created_date">{{titleDate}}</label>
|
||||
<input type="date" class="form-control" id="created_date" [(ngModel)]="dateValue" (change)="dateOrTimeChanged()">
|
||||
</div>
|
||||
<div class="form-group col">
|
||||
<div class="form-group col" *ngIf="titleTime">
|
||||
<label for="created_time">{{titleTime}}</label>
|
||||
<input type="time" class="form-control" id="created_time" [(ngModel)]="timeValue" (change)="dateOrTimeChanged()">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
@Component({
|
||||
providers: [{
|
||||
@@ -40,7 +39,7 @@ export class DateTimeComponent implements OnInit,ControlValueAccessor {
|
||||
titleDate: string = "Date"
|
||||
|
||||
@Input()
|
||||
titleTime: string = "Time"
|
||||
titleTime: string
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, forwardRef, Input, Output } from '@angular/core';
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import { ThrowStmt } from '@angular/compiler';
|
||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Observable } from 'rxjs';
|
||||
import { TagEditDialogComponent } from 'src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { TagService } from 'src/app/services/rest/tag.service';
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Component, forwardRef } from '@angular/core';
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
@Component({
|
||||
|
@@ -1,21 +1,29 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
templateUrl: './page-header.component.html',
|
||||
styleUrls: ['./page-header.component.scss']
|
||||
})
|
||||
export class PageHeaderComponent implements OnInit {
|
||||
export class PageHeaderComponent {
|
||||
|
||||
constructor() { }
|
||||
constructor(private titleService: Title) { }
|
||||
|
||||
_title = ""
|
||||
|
||||
@Input()
|
||||
title: string = ""
|
||||
set title(title: string) {
|
||||
this._title = title
|
||||
this.titleService.setTitle(`${this.title} - ${environment.appTitle}`)
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this._title
|
||||
}
|
||||
|
||||
@Input()
|
||||
subTitle: string = ""
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
|
||||
@Component({
|
||||
|
@@ -4,11 +4,7 @@
|
||||
|
||||
<div class='row'>
|
||||
<div class="col-lg-8">
|
||||
<app-widget-frame title="Saved views" *ngIf="savedViews.length == 0">
|
||||
<p class="card-text">This space is reserved to display your saved views. Go to your documents and save a view
|
||||
to have it displayed
|
||||
here!</p>
|
||||
</app-widget-frame>
|
||||
<app-welcome-widget *ngIf="savedViews.length == 0"></app-welcome-widget>
|
||||
|
||||
<ng-container *ngFor="let v of savedViews">
|
||||
<app-saved-view-widget [savedView]="v"></app-saved-view-widget>
|
||||
@@ -22,4 +18,4 @@
|
||||
<app-upload-file-widget></app-upload-file-widget>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
|
||||
|
||||
|
||||
@Component({
|
||||
@@ -10,13 +11,15 @@ import { SavedViewConfigService } from 'src/app/services/saved-view-config.servi
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
public savedViewConfigService: SavedViewConfigService) { }
|
||||
private savedViewService: SavedViewService) { }
|
||||
|
||||
|
||||
savedViews = []
|
||||
savedViews: PaperlessSavedView[] = []
|
||||
|
||||
ngOnInit(): void {
|
||||
this.savedViews = this.savedViewConfigService.getDashboardConfigs()
|
||||
this.savedViewService.listAll().subscribe(results => {
|
||||
this.savedViews = results.results.filter(savedView => savedView.show_on_dashboard)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<app-widget-frame [title]="savedView.title">
|
||||
<app-widget-frame [title]="savedView.name">
|
||||
|
||||
<a header-buttons [routerLink]="" (click)="showAll()">Show all</a>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<tbody>
|
||||
<tr *ngFor="let doc of documents" routerLink="/documents/{{doc.id}}">
|
||||
<td>{{doc.created | date}}</td>
|
||||
<td>{{doc.title}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ml-1"></app-tag>
|
||||
<td>{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ml-1"></app-tag>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
||||
import { SavedViewConfig } from 'src/app/data/saved-view-config';
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||
|
||||
@@ -18,19 +18,23 @@ export class SavedViewWidgetComponent implements OnInit {
|
||||
private list: DocumentListViewService) { }
|
||||
|
||||
@Input()
|
||||
savedView: SavedViewConfig
|
||||
savedView: PaperlessSavedView
|
||||
|
||||
documents: PaperlessDocument[] = []
|
||||
|
||||
ngOnInit(): void {
|
||||
this.documentService.list(1,10,this.savedView.sortField,this.savedView.sortDirection,this.savedView.filterRules).subscribe(result => {
|
||||
this.documentService.list(1,10,this.savedView.sort_field, this.savedView.sort_reverse, this.savedView.filter_rules).subscribe(result => {
|
||||
this.documents = result.results
|
||||
})
|
||||
}
|
||||
|
||||
showAll() {
|
||||
this.list.load(this.savedView)
|
||||
this.router.navigate(["documents"])
|
||||
if (this.savedView.show_in_sidebar) {
|
||||
this.router.navigate(['view', this.savedView.id])
|
||||
} else {
|
||||
this.list.load(this.savedView)
|
||||
this.router.navigate(["documents"])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,15 +1,18 @@
|
||||
<app-widget-frame title="Upload new documents">
|
||||
|
||||
<form content>
|
||||
<ngx-file-drop
|
||||
dropZoneLabel="Drop documents here or" (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)"
|
||||
dropZoneClassName="bg-light card"
|
||||
multiple="true"
|
||||
contentClassName="justify-content-center d-flex align-items-center p-5"
|
||||
[showBrowseBtn]=true
|
||||
browseBtnClassName="btn btn-sm btn-outline-primary ml-2">
|
||||
<div content>
|
||||
<form>
|
||||
<ngx-file-drop dropZoneLabel="Drop documents here or" (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card"
|
||||
multiple="true" contentClassName="justify-content-center d-flex align-items-center p-5" [showBrowseBtn]=true
|
||||
browseBtnClassName="btn btn-sm btn-outline-primary ml-2">
|
||||
|
||||
</ngx-file-drop>
|
||||
</form>
|
||||
</ngx-file-drop>
|
||||
</form>
|
||||
<div *ngIf="uploadVisible" class="mt-3">
|
||||
<p>Uploading {{uploadStatus.length}} file(s)</p>
|
||||
<ngb-progressbar [value]="loadedSum" [max]="totalSum" [striped]="true" [animated]="uploadStatus.length > 0">
|
||||
</ngb-progressbar>
|
||||
</div>
|
||||
</div>
|
||||
</app-widget-frame>
|
@@ -1,8 +1,15 @@
|
||||
import { HttpEventType } from '@angular/common/http';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop';
|
||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||
import { Toast, ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
|
||||
interface UploadStatus {
|
||||
loaded: number
|
||||
total: number
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload-file-widget',
|
||||
templateUrl: './upload-file-widget.component.html',
|
||||
@@ -16,26 +23,59 @@ export class UploadFileWidgetComponent implements OnInit {
|
||||
}
|
||||
|
||||
public fileOver(event){
|
||||
console.log(event);
|
||||
}
|
||||
|
||||
|
||||
public fileLeave(event){
|
||||
console.log(event);
|
||||
}
|
||||
|
||||
|
||||
uploadStatus: UploadStatus[] = []
|
||||
completedFiles = 0
|
||||
|
||||
uploadVisible = false
|
||||
|
||||
get loadedSum() {
|
||||
return this.uploadStatus.map(s => s.loaded).reduce((a,b) => a+b, this.completedFiles > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
get totalSum() {
|
||||
return this.uploadStatus.map(s => s.total).reduce((a,b) => a+b, 1)
|
||||
}
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
for (const droppedFile of files) {
|
||||
if (droppedFile.fileEntry.isFile) {
|
||||
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
|
||||
console.log(fileEntry)
|
||||
let uploadStatusObject: UploadStatus = {loaded: 0, total: 1}
|
||||
this.uploadStatus.push(uploadStatusObject)
|
||||
this.uploadVisible = true
|
||||
|
||||
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
|
||||
fileEntry.file((file: File) => {
|
||||
console.log(file)
|
||||
const formData = new FormData()
|
||||
let formData = new FormData()
|
||||
formData.append('document', file, file.name)
|
||||
this.documentService.uploadDocument(formData).subscribe(result => {
|
||||
this.toastService.showToast(Toast.make("Information", "The document has been uploaded and will be processed by the consumer shortly."))
|
||||
|
||||
this.documentService.uploadDocument(formData).subscribe(event => {
|
||||
if (event.type == HttpEventType.UploadProgress) {
|
||||
uploadStatusObject.loaded = event.loaded
|
||||
uploadStatusObject.total = event.total
|
||||
} else if (event.type == HttpEventType.Response) {
|
||||
this.uploadStatus.splice(this.uploadStatus.indexOf(uploadStatusObject), 1)
|
||||
this.completedFiles += 1
|
||||
this.toastService.showToast(Toast.make("Information", "The document has been uploaded and will be processed by the consumer shortly."))
|
||||
}
|
||||
|
||||
}, error => {
|
||||
this.toastService.showToast(Toast.makeError("An error has occured while uploading the document. Sorry!"))
|
||||
this.uploadStatus.splice(this.uploadStatus.indexOf(uploadStatusObject), 1)
|
||||
this.completedFiles += 1
|
||||
switch (error.status) {
|
||||
case 400: {
|
||||
this.toastService.showToast(Toast.makeError(`There was an error while uploading the document: ${error.error.document}`))
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.toastService.showToast(Toast.makeError("An error has occurred while uploading the document. Sorry!"))
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
@@ -0,0 +1,16 @@
|
||||
<app-widget-frame title="First steps">
|
||||
|
||||
<ng-container content>
|
||||
<img src="assets/save-filter.png" class="float-right">
|
||||
<p>Paperless is running! :)</p>
|
||||
<p>You can start uploading documents by dropping them in the file upload box to the right or by dropping them in the configured consumption folder and they'll start showing up in the documents list.
|
||||
After you've added some metadata to your documents, use the filtering mechanisms of paperless to create custom views (such as 'Recently added', 'Tagged TODO') and have them displayed on the dashboard instead of this message.</p>
|
||||
<p>Paperless offers some more features that try to make your life easier, such as:</p>
|
||||
<ul>
|
||||
<li>Once you've got a couple documents in paperless and added metadata to them, paperless can assign that metadata to new documents automatically.</li>
|
||||
<li>You can configure paperless to read your mails and add documents from attached files.</li>
|
||||
</ul>
|
||||
<p>Consult the documentation on how to use these features. The section on basic usage also has some information on how to use paperless in general.</p>
|
||||
</ng-container>
|
||||
|
||||
</app-widget-frame>
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { WelcomeWidgetComponent } from './welcome-widget.component';
|
||||
|
||||
describe('WelcomeWidgetComponent', () => {
|
||||
let component: WelcomeWidgetComponent;
|
||||
let fixture: ComponentFixture<WelcomeWidgetComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ WelcomeWidgetComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(WelcomeWidgetComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,15 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-welcome-widget',
|
||||
templateUrl: './welcome-widget.component.html',
|
||||
styleUrls: ['./welcome-widget.component.scss']
|
||||
})
|
||||
export class WelcomeWidgetComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
@@ -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>
|
||||
|
@@ -14,15 +14,15 @@
|
||||
</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 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 shadow" ngbDropdownMenu>
|
||||
<a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="close()">
|
||||
@@ -35,41 +35,100 @@
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl">
|
||||
<div class="col mb-4">
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
<app-input-text title="Title" formControlName="title"></app-input-text>
|
||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||
<li [ngbNavItem]="1">
|
||||
<a ngbNavLink>Details</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="archive_serial_number">Archive Serial Number</label>
|
||||
<input type="number" class="form-control" id="archive_serial_number"
|
||||
formControlName='archive_serial_number'>
|
||||
</div>
|
||||
<app-input-text title="Title" formControlName="title"></app-input-text>
|
||||
<div class="form-group">
|
||||
<label for="archive_serial_number">Archive Serial Number</label>
|
||||
<input type="number" class="form-control" id="archive_serial_number"
|
||||
formControlName='archive_serial_number'>
|
||||
</div>
|
||||
<app-input-date-time titleDate="Date created" formControlName="created"></app-input-date-time>
|
||||
<app-input-select [items]="correspondents" title="Correspondent" formControlName="correspondent"
|
||||
allowNull="true" (createNew)="createCorrespondent()"></app-input-select>
|
||||
<app-input-select [items]="documentTypes" title="Document type" formControlName="document_type"
|
||||
allowNull="true" (createNew)="createDocumentType()"></app-input-select>
|
||||
<app-input-tags formControlName="tags" title="Tags"></app-input-tags>
|
||||
|
||||
<app-input-date-time title="Date created" titleTime="Time created" formControlName="created"></app-input-date-time>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">Content</label>
|
||||
<textarea class="form-control" id="content" rows="5" formControlName='content'></textarea>
|
||||
</div>
|
||||
<li [ngbNavItem]="2">
|
||||
<a ngbNavLink>Content</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" id="content" rows="20" formControlName='content'></textarea>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<app-input-select [items]="correspondents" title="Correspondent" formControlName="correspondent" allowNull="true" (createNew)="createCorrespondent()"></app-input-select>
|
||||
<li [ngbNavItem]="3">
|
||||
<a ngbNavLink>Metadata</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<app-input-select [items]="documentTypes" title="Document type" formControlName="document_type" allowNull="true" (createNew)="createDocumentType()"></app-input-select>
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Date modified</td>
|
||||
<td>{{document.modified | date:'medium'}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date added</td>
|
||||
<td>{{document.added | date:'medium'}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Media filename</td>
|
||||
<td>{{metadata?.media_filename}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original MD5 Checksum</td>
|
||||
<td>{{metadata?.original_checksum}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original file size</td>
|
||||
<td>{{metadata?.original_size | fileSize}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original mime type</td>
|
||||
<td>{{metadata?.original_mime_type}}</td>
|
||||
</tr>
|
||||
<tr *ngIf="metadata?.has_archive_version">
|
||||
<td>Archive MD5 Checksum</td>
|
||||
<td>{{metadata?.archive_checksum}}</td>
|
||||
</tr>
|
||||
<tr *ngIf="metadata?.has_archive_version">
|
||||
<td>Archive file size</td>
|
||||
<td>{{metadata?.archive_size | fileSize}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<app-input-tags formControlName="tags" title="Tags"></app-input-tags>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="discard()">Discard</button>
|
||||
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()">Save & edit next</button>
|
||||
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()">Save & edit
|
||||
next</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</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="col-md-6 col-xl-8 mb-3">
|
||||
<div class="pdf-viewer-container" *ngIf="getContentType() == 'application/pdf'">
|
||||
<pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true"></pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,6 @@
|
||||
.pdf-viewer-container {
|
||||
height: calc(100vh - 160px);
|
||||
top: 70px;
|
||||
position: sticky;
|
||||
background-color: gray;
|
||||
}
|
||||
|
@@ -6,12 +6,13 @@ import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
|
||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
||||
import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata';
|
||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe';
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service';
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
|
||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||
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';
|
||||
|
||||
@@ -22,6 +23,9 @@ import { DocumentTypeEditDialogComponent } from '../manage/document-type-list/do
|
||||
})
|
||||
export class DocumentDetailComponent implements OnInit {
|
||||
|
||||
public expandOriginalMetadata = false;
|
||||
public expandArchivedMetadata = false;
|
||||
|
||||
documentId: number
|
||||
document: PaperlessDocument
|
||||
metadata: PaperlessDocumentMetadata
|
||||
@@ -51,7 +55,12 @@ export class DocumentDetailComponent implements OnInit {
|
||||
private router: Router,
|
||||
private modalService: NgbModal,
|
||||
private openDocumentService: OpenDocumentsService,
|
||||
private documentListViewService: DocumentListViewService) { }
|
||||
private documentListViewService: DocumentListViewService,
|
||||
private documentTitlePipe: DocumentTitlePipe) { }
|
||||
|
||||
getContentType() {
|
||||
return this.metadata?.has_archive_version ? 'application/pdf' : this.metadata?.original_mime_type
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.documentForm.valueChanges.subscribe(wow => {
|
||||
@@ -83,7 +92,7 @@ export class DocumentDetailComponent implements OnInit {
|
||||
this.documentsService.getMetadata(doc.id).subscribe(result => {
|
||||
this.metadata = result
|
||||
})
|
||||
this.title = doc.title
|
||||
this.title = this.documentTitlePipe.transform(doc.title)
|
||||
this.documentForm.patchValue(doc)
|
||||
}
|
||||
|
||||
@@ -144,10 +153,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()
|
||||
|
@@ -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>
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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 {
|
||||
}
|
||||
|
||||
}
|
@@ -7,12 +7,12 @@
|
||||
<div class="card-body">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title">
|
||||
<h5 class="card-title">
|
||||
<ng-container *ngIf="document.correspondent">
|
||||
<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>
|
||||
@@ -52,4 +52,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,5 +1,6 @@
|
||||
.result-content {
|
||||
color: darkgray;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.doc-img {
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||
|
||||
@Component({
|
||||
|
@@ -1,17 +1,23 @@
|
||||
<div class="col p-2 h-100" style="width: 16rem;">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class=" border-bottom doc-img pr-1" [ngStyle]="{'background-image': 'url(' + getThumbUrl() + ')'}">
|
||||
<div class="row" *ngFor="let t of document.tags$ | async">
|
||||
<app-tag style="font-size: large;" [tag]="t" class="col text-right" (click)="clickTag.emit(t.id)" [clickable]="true" linkTitle="Filter by tag"></app-tag>
|
||||
<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">
|
||||
<app-tag [tag]="t" (click)="clickTag.emit(t.id)" [clickable]="true" linkTitle="Filter by tag"></app-tag>
|
||||
</div>
|
||||
<div *ngIf="moreTags">
|
||||
<span class="badge badge-secondary">+ {{moreTags}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text">
|
||||
<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">
|
||||
@@ -38,7 +44,7 @@
|
||||
</div>
|
||||
<small class="text-muted">{{document.created | date}}</small>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
.doc-img {
|
||||
background-size: cover;
|
||||
background-position: top;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
height: 200px;
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||
|
||||
@Component({
|
||||
@@ -21,6 +21,8 @@ export class DocumentCardSmallComponent implements OnInit {
|
||||
@Output()
|
||||
clickCorrespondent = new EventEmitter<number>()
|
||||
|
||||
moreTags: number = null
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
@@ -35,4 +37,18 @@ export class DocumentCardSmallComponent implements OnInit {
|
||||
getPreviewUrl() {
|
||||
return this.documentService.getPreviewUrl(this.document.id)
|
||||
}
|
||||
|
||||
getTagsLimited$() {
|
||||
return this.document.tags$.pipe(
|
||||
map(tags => {
|
||||
if (tags.length > 7) {
|
||||
this.moreTags = tags.length - 6
|
||||
return tags.slice(0, 6)
|
||||
} else {
|
||||
return tags
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
<app-page-header [title]="getTitle()">
|
||||
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup [(ngModel)]="displayMode"
|
||||
(ngModelChange)="saveDisplayMode()">
|
||||
<label ngbButtonLabel class="btn-outline-primary btn-sm">
|
||||
@@ -21,71 +20,64 @@
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortDirection">
|
||||
|
||||
<div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortReverse">
|
||||
<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>
|
||||
</div>
|
||||
<label ngbButtonLabel class="btn-outline-primary btn-sm">
|
||||
<input ngbButton type="radio" class="btn btn-sm" value="asc">
|
||||
<input ngbButton type="radio" class="btn btn-sm" [value]="false">
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" />
|
||||
</svg>
|
||||
</label>
|
||||
<label ngbButtonLabel class="btn-outline-primary btn-sm">
|
||||
<input ngbButton type="radio" class="btn btn-sm" value="des">
|
||||
<input ngbButton type="radio" class="btn btn-sm" [value]="true">
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="btn-group ml-2">
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="showFilter=!showFilter">
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#funnel" />
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle>Views</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
<ng-container *ngIf="!list.savedViewId">
|
||||
<button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button>
|
||||
<div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId">Save "{{list.savedViewTitle}}"</button>
|
||||
<button ngbDropdownItem (click)="saveViewConfigAs()">Save as...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</app-page-header>
|
||||
|
||||
<div class="card w-100 mb-3" [hidden]="!showFilter">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Filter</h5>
|
||||
<app-filter-editor [(filterRules)]="filterRules" (apply)="applyFilterRules()" (clear)="clearFilterRules()"></app-filter-editor>
|
||||
</div>
|
||||
<div class="w-100 mb-2 mb-sm-4">
|
||||
<app-filter-editor [(filterRules)]="list.filterRules" #filterEditor></app-filter-editor>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<p>{{list.collectionSize || 0}} document(s)</p>
|
||||
<p>{{list.collectionSize || 0}} document(s) <span *ngIf="isFiltered">(filtered)</span></p>
|
||||
<ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
||||
[rotate]="true" (pageChange)="list.reload()" aria-label="Default pagination"></ngb-pagination>
|
||||
</div>
|
||||
|
||||
<div *ngIf="displayMode == 'largeCards'">
|
||||
<app-document-card-large *ngFor="let d of list.documents" [document]="d" [details]="d.content" (clickTag)="filterByTag($event)" (clickCorrespondent)="filterByCorrespondent($event)">
|
||||
<app-document-card-large *ngFor="let d of list.documents" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)">
|
||||
</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>
|
||||
@@ -101,16 +93,16 @@
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
<ng-container *ngIf="d.correspondent">
|
||||
<a [routerLink]="" (click)="filterByCorrespondent(d.correspondent)" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
|
||||
<a [routerLink]="" (click)="clickCorrespondent(d.correspondent)" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
|
||||
</ng-container>
|
||||
</td>
|
||||
<td>
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document">{{d.title}}</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>
|
||||
<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)="clickTag(t.id)"></app-tag>
|
||||
</td>
|
||||
<td class="d-none d-xl-table-cell">
|
||||
<ng-container *ngIf="d.document_type">
|
||||
<a [routerLink]="" (click)="filterByDocumentType(d.document_type)" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
|
||||
<a [routerLink]="" (click)="clickDocumentType(d.document_type)" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
|
||||
</ng-container>
|
||||
</td>
|
||||
<td>
|
||||
@@ -125,5 +117,5 @@
|
||||
|
||||
|
||||
<div class=" m-n2 row" *ngIf="displayMode == 'smallCards'">
|
||||
<app-document-card-small [document]="d" *ngFor="let d of list.documents" (clickTag)="filterByTag($event)" (clickCorrespondent)="filterByCorrespondent($event)"></app-document-card-small>
|
||||
<app-document-card-small [document]="d" *ngFor="let d of list.documents" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small>
|
||||
</div>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { cloneFilterRules, FilterRule } from 'src/app/data/filter-rule';
|
||||
import { FILTER_CORRESPONDENT, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
|
||||
import { SavedViewConfig } from 'src/app/data/saved-view-config';
|
||||
import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type';
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||
import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service';
|
||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
|
||||
import { Toast, ToastService } from 'src/app/services/toast.service';
|
||||
import { FilterEditorComponent } from '../filter-editor/filter-editor.component';
|
||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component';
|
||||
|
||||
@Component({
|
||||
@@ -19,15 +19,20 @@ export class DocumentListComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
public list: DocumentListViewService,
|
||||
public savedViewConfigService: SavedViewConfigService,
|
||||
public savedViewService: SavedViewService,
|
||||
public route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private toastService: ToastService,
|
||||
public modalService: NgbModal) { }
|
||||
|
||||
@ViewChild("filterEditor")
|
||||
private filterEditor: FilterEditorComponent
|
||||
|
||||
displayMode = 'smallCards' // largeCards, smallCards, details
|
||||
|
||||
filterRules: FilterRule[] = []
|
||||
showFilter = false
|
||||
get isFiltered() {
|
||||
return this.list.filterRules?.length > 0
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.list.savedViewTitle || "Documents"
|
||||
@@ -46,91 +51,66 @@ export class DocumentListComponent implements OnInit {
|
||||
this.displayMode = localStorage.getItem('document-list:displayMode')
|
||||
}
|
||||
this.route.paramMap.subscribe(params => {
|
||||
this.list.clear()
|
||||
if (params.has('id')) {
|
||||
this.list.savedView = this.savedViewConfigService.getConfig(params.get('id'))
|
||||
this.filterRules = this.list.filterRules
|
||||
this.showFilter = false
|
||||
this.savedViewService.getCached(+params.get('id')).subscribe(view => {
|
||||
if (!view) {
|
||||
this.router.navigate(["404"])
|
||||
return
|
||||
}
|
||||
|
||||
this.list.savedView = view
|
||||
this.list.reload()
|
||||
})
|
||||
} else {
|
||||
this.list.savedView = null
|
||||
this.filterRules = this.list.filterRules
|
||||
this.showFilter = this.filterRules.length > 0
|
||||
this.list.reload()
|
||||
}
|
||||
this.list.clear()
|
||||
this.list.reload()
|
||||
})
|
||||
}
|
||||
|
||||
applyFilterRules() {
|
||||
this.list.filterRules = this.filterRules
|
||||
}
|
||||
|
||||
clearFilterRules() {
|
||||
this.list.filterRules = this.filterRules
|
||||
this.showFilter = false
|
||||
}
|
||||
|
||||
loadViewConfig(config: SavedViewConfig) {
|
||||
this.filterRules = cloneFilterRules(config.filterRules)
|
||||
this.list.load(config)
|
||||
loadViewConfig(view: PaperlessSavedView) {
|
||||
this.list.load(view)
|
||||
this.list.reload()
|
||||
}
|
||||
|
||||
saveViewConfig() {
|
||||
this.savedViewConfigService.updateConfig(this.list.savedView)
|
||||
this.toastService.showToast(Toast.make("Information", `View "${this.list.savedView.title}" saved successfully.`))
|
||||
this.savedViewService.update(this.list.savedView).subscribe(result => {
|
||||
this.toastService.showToast(Toast.make("Information", `View "${this.list.savedView.name}" saved successfully.`))
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
saveViewConfigAs() {
|
||||
let modal = this.modalService.open(SaveViewConfigDialogComponent, {backdrop: 'static'})
|
||||
modal.componentInstance.defaultName = this.filterEditor.generateFilterName()
|
||||
modal.componentInstance.saveClicked.subscribe(formValue => {
|
||||
this.savedViewConfigService.newConfig({
|
||||
title: formValue.title,
|
||||
showInDashboard: formValue.showInDashboard,
|
||||
showInSideBar: formValue.showInSideBar,
|
||||
filterRules: this.list.filterRules,
|
||||
sortDirection: this.list.sortDirection,
|
||||
sortField: this.list.sortField
|
||||
let savedView = {
|
||||
name: formValue.name,
|
||||
show_on_dashboard: formValue.showOnDashboard,
|
||||
show_in_sidebar: formValue.showInSideBar,
|
||||
filter_rules: this.list.filterRules,
|
||||
sort_reverse: this.list.sortReverse,
|
||||
sort_field: this.list.sortField
|
||||
}
|
||||
this.savedViewService.create(savedView).subscribe(() => {
|
||||
modal.close()
|
||||
this.toastService.showToast(Toast.make("Information", `View "${savedView.name}" created successfully.`))
|
||||
})
|
||||
modal.close()
|
||||
})
|
||||
}
|
||||
|
||||
filterByTag(tag_id: number) {
|
||||
let filterRules = this.list.filterRules
|
||||
if (filterRules.find(rule => rule.type.id == FILTER_HAS_TAG && rule.value == tag_id)) {
|
||||
return
|
||||
}
|
||||
|
||||
filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_HAS_TAG), value: tag_id})
|
||||
this.filterRules = filterRules
|
||||
this.applyFilterRules()
|
||||
clickTag(tagID: number) {
|
||||
this.filterEditor.toggleTag(tagID)
|
||||
}
|
||||
|
||||
filterByCorrespondent(correspondent_id: number) {
|
||||
let filterRules = this.list.filterRules
|
||||
let existing_rule = filterRules.find(rule => rule.type.id == FILTER_CORRESPONDENT)
|
||||
if (existing_rule && existing_rule.value == correspondent_id) {
|
||||
return
|
||||
} else if (existing_rule) {
|
||||
existing_rule.value = correspondent_id
|
||||
} else {
|
||||
filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_CORRESPONDENT), value: correspondent_id})
|
||||
}
|
||||
this.filterRules = filterRules
|
||||
this.applyFilterRules()
|
||||
clickCorrespondent(correspondentID: number) {
|
||||
this.filterEditor.toggleCorrespondent(correspondentID)
|
||||
}
|
||||
|
||||
filterByDocumentType(document_type_id: number) {
|
||||
let filterRules = this.list.filterRules
|
||||
let existing_rule = filterRules.find(rule => rule.type.id == FILTER_DOCUMENT_TYPE)
|
||||
if (existing_rule && existing_rule.value == document_type_id) {
|
||||
return
|
||||
} else if (existing_rule) {
|
||||
existing_rule.value = document_type_id
|
||||
} else {
|
||||
filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_DOCUMENT_TYPE), value: document_type_id})
|
||||
}
|
||||
this.filterRules = filterRules
|
||||
this.applyFilterRules()
|
||||
clickDocumentType(documentTypeID: number) {
|
||||
this.filterEditor.toggleDocumentType(documentTypeID)
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -6,9 +6,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<app-input-text title="Title" formControlName="title"></app-input-text>
|
||||
<app-input-text title="Name" formControlName="name"></app-input-text>
|
||||
<app-input-check title="Show in side bar" formControlName="showInSideBar"></app-input-check>
|
||||
<app-input-check title="Show in dashboard" formControlName="showInDashboard"></app-input-check>
|
||||
<app-input-check title="Show on dashboard" formControlName="showOnDashboard"></app-input-check>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@@ -14,10 +14,23 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
@Output()
|
||||
public saveClicked = new EventEmitter()
|
||||
|
||||
_defaultName = ""
|
||||
|
||||
get defaultName() {
|
||||
return this._defaultName
|
||||
}
|
||||
|
||||
@Input()
|
||||
set defaultName(value: string) {
|
||||
this._defaultName = value
|
||||
this.saveViewConfigForm.patchValue({name: value})
|
||||
}
|
||||
|
||||
|
||||
saveViewConfigForm = new FormGroup({
|
||||
title: new FormControl(''),
|
||||
name: new FormControl(''),
|
||||
showInSideBar: new FormControl(false),
|
||||
showInDashboard: new FormControl(false),
|
||||
showOnDashboard: new FormControl(false),
|
||||
})
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@@ -0,0 +1,43 @@
|
||||
<div class="btn-group" ngbDropdown role="group">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'">
|
||||
{{title}}
|
||||
</button>
|
||||
<div class="dropdown-menu date-filter shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div class="list-group list-group-flush">
|
||||
<button class="list-group-item small list-goup list-group-item-action d-flex p-2 pl-3" (click)="clear()">Clear</button>
|
||||
<button *ngFor="let range of [7, 30, 'month', 'year']" class="list-group-item small list-goup list-group-item-action d-flex p-2 pl-3" role="menuitem" (click)="setDateQuickFilter(range)">
|
||||
<ng-container *ngIf="isStringRange(range)">This </ng-container>
|
||||
{{ range }}
|
||||
<ng-container *ngIf="!isStringRange(range)"> days</ng-container>
|
||||
</button>
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
<div>Before</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" type="text" placeholder="yyyy-mm-dd" name="before" [(ngModel)]="_dateBefore" [maxDate]="this._maxDate" ngbDatepicker (dateSelect)="onBeforeSelected($event)" #dpBefore="ngbDatepicker">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary btn-sm" (click)="dpBefore.toggle()" type="button">
|
||||
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-calendar-date" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
<path d="M6.445 11.688V6.354h-.633A12.6 12.6 0 0 0 4.5 7.16v.695c.375-.257.969-.62 1.258-.777h.012v4.61h.675zm1.188-1.305c.047.64.594 1.406 1.703 1.406 1.258 0 2-1.066 2-2.871 0-1.934-.781-2.668-1.953-2.668-.926 0-1.797.672-1.797 1.809 0 1.16.824 1.77 1.676 1.77.746 0 1.23-.376 1.383-.79h.027c-.004 1.316-.461 2.164-1.305 2.164-.664 0-1.008-.45-1.05-.82h-.684zm2.953-2.317c0 .696-.559 1.18-1.184 1.18-.601 0-1.144-.383-1.144-1.2 0-.823.582-1.21 1.168-1.21.633 0 1.16.398 1.16 1.23z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
<div>After</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control form-control-sm" type="text" placeholder="yyyy-mm-dd" name="after" [(ngModel)]="_dateAfter" [maxDate]="this._maxDate" ngbDatepicker (dateSelect)="onAfterSelected($event)" #dpAfter="ngbDatepicker">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary btn-sm" (click)="dpAfter.toggle()" type="button">
|
||||
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-calendar-date" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
<path d="M6.445 11.688V6.354h-.633A12.6 12.6 0 0 0 4.5 7.16v.695c.375-.257.969-.62 1.258-.777h.012v4.61h.675zm1.188-1.305c.047.64.594 1.406 1.703 1.406 1.258 0 2-1.066 2-2.871 0-1.934-.781-2.668-1.953-2.668-.926 0-1.797.672-1.797 1.809 0 1.16.824 1.77 1.676 1.77.746 0 1.23-.376 1.383-.79h.027c-.004 1.316-.461 2.164-1.305 2.164-.664 0-1.008-.45-1.05-.82h-.684zm2.953-2.317c0 .696-.559 1.18-1.184 1.18-.601 0-1.144-.383-1.144-1.2 0-.823.582-1.21 1.168-1.21.633 0 1.16.398 1.16 1.23z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,7 @@
|
||||
.date-filter {
|
||||
min-width: 250px;
|
||||
|
||||
.btn-link {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FilterDropdownDateComponent } from './filter-dropdown-date.component';
|
||||
|
||||
describe('FilterDropdownDateComponent', () => {
|
||||
let component: FilterDropdownDateComponent;
|
||||
let fixture: ComponentFixture<FilterDropdownDateComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FilterDropdownDateComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FilterDropdownDateComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,112 @@
|
||||
import { Component, EventEmitter, Input, Output, ElementRef, ViewChild, SimpleChange } from '@angular/core';
|
||||
import { NgbDate, NgbDateStruct, NgbDatepicker } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
export interface DateSelection {
|
||||
before?: NgbDateStruct
|
||||
after?: NgbDateStruct
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-dropdown-date',
|
||||
templateUrl: './filter-dropdown-date.component.html',
|
||||
styleUrls: ['./filter-dropdown-date.component.scss']
|
||||
})
|
||||
export class FilterDropdownDateComponent {
|
||||
|
||||
@Input()
|
||||
dateBefore: NgbDateStruct
|
||||
|
||||
@Input()
|
||||
dateAfter: NgbDateStruct
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Output()
|
||||
datesSet = new EventEmitter<DateSelection>()
|
||||
|
||||
@ViewChild('dpAfter') dpAfter: NgbDatepicker
|
||||
@ViewChild('dpBefore') dpBefore: NgbDatepicker
|
||||
|
||||
_dateBefore: NgbDateStruct
|
||||
_dateAfter: NgbDateStruct
|
||||
|
||||
get _maxDate(): NgbDate {
|
||||
let date = new Date()
|
||||
return NgbDate.from({year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate()})
|
||||
}
|
||||
|
||||
isStringRange(range: any) {
|
||||
return typeof range == 'string'
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChange) {
|
||||
// this is a hacky workaround perhaps because of https://github.com/angular/angular/issues/11097
|
||||
let dateString: string = ''
|
||||
let dateAfterChange: SimpleChange
|
||||
let dateBeforeChange: SimpleChange
|
||||
if (changes) {
|
||||
dateAfterChange = changes['dateAfter']
|
||||
dateBeforeChange = changes['dateBefore']
|
||||
}
|
||||
|
||||
if (this.dpBefore && this.dpAfter) {
|
||||
let dpAfterElRef: ElementRef = this.dpAfter['_elRef']
|
||||
let dpBeforeElRef: ElementRef = this.dpBefore['_elRef']
|
||||
|
||||
if (dateAfterChange && dateAfterChange.currentValue) {
|
||||
let dateAfterDate = dateAfterChange.currentValue as NgbDateStruct
|
||||
dateString = `${dateAfterDate.year}-${dateAfterDate.month.toString().padStart(2,'0')}-${dateAfterDate.day.toString().padStart(2,'0')}`
|
||||
dpAfterElRef.nativeElement.value = dateString
|
||||
} else if (dateBeforeChange && dateBeforeChange.currentValue) {
|
||||
let dateBeforeDate = dateBeforeChange.currentValue as NgbDateStruct
|
||||
dateString = `${dateBeforeDate.year}-${dateBeforeDate.month.toString().padStart(2,'0')}-${dateBeforeDate.day.toString().padStart(2,'0')}`
|
||||
dpBeforeElRef.nativeElement.value = dateString
|
||||
} else {
|
||||
dpAfterElRef.nativeElement.value = dateString
|
||||
dpBeforeElRef.nativeElement.value = dateString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDateQuickFilter(range: any) {
|
||||
let date = new Date()
|
||||
let newDate: NgbDateStruct = { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }
|
||||
switch (typeof range) {
|
||||
case 'number':
|
||||
date.setDate(date.getDate() - range)
|
||||
newDate.year = date.getFullYear()
|
||||
newDate.month = date.getMonth() + 1
|
||||
newDate.day = date.getDate()
|
||||
break
|
||||
|
||||
case 'string':
|
||||
newDate.day = 1
|
||||
if (range == 'year') newDate.month = 1
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
this._dateAfter = newDate
|
||||
this._dateBefore = null
|
||||
this.datesSet.emit({after: newDate, before: null})
|
||||
}
|
||||
|
||||
onBeforeSelected(date: NgbDateStruct) {
|
||||
this._dateBefore = date
|
||||
this.datesSet.emit({after: this._dateAfter, before: date})
|
||||
}
|
||||
|
||||
onAfterSelected(date: NgbDateStruct) {
|
||||
this._dateAfter = date
|
||||
this.datesSet.emit({after: date, before: this._dateBefore})
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._dateBefore = null
|
||||
this._dateAfter = null
|
||||
this.datesSet.emit({after: null, before: null})
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-left-0 border-right-0 border-bottom" role="menuitem" (click)="toggleItem()">
|
||||
<div class="selected-icon mr-1">
|
||||
<svg *ngIf="selected" width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-check" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.236.236 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mr-1">
|
||||
<app-tag *ngIf="isTag; else displayName" [tag]="item" [clickable]="true" linkTitle="Filter by tag"></app-tag>
|
||||
<ng-template #displayName><small>{{item.name}}</small></ng-template>
|
||||
</div>
|
||||
<div class="badge badge-light rounded-pill ml-auto mr-1">{{item.document_count}}</div>
|
||||
</button>
|
@@ -0,0 +1,4 @@
|
||||
.selected-icon {
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FilterDropodownButtonComponent } from './filter-dropdown-button.component';
|
||||
|
||||
describe('FilterDropodownButtonComponent', () => {
|
||||
let component: FilterDropodownButtonComponent;
|
||||
let fixture: ComponentFixture<FilterDropodownButtonComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FilterDropodownButtonComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FilterDropodownButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,32 @@
|
||||
import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core';
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
|
||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-dropdown-button',
|
||||
templateUrl: './filter-dropdown-button.component.html',
|
||||
styleUrls: ['./filter-dropdown-button.component.scss']
|
||||
})
|
||||
export class FilterDropdownButtonComponent implements OnInit {
|
||||
|
||||
@Input()
|
||||
item: PaperlessTag | PaperlessDocumentType | PaperlessCorrespondent
|
||||
|
||||
@Input()
|
||||
selected: boolean
|
||||
|
||||
@Output()
|
||||
toggle = new EventEmitter()
|
||||
|
||||
isTag: boolean
|
||||
|
||||
ngOnInit() {
|
||||
this.isTag = 'is_inbox_tag' in this.item // ~ this.item instanceof PaperlessTag
|
||||
}
|
||||
|
||||
toggleItem(): void {
|
||||
this.selected = !this.selected
|
||||
this.toggle.emit(this.item)
|
||||
}
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
<div class="btn-group" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #filterDropdown="ngbDropdown">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="itemsSelected?.length > 0 ? 'btn-primary' : 'btn-outline-primary'">
|
||||
<div class="d-none d-md-inline">{{title}}</div>
|
||||
<div class="d-inline-block d-md-none">
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
|
||||
</svg>
|
||||
</div>
|
||||
<ng-container *ngIf="itemsSelected?.length > 0">
|
||||
<div class="badge bg-secondary text-light rounded-pill badge-corner">
|
||||
{{itemsSelected?.length}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</button>
|
||||
<div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div class="list-group list-group-flush">
|
||||
<div class="list-group-item">
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" type="text" [(ngModel)]="filterText" placeholder="Filter {{title}}" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="items" class="items">
|
||||
<ng-container *ngFor="let item of items | filter: filterText; let i = index">
|
||||
<app-filter-dropdown-button [item]="item" [selected]="isItemSelected(item)" (toggle)="toggleItem($event)"></app-filter-dropdown-button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,14 @@
|
||||
.badge-corner {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 250px;
|
||||
|
||||
.items {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FilterDropodownComponent } from './filter-dropdown.component';
|
||||
|
||||
describe('FilterDropodownComponent', () => {
|
||||
let component: FilterDropodownComponent;
|
||||
let fixture: ComponentFixture<FilterDropodownComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FilterDropodownComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FilterDropodownComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,58 @@
|
||||
import { Component, EventEmitter, Input, Output, ElementRef, ViewChild } from '@angular/core';
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id';
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe';
|
||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-dropdown',
|
||||
templateUrl: './filter-dropdown.component.html',
|
||||
styleUrls: ['./filter-dropdown.component.scss']
|
||||
})
|
||||
export class FilterDropdownComponent {
|
||||
|
||||
constructor(private filterPipe: FilterPipe) { }
|
||||
|
||||
@Input()
|
||||
items: ObjectWithId[]
|
||||
|
||||
@Input()
|
||||
itemsSelected: ObjectWithId[]
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Input()
|
||||
icon: string
|
||||
|
||||
@Output()
|
||||
toggle = new EventEmitter()
|
||||
|
||||
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||
@ViewChild('filterDropdown') filterDropdown: NgbDropdown
|
||||
|
||||
filterText: string
|
||||
|
||||
toggleItem(item: ObjectWithId): void {
|
||||
this.toggle.emit(item)
|
||||
}
|
||||
|
||||
isItemSelected(item: ObjectWithId): boolean {
|
||||
return this.itemsSelected?.find(i => i.id == item.id) !== undefined
|
||||
}
|
||||
|
||||
dropdownOpenChange(open: boolean): void {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
this.listFilterTextInput.nativeElement.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
this.filterText = ''
|
||||
}
|
||||
}
|
||||
|
||||
listFilterEnter(): void {
|
||||
let filtered = this.filterPipe.transform(this.items, this.filterText)
|
||||
if (filtered.length == 1) this.toggleItem(filtered.shift())
|
||||
this.filterDropdown.close()
|
||||
}
|
||||
}
|
@@ -1,52 +1,27 @@
|
||||
<div *ngFor="let rule of filterRules" class="form-row form-group">
|
||||
<div class="col-md-3 col-form-label">
|
||||
<span>{{rule.type.name}}</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<input *ngIf="rule.type.datatype == 'string'" type="text" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
||||
<input *ngIf="rule.type.datatype == 'number'" type="number" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
||||
<input *ngIf="rule.type.datatype == 'date'" type="date" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
||||
|
||||
<select *ngIf="rule.type.datatype == 'tag'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
||||
<option *ngFor="let t of tags" [ngValue]="t.id">{{t.name}}</option>
|
||||
</select>
|
||||
|
||||
<select *ngIf="rule.type.datatype == 'document_type'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
||||
<option *ngFor="let dt of documentTypes" [ngValue]="dt.id">{{dt.name}}</option>
|
||||
</select>
|
||||
|
||||
<select *ngIf="rule.type.datatype == 'correspondent'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
||||
<option *ngFor="let c of correspondents" [ngValue]="c.id">{{c.name}}</option>
|
||||
</select>
|
||||
|
||||
<select *ngIf="rule.type.datatype == 'boolean'" class="form-control form-control-sm" [(ngModel)]="rule.value">
|
||||
<option [ngValue]="true">Yes</option>
|
||||
<option [ngValue]="false">No</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="removeRuleClicked(rule)">
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-group">
|
||||
<div class="col">
|
||||
<select [(ngModel)]="selectedRuleType" class="form-control form-control-sm">
|
||||
<option *ngFor="let ruleType of getRuleTypes()" [ngValue]="ruleType">{{ruleType.name}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button (click)="newRuleClicked()" class="btn btn-sm btn-outline-secondary">Add</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button (click)="clearClicked()" class="btn btn-sm btn-outline-secondary">Clear</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button (click)="applyClicked()" class="btn btn-sm btn-outline-secondary">Apply</button>
|
||||
<div class="row">
|
||||
<div class="col mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex">
|
||||
<label class="text-muted mr-2">Filter by:</label>
|
||||
<input class="form-control form-control-sm flex-grow-1" type="text" [(ngModel)]="titleFilter" placeholder="Title">
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-100 d-xl-none"></div>
|
||||
<div class="col col-xl-auto mb-2 mb-xl-0">
|
||||
<div class="d-flex">
|
||||
<app-filter-dropdown class="mr-2 mr-md-3" [items]="tags" [itemsSelected]="selectedTags" title="Tags" icon="tag-fill" (toggle)="toggleTag($event.id)"></app-filter-dropdown>
|
||||
<app-filter-dropdown class="mr-2 mr-md-3" [items]="correspondents" [itemsSelected]="selectedCorrespondents" title="Correspondents" icon="person-fill" (toggle)="toggleCorrespondent($event.id)"></app-filter-dropdown>
|
||||
<app-filter-dropdown class="mr-2 mr-md-3" [items]="documentTypes" [itemsSelected]="selectedDocumentTypes" title="Document types" icon="file-earmark-fill" (toggle)="toggleDocumentType($event.id)"></app-filter-dropdown>
|
||||
<app-filter-dropdown-date class="mr-2 mr-md-3" [dateBefore]="dateCreatedBefore" [dateAfter]="dateCreatedAfter" title="Created" (datesSet)="onDatesCreatedSet($event)"></app-filter-dropdown-date>
|
||||
<app-filter-dropdown-date [dateBefore]="dateAddedBefore" [dateAfter]="dateAddedAfter" title="Added" (datesSet)="onDatesAddedSet($event)"></app-filter-dropdown-date>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-100 d-xl-none"></div>
|
||||
<div class="col col-xl-auto mb-2 mb-xl-0">
|
||||
<button class="btn btn-link btn-sm px-0 mx-0 ml-xl-n4" [disabled]="!hasFilters()" (click)="clearSelected()">
|
||||
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,10 @@
|
||||
.quick-filter {
|
||||
min-width: 250px;
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
|
||||
.selected-icon {
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
}
|
||||
}
|
||||
|
@@ -1,67 +1,240 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { FilterRule } from 'src/app/data/filter-rule';
|
||||
import { FilterRuleType, FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
|
||||
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core';
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
|
||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { NgbDateParserFormatter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
|
||||
import { TagService } from 'src/app/services/rest/tag.service';
|
||||
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
||||
import { FilterRule } from 'src/app/data/filter-rule';
|
||||
import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES, FILTER_TITLE } from 'src/app/data/filter-rule-type';
|
||||
import { DateSelection } from './filter-dropdown-date/filter-dropdown-date.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-editor',
|
||||
templateUrl: './filter-editor.component.html',
|
||||
styleUrls: ['./filter-editor.component.scss']
|
||||
})
|
||||
export class FilterEditorComponent implements OnInit {
|
||||
export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(private documentTypeService: DocumentTypeService, private tagService: TagService, private correspondentService: CorrespondentService) { }
|
||||
generateFilterName() {
|
||||
if (this.filterRules.length == 1) {
|
||||
let rule = this.filterRules[0]
|
||||
switch(this.filterRules[0].rule_type) {
|
||||
|
||||
case FILTER_CORRESPONDENT:
|
||||
return `Correspondent: ${this.correspondents.find(c => c.id == +rule.value)?.name}`
|
||||
|
||||
@Output()
|
||||
clear = new EventEmitter()
|
||||
case FILTER_DOCUMENT_TYPE:
|
||||
return `Type: ${this.documentTypes.find(dt => dt.id == +rule.value)?.name}`
|
||||
|
||||
@Input()
|
||||
filterRules: FilterRule[] = []
|
||||
case FILTER_HAS_TAG:
|
||||
return `Tag: ${this.tags.find(t => t.id == +rule.value)?.name}`
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter()
|
||||
}
|
||||
}
|
||||
|
||||
selectedRuleType: FilterRuleType = FILTER_RULE_TYPES[0]
|
||||
|
||||
correspondents: PaperlessCorrespondent[] = []
|
||||
tags: PaperlessTag[] = []
|
||||
documentTypes: PaperlessDocumentType[] = []
|
||||
|
||||
newRuleClicked() {
|
||||
this.filterRules.push({type: this.selectedRuleType, value: null})
|
||||
this.selectedRuleType = this.getRuleTypes().length > 0 ? this.getRuleTypes()[0] : null
|
||||
return ""
|
||||
}
|
||||
|
||||
removeRuleClicked(rule) {
|
||||
let index = this.filterRules.findIndex(r => r == rule)
|
||||
if (index > -1) {
|
||||
this.filterRules.splice(index, 1)
|
||||
constructor(
|
||||
private documentTypeService: DocumentTypeService,
|
||||
private tagService: TagService,
|
||||
private correspondentService: CorrespondentService,
|
||||
private dateParser: NgbDateParserFormatter
|
||||
) { }
|
||||
|
||||
tags: PaperlessTag[] = []
|
||||
correspondents: PaperlessCorrespondent[]
|
||||
documentTypes: PaperlessDocumentType[] = []
|
||||
|
||||
@Input()
|
||||
filterRules: FilterRule[]
|
||||
|
||||
@Output()
|
||||
filterRulesChange = new EventEmitter<FilterRule[]>()
|
||||
|
||||
hasFilters() {
|
||||
return this.filterRules.length > 0
|
||||
}
|
||||
|
||||
get selectedTags(): PaperlessTag[] {
|
||||
let tagRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_HAS_TAG)
|
||||
return this.tags?.filter(t => tagRules.find(tr => +tr.value == t.id))
|
||||
}
|
||||
|
||||
get selectedCorrespondents(): PaperlessCorrespondent[] {
|
||||
let correspondentRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_CORRESPONDENT)
|
||||
return this.correspondents?.filter(c => correspondentRules.find(cr => +cr.value == c.id))
|
||||
}
|
||||
|
||||
get selectedDocumentTypes(): PaperlessDocumentType[] {
|
||||
let documentTypeRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_DOCUMENT_TYPE)
|
||||
return this.documentTypes?.filter(dt => documentTypeRules.find(dtr => +dtr.value == dt.id))
|
||||
}
|
||||
|
||||
get titleFilter() {
|
||||
let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE)
|
||||
return existingRule ? existingRule.value : ''
|
||||
}
|
||||
|
||||
set titleFilter(value) {
|
||||
this.titleFilterDebounce.next(value)
|
||||
}
|
||||
|
||||
titleFilterDebounce: Subject<string>
|
||||
subscription: Subscription
|
||||
|
||||
ngOnInit() {
|
||||
this.tagService.listAll().subscribe(result => this.tags = result.results)
|
||||
this.correspondentService.listAll().subscribe(result => this.correspondents = result.results)
|
||||
this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
|
||||
|
||||
this.titleFilterDebounce = new Subject<string>()
|
||||
|
||||
this.subscription = this.titleFilterDebounce.pipe(
|
||||
debounceTime(400),
|
||||
distinctUntilChanged()
|
||||
).subscribe(title => {
|
||||
this.setTitleRule(title)
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.titleFilterDebounce.complete()
|
||||
// TODO: not sure if both is necessary
|
||||
this.subscription.unsubscribe()
|
||||
}
|
||||
|
||||
applyFilters() {
|
||||
this.filterRulesChange.next(this.filterRules)
|
||||
}
|
||||
|
||||
clearSelected() {
|
||||
this.filterRules = []
|
||||
this.applyFilters()
|
||||
}
|
||||
|
||||
private toggleFilterRule(filterRuleTypeID: number, value: number) {
|
||||
|
||||
let filterRuleType = FILTER_RULE_TYPES.find(t => t.id == filterRuleTypeID)
|
||||
|
||||
let existingRule = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID && rule.value == value?.toString())
|
||||
let existingRuleOfSameType = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID)
|
||||
|
||||
if (existingRule) {
|
||||
// if this exact rule already exists, remove it in all cases.
|
||||
this.filterRules.splice(this.filterRules.indexOf(existingRule), 1)
|
||||
} else if (filterRuleType.multi || !existingRuleOfSameType) {
|
||||
// if we allow multiple rules per type, or no rule of this type already exists, push a new rule.
|
||||
this.filterRules.push({rule_type: filterRuleTypeID, value: value?.toString()})
|
||||
} else {
|
||||
// otherwise (i.e., no multi support AND there's already a rule of this type), update the rule.
|
||||
existingRuleOfSameType.value = value?.toString()
|
||||
}
|
||||
this.applyFilters()
|
||||
}
|
||||
|
||||
private setTitleRule(title: string) {
|
||||
let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE)
|
||||
|
||||
if (!existingRule && title) {
|
||||
this.filterRules.push({rule_type: FILTER_TITLE, value: title})
|
||||
} else if (existingRule && !title) {
|
||||
this.filterRules.splice(this.filterRules.findIndex(rule => rule.rule_type == FILTER_TITLE), 1)
|
||||
} else if (existingRule && title) {
|
||||
existingRule.value = title
|
||||
}
|
||||
this.applyFilters()
|
||||
}
|
||||
|
||||
toggleTag(tagId: number) {
|
||||
this.toggleFilterRule(FILTER_HAS_TAG, tagId)
|
||||
}
|
||||
|
||||
toggleCorrespondent(correspondentId: number) {
|
||||
this.toggleFilterRule(FILTER_CORRESPONDENT, correspondentId)
|
||||
}
|
||||
|
||||
toggleDocumentType(documentTypeId: number) {
|
||||
this.toggleFilterRule(FILTER_DOCUMENT_TYPE, documentTypeId)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Date handling
|
||||
|
||||
|
||||
onDatesCreatedSet(dates: DateSelection) {
|
||||
this.setDateCreatedBefore(dates.before)
|
||||
this.setDateCreatedAfter(dates.after)
|
||||
this.applyFilters()
|
||||
}
|
||||
|
||||
onDatesAddedSet(dates: DateSelection) {
|
||||
this.setDateAddedBefore(dates.before)
|
||||
this.setDateAddedAfter(dates.after)
|
||||
this.applyFilters()
|
||||
}
|
||||
|
||||
get dateCreatedBefore(): NgbDateStruct {
|
||||
let createdBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_BEFORE)
|
||||
return createdBeforeRule ? this.dateParser.parse(createdBeforeRule.value) : null
|
||||
}
|
||||
|
||||
get dateCreatedAfter(): NgbDateStruct {
|
||||
let createdAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_AFTER)
|
||||
return createdAfterRule ? this.dateParser.parse(createdAfterRule.value) : null
|
||||
}
|
||||
|
||||
get dateAddedBefore(): NgbDateStruct {
|
||||
let addedBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_BEFORE)
|
||||
return addedBeforeRule ? this.dateParser.parse(addedBeforeRule.value) : null
|
||||
}
|
||||
|
||||
get dateAddedAfter(): NgbDateStruct {
|
||||
let addedAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_AFTER)
|
||||
return addedAfterRule ? this.dateParser.parse(addedAfterRule.value) : null
|
||||
}
|
||||
|
||||
setDateCreatedBefore(date?: NgbDateStruct) {
|
||||
if (date) this.setDateFilter(date, FILTER_CREATED_BEFORE)
|
||||
else this.clearDateFilter(FILTER_CREATED_BEFORE)
|
||||
}
|
||||
|
||||
setDateCreatedAfter(date?: NgbDateStruct) {
|
||||
if (date) this.setDateFilter(date, FILTER_CREATED_AFTER)
|
||||
else this.clearDateFilter(FILTER_CREATED_AFTER)
|
||||
}
|
||||
|
||||
setDateAddedBefore(date?: NgbDateStruct) {
|
||||
if (date) this.setDateFilter(date, FILTER_ADDED_BEFORE)
|
||||
else this.clearDateFilter(FILTER_ADDED_BEFORE)
|
||||
}
|
||||
|
||||
setDateAddedAfter(date?: NgbDateStruct) {
|
||||
if (date) this.setDateFilter(date, FILTER_ADDED_AFTER)
|
||||
else this.clearDateFilter(FILTER_ADDED_AFTER)
|
||||
}
|
||||
|
||||
setDateFilter(date: NgbDateStruct, dateRuleTypeID: number) {
|
||||
let existingRule = this.filterRules.find(rule => rule.rule_type == dateRuleTypeID)
|
||||
let newValue = this.dateParser.format(date)
|
||||
|
||||
if (existingRule) {
|
||||
existingRule.value = newValue
|
||||
} else {
|
||||
this.filterRules.push({rule_type: dateRuleTypeID, value: newValue})
|
||||
}
|
||||
}
|
||||
|
||||
applyClicked() {
|
||||
this.apply.next()
|
||||
}
|
||||
|
||||
clearClicked() {
|
||||
this.filterRules.splice(0,this.filterRules.length)
|
||||
this.clear.next()
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.correspondentService.listAll().subscribe(result => {this.correspondents = result.results})
|
||||
this.tagService.listAll().subscribe(result => this.tags = result.results)
|
||||
this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
|
||||
}
|
||||
|
||||
getRuleTypes() {
|
||||
return FILTER_RULE_TYPES.filter(rt => rt.multi || !this.filterRules.find(r => r.type == rt))
|
||||
clearDateFilter(dateRuleTypeID: number) {
|
||||
let ruleIndex = this.filterRules.findIndex(rule => rule.rule_type == dateRuleTypeID)
|
||||
if (ruleIndex != -1) {
|
||||
this.filterRules.splice(ruleIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component';
|
||||
|
@@ -26,9 +26,26 @@
|
||||
<td scope="row">{{ correspondent.last_correspondence | date }}</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(correspondent)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(correspondent)">Delete</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(correspondent)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>
|
||||
</svg>
|
||||
Documents
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(correspondent)">
|
||||
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(correspondent)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type';
|
||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
|
||||
import { GenericListComponent } from '../generic-list/generic-list.component';
|
||||
import { CorrespondentEditDialogComponent } from './correspondent-edit-dialog/correspondent-edit-dialog.component';
|
||||
@@ -12,12 +15,21 @@ import { CorrespondentEditDialogComponent } from './correspondent-edit-dialog/co
|
||||
})
|
||||
export class CorrespondentListComponent extends GenericListComponent<PaperlessCorrespondent> {
|
||||
|
||||
constructor(correspondentsService: CorrespondentService,
|
||||
modalService: NgbModal) {
|
||||
super(correspondentsService,modalService,CorrespondentEditDialogComponent)
|
||||
}
|
||||
constructor(correspondentsService: CorrespondentService, modalService: NgbModal,
|
||||
private router: Router,
|
||||
private list: DocumentListViewService
|
||||
) {
|
||||
super(correspondentsService,modalService,CorrespondentEditDialogComponent)
|
||||
}
|
||||
|
||||
getObjectName(object: PaperlessCorrespondent) {
|
||||
return `correspondent '${object.name}'`
|
||||
}
|
||||
getObjectName(object: PaperlessCorrespondent) {
|
||||
return `correspondent '${object.name}'`
|
||||
}
|
||||
|
||||
filterDocuments(object: PaperlessCorrespondent) {
|
||||
this.list.documentListView.filter_rules = [
|
||||
{rule_type: FILTER_CORRESPONDENT, value: object.id.toString()}
|
||||
]
|
||||
this.router.navigate(["documents"])
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component';
|
||||
|
@@ -25,8 +25,25 @@
|
||||
<td scope="row">{{ document_type.document_count }}</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(document_type)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(document_type)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(document_type)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>
|
||||
</svg>
|
||||
Documents
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(document_type)">
|
||||
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(document_type)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type';
|
||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
|
||||
import { GenericListComponent } from '../generic-list/generic-list.component';
|
||||
import { DocumentTypeEditDialogComponent } from './document-type-edit-dialog/document-type-edit-dialog.component';
|
||||
@@ -12,11 +15,21 @@ import { DocumentTypeEditDialogComponent } from './document-type-edit-dialog/doc
|
||||
})
|
||||
export class DocumentTypeListComponent extends GenericListComponent<PaperlessDocumentType> {
|
||||
|
||||
constructor(service: DocumentTypeService, modalService: NgbModal) {
|
||||
constructor(service: DocumentTypeService, modalService: NgbModal,
|
||||
private router: Router,
|
||||
private list: DocumentListViewService
|
||||
) {
|
||||
super(service, modalService, DocumentTypeEditDialogComponent)
|
||||
}
|
||||
}
|
||||
|
||||
getObjectName(object: PaperlessDocumentType) {
|
||||
getObjectName(object: PaperlessDocumentType) {
|
||||
return `document type '${object.name}'`
|
||||
}
|
||||
|
||||
filterDocuments(object: PaperlessDocumentType) {
|
||||
this.list.documentListView.filter_rules = [
|
||||
{rule_type: FILTER_DOCUMENT_TYPE, value: object.id.toString()}
|
||||
]
|
||||
this.router.navigate(["documents"])
|
||||
}
|
||||
}
|
||||
|
@@ -4,13 +4,13 @@ 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 {
|
||||
|
||||
|
||||
constructor(
|
||||
private service: AbstractPaperlessService<T>,
|
||||
private service: AbstractPaperlessService<T>,
|
||||
private modalService: NgbModal,
|
||||
private editDialogComponent: any) {
|
||||
}
|
||||
@@ -60,7 +60,8 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On
|
||||
}
|
||||
|
||||
reloadData() {
|
||||
this.service.list(this.page, null, this.sortField, this.sortDirection).subscribe(c => {
|
||||
// TODO: this is a hack
|
||||
this.service.list(this.page, null, this.sortField, this.sortDirection == 'des').subscribe(c => {
|
||||
this.data = c.results
|
||||
this.collectionSize = c.count
|
||||
});
|
||||
@@ -88,10 +89,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()
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { kMaxLength } from 'buffer';
|
||||
import { LOG_LEVELS, LOG_LEVEL_INFO, PaperlessLog } from 'src/app/data/paperless-log';
|
||||
import { LogService } from 'src/app/services/rest/log.service';
|
||||
|
||||
@@ -20,7 +19,7 @@ export class LogsComponent implements OnInit {
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.logService.list(1, 50, 'created', 'des', {'level__gte': this.level}).subscribe(result => this.logs = result.results)
|
||||
this.logService.list(1, 50, 'created', true, {'level__gte': this.level}).subscribe(result => this.logs = result.results)
|
||||
}
|
||||
|
||||
getLevelText(level: number) {
|
||||
@@ -32,7 +31,7 @@ export class LogsComponent implements OnInit {
|
||||
if (this.logs.length > 0) {
|
||||
lastCreated = new Date(this.logs[this.logs.length-1].created).toISOString()
|
||||
}
|
||||
this.logService.list(1, 25, 'created', 'des', {'created__lt': lastCreated, 'level__gte': this.level}).subscribe(result => {
|
||||
this.logService.list(1, 25, 'created', true, {'created__lt': lastCreated, 'level__gte': this.level}).subscribe(result => {
|
||||
this.logs.push(...result.results)
|
||||
})
|
||||
}
|
||||
|
@@ -34,24 +34,35 @@
|
||||
<a ngbNavLink>Saved views</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<table class="table table-borderless table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Show in dashboard</th>
|
||||
<th scope="col">Show in sidebar</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let config of savedViewConfigService.getConfigs()">
|
||||
<td>{{ config.title }}</td>
|
||||
<td>{{ config.showInDashboard }}</td>
|
||||
<td>{{ config.showInSideBar }}</td>
|
||||
<td><button type="button" class="btn btn-sm btn-outline-danger" (click)="deleteViewConfig(config)">Delete</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div formGroupName="savedViews">
|
||||
|
||||
<div *ngFor="let view of savedViews" [formGroupName]="view.id" class="form-row">
|
||||
<div class="form-group col-4 mr-3">
|
||||
<label for="name_{{view.id}}">Name</label>
|
||||
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
|
||||
</div>
|
||||
|
||||
<div class="form-group col-auto mr-3">
|
||||
<label for="show_on_dashboard_{{view.id}}">Appears on</label>
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||
<label class="custom-control-label" for="show_on_dashboard_{{view.id}}">Show on dashboard</label>
|
||||
</div>
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
||||
<label class="custom-control-label" for="show_in_sidebar_{{view.id}}">Show in sidebar</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group col-auto">
|
||||
<label for="name_{{view.id}}">Actions</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="savedViews.length == 0">No saved views defined.</div>
|
||||
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { SavedViewConfig } from 'src/app/data/saved-view-config';
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
|
||||
import { GENERAL_SETTINGS } from 'src/app/data/storage-keys';
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
|
||||
import { Toast, ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -12,24 +13,63 @@ import { SavedViewConfigService } from 'src/app/services/saved-view-config.servi
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
|
||||
savedViewGroup = new FormGroup({})
|
||||
|
||||
settingsForm = new FormGroup({
|
||||
'documentListItemPerPage': new FormControl(+localStorage.getItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE) || GENERAL_SETTINGS.DOCUMENT_LIST_SIZE_DEFAULT)
|
||||
'documentListItemPerPage': new FormControl(+localStorage.getItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE) || GENERAL_SETTINGS.DOCUMENT_LIST_SIZE_DEFAULT),
|
||||
'savedViews': this.savedViewGroup
|
||||
})
|
||||
|
||||
constructor(
|
||||
private savedViewConfigService: SavedViewConfigService,
|
||||
private documentListViewService: DocumentListViewService
|
||||
public savedViewService: SavedViewService,
|
||||
private documentListViewService: DocumentListViewService,
|
||||
private toastService: ToastService
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
savedViews: PaperlessSavedView[]
|
||||
|
||||
ngOnInit() {
|
||||
this.savedViewService.listAll().subscribe(r => {
|
||||
this.savedViews = r.results
|
||||
for (let view of this.savedViews) {
|
||||
this.savedViewGroup.addControl(view.id.toString(), new FormGroup({
|
||||
"id": new FormControl(view.id),
|
||||
"name": new FormControl(view.name),
|
||||
"show_on_dashboard": new FormControl(view.show_on_dashboard),
|
||||
"show_in_sidebar": new FormControl(view.show_in_sidebar)
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deleteViewConfig(config: SavedViewConfig) {
|
||||
this.savedViewConfigService.deleteConfig(config)
|
||||
deleteSavedView(savedView: PaperlessSavedView) {
|
||||
this.savedViewService.delete(savedView).subscribe(() => {
|
||||
this.savedViewGroup.removeControl(savedView.id.toString())
|
||||
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
|
||||
this.toastService.showToast(Toast.make("Information", `Saved view "${savedView.name} deleted.`))
|
||||
})
|
||||
}
|
||||
|
||||
private saveLocalSettings() {
|
||||
localStorage.setItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage)
|
||||
this.documentListViewService.updatePageSize()
|
||||
this.toastService.showToast(Toast.make("Information", "Settings saved successfully."))
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
localStorage.setItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage)
|
||||
this.documentListViewService.updatePageSize()
|
||||
let x = []
|
||||
for (let id in this.savedViewGroup.value) {
|
||||
x.push(this.savedViewGroup.value[id])
|
||||
}
|
||||
if (x.length > 0) {
|
||||
this.savedViewService.patchMany(x).subscribe(s => {
|
||||
this.saveLocalSettings()
|
||||
}, error => {
|
||||
this.toastService.showToast(Toast.makeError(`Error while storing settings on server: ${JSON.stringify(error.error)}`))
|
||||
})
|
||||
} else {
|
||||
this.saveLocalSettings()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@
|
||||
aria-label="Default pagination"></ngb-pagination>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped border shadow">
|
||||
<table class="table table-striped border shadow-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" sortable="name" (sort)="onSort($event)">Name</th>
|
||||
@@ -28,8 +28,25 @@
|
||||
<td scope="row">{{ tag.document_count }}</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(tag)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(tag)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(tag)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>
|
||||
</svg>
|
||||
Documents
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(tag)">
|
||||
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(tag)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { FILTER_HAS_TAG } from 'src/app/data/filter-rule-type';
|
||||
import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
|
||||
import { TagService } from 'src/app/services/rest/tag.service';
|
||||
import { CorrespondentEditDialogComponent } from '../correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
|
||||
import { GenericListComponent } from '../generic-list/generic-list.component';
|
||||
import { TagEditDialogComponent } from './tag-edit-dialog/tag-edit-dialog.component';
|
||||
|
||||
@@ -13,9 +15,12 @@ import { TagEditDialogComponent } from './tag-edit-dialog/tag-edit-dialog.compon
|
||||
})
|
||||
export class TagListComponent extends GenericListComponent<PaperlessTag> {
|
||||
|
||||
constructor(tagService: TagService, modalService: NgbModal) {
|
||||
constructor(tagService: TagService, modalService: NgbModal,
|
||||
private router: Router,
|
||||
private list: DocumentListViewService
|
||||
) {
|
||||
super(tagService, modalService, TagEditDialogComponent)
|
||||
}
|
||||
}
|
||||
|
||||
getColor(id) {
|
||||
return TAG_COLOURS.find(c => c.id == id)
|
||||
@@ -24,4 +29,11 @@ export class TagListComponent extends GenericListComponent<PaperlessTag> {
|
||||
getObjectName(object: PaperlessTag) {
|
||||
return `tag '${object.name}'`
|
||||
}
|
||||
|
||||
filterDocuments(object: PaperlessTag) {
|
||||
this.list.documentListView.filter_rules = [
|
||||
{rule_type: FILTER_HAS_TAG, value: object.id.toString()}
|
||||
]
|
||||
this.router.navigate(["documents"])
|
||||
}
|
||||
}
|
||||
|
@@ -16,19 +16,22 @@ export const FILTER_ADDED_AFTER = 14
|
||||
export const FILTER_MODIFIED_BEFORE = 15
|
||||
export const FILTER_MODIFIED_AFTER = 16
|
||||
|
||||
export const FILTER_DOES_NOT_HAVE_TAG = 17
|
||||
|
||||
export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
|
||||
{id: FILTER_TITLE, name: "Title contains", filtervar: "title__icontains", datatype: "string", multi: false},
|
||||
{id: FILTER_CONTENT, name: "Content contains", filtervar: "content__icontains", datatype: "string", multi: false},
|
||||
|
||||
{id: FILTER_TITLE, name: "Title contains", filtervar: "title__icontains", datatype: "string", multi: false, default: ""},
|
||||
{id: FILTER_CONTENT, name: "Content contains", filtervar: "content__icontains", datatype: "string", multi: false, default: ""},
|
||||
|
||||
{id: FILTER_ASN, name: "ASN is", filtervar: "archive_serial_number", datatype: "number", multi: false},
|
||||
|
||||
|
||||
{id: FILTER_CORRESPONDENT, name: "Correspondent is", filtervar: "correspondent__id", datatype: "correspondent", multi: false},
|
||||
{id: FILTER_DOCUMENT_TYPE, name: "Document type is", filtervar: "document_type__id", datatype: "document_type", multi: false},
|
||||
|
||||
{id: FILTER_IS_IN_INBOX, name: "Is in Inbox", filtervar: "is_in_inbox", datatype: "boolean", multi: false},
|
||||
{id: FILTER_HAS_TAG, name: "Has tag", filtervar: "tags__id__all", datatype: "tag", multi: true},
|
||||
{id: FILTER_HAS_ANY_TAG, name: "Has any tag", filtervar: "is_tagged", datatype: "boolean", multi: false},
|
||||
{id: FILTER_IS_IN_INBOX, name: "Is in Inbox", filtervar: "is_in_inbox", datatype: "boolean", multi: false, default: true},
|
||||
{id: FILTER_HAS_TAG, name: "Has tag", filtervar: "tags__id__all", datatype: "tag", multi: true},
|
||||
{id: FILTER_DOES_NOT_HAVE_TAG, name: "Does not have tag", filtervar: "tags__id__none", datatype: "tag", multi: true},
|
||||
{id: FILTER_HAS_ANY_TAG, name: "Has any tag", filtervar: "is_tagged", datatype: "boolean", multi: false, default: true},
|
||||
|
||||
{id: FILTER_CREATED_BEFORE, name: "Created before", filtervar: "created__date__lt", datatype: "date", multi: false},
|
||||
{id: FILTER_CREATED_AFTER, name: "Created after", filtervar: "created__date__gt", datatype: "date", multi: false},
|
||||
@@ -39,7 +42,7 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
|
||||
{id: FILTER_ADDED_BEFORE, name: "Added before", filtervar: "added__date__lt", datatype: "date", multi: false},
|
||||
{id: FILTER_ADDED_AFTER, name: "Added after", filtervar: "added__date__gt", datatype: "date", multi: false},
|
||||
|
||||
|
||||
{id: FILTER_MODIFIED_BEFORE, name: "Modified before", filtervar: "modified__date__lt", datatype: "date", multi: false},
|
||||
{id: FILTER_MODIFIED_AFTER, name: "Modified after", filtervar: "modified__date__gt", datatype: "date", multi: false},
|
||||
]
|
||||
@@ -50,4 +53,5 @@ export interface FilterRuleType {
|
||||
filtervar: string
|
||||
datatype: string //number, string, boolean, date
|
||||
multi: boolean
|
||||
}
|
||||
default?: any
|
||||
}
|
||||
|
@@ -1,10 +1,8 @@
|
||||
import { FilterRuleType } from './filter-rule-type';
|
||||
|
||||
export function cloneFilterRules(filterRules: FilterRule[]): FilterRule[] {
|
||||
if (filterRules) {
|
||||
let newRules: FilterRule[] = []
|
||||
for (let rule of filterRules) {
|
||||
newRules.push({type: rule.type, value: rule.value})
|
||||
newRules.push({rule_type: rule.rule_type, value: rule.value})
|
||||
}
|
||||
return newRules
|
||||
} else {
|
||||
@@ -13,6 +11,6 @@ export function cloneFilterRules(filterRules: FilterRule[]): FilterRule[] {
|
||||
}
|
||||
|
||||
export interface FilterRule {
|
||||
type: FilterRuleType
|
||||
value: any
|
||||
rule_type: number
|
||||
value: string
|
||||
}
|
@@ -1,11 +1,13 @@
|
||||
export interface PaperlessDocumentMetadata {
|
||||
|
||||
paperless__checksum?: string
|
||||
original_checksum?: string
|
||||
|
||||
paperless__mime_type?: string
|
||||
archived_checksum?: string
|
||||
|
||||
paperless__filename?: string
|
||||
original_mime_type?: string
|
||||
|
||||
paperless__has_archive_version?: boolean
|
||||
media_filename?: string
|
||||
|
||||
has_archive_version?: boolean
|
||||
|
||||
}
|
18
src-ui/src/app/data/paperless-saved-view.ts
Normal file
18
src-ui/src/app/data/paperless-saved-view.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FilterRule } from './filter-rule';
|
||||
import { ObjectWithId } from './object-with-id';
|
||||
|
||||
export interface PaperlessSavedView extends ObjectWithId {
|
||||
|
||||
name?: string
|
||||
|
||||
show_on_dashboard?: boolean
|
||||
|
||||
show_in_sidebar?: boolean
|
||||
|
||||
sort_field: string
|
||||
|
||||
sort_reverse: boolean
|
||||
|
||||
filter_rules: FilterRule[]
|
||||
|
||||
}
|
@@ -1,19 +0,0 @@
|
||||
import { FilterRule } from './filter-rule';
|
||||
|
||||
export interface SavedViewConfig {
|
||||
|
||||
id?: string
|
||||
|
||||
filterRules: FilterRule[]
|
||||
|
||||
sortField: string
|
||||
|
||||
sortDirection: string
|
||||
|
||||
title?: string
|
||||
|
||||
showInSideBar?: boolean
|
||||
|
||||
showInDashboard?: boolean
|
||||
|
||||
}
|
8
src-ui/src/app/pipes/document-title.pipe.spec.ts
Normal file
8
src-ui/src/app/pipes/document-title.pipe.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { DocumentTitlePipe } from './document-title.pipe';
|
||||
|
||||
describe('DocumentTitlePipe', () => {
|
||||
it('create an instance', () => {
|
||||
const pipe = new DocumentTitlePipe();
|
||||
expect(pipe).toBeTruthy();
|
||||
});
|
||||
});
|
16
src-ui/src/app/pipes/document-title.pipe.ts
Normal file
16
src-ui/src/app/pipes/document-title.pipe.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'documentTitle'
|
||||
})
|
||||
export class DocumentTitlePipe implements PipeTransform {
|
||||
|
||||
transform(value: string): string {
|
||||
if (value) {
|
||||
return value
|
||||
} else {
|
||||
return "(no title)"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
8
src-ui/src/app/pipes/file-size.pipe.spec.ts
Normal file
8
src-ui/src/app/pipes/file-size.pipe.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FileSizePipe } from './file-size.pipe';
|
||||
|
||||
describe('FileSizePipe', () => {
|
||||
it('create an instance', () => {
|
||||
const pipe = new FileSizePipe();
|
||||
expect(pipe).toBeTruthy();
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user