Adds custom fields of certain data types, attachable to documents and searchable
Co-Authored-By: Trenton H <797416+stumpylog@users.noreply.github.com>
django-filter = "~=23.3"
djangorestframework = "~=3.14"
djangorestframework-guardian = "*"
+drf-writable-nested = "*"
filelock = "*"
gunicorn = "*"
imap-tools = "*"
{
"_meta": {
"hash": {
- "sha256": "7b4272de2042a346f3252ae20e7bbeee60c375381f59526caa35511a706d4977"
+ "sha256": "3c380d590439f008ec85f1d5821ed96b4ebd56fcee3f287e6e0a6f5923262229"
},
"pipfile-spec": 6,
"requires": {},
"index": "pypi",
"version": "==0.3.0"
},
+ "drf-writable-nested": {
+ "hashes": [
+ "sha256:154c0381e8a3a477e0fd539d5e1caf8ff4c1097a9c0c0fe741d4858b11b0455b"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.7'",
+ "version": "==0.7.0"
+ },
"exceptiongroup": {
"hashes": [
"sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9",
search_index() {
- local -r index_version=6
+ local -r index_version=7
local -r index_version_file=${DATA_DIR}/.index_version
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
- `/api/users/`: Full CRUD support.
- `/api/groups/`: Full CRUD support.
- `/api/share_links/`: Full CRUD support.
+- `/api/custom_fields/`: Full CRUD support.
All of these endpoints except for the logging endpoint allow you to
fetch (and edit and delete where appropriate) individual objects by
- `notes`: Array of notes associated with the document.
- `set_permissions`: Allows setting document permissions. Optional,
write-only. See [below](#permissions).
+- `custom_fields`: Array of custom fields & values, specified as
+ { field: CUSTOM_FIELD_ID, value: VALUE }
## Downloading documents
- `{added_month_name_short}`: added month short name
- `{added_day}`: added day
+## Custom Fields {#custom-fields}
+
+Paperless-ngx supports the use of custom fields for documents as of v2.0, allowing a user
+to optionally attach data to documents which does not fit in the existing set of fields
+Paperless-ngx provides.
+
+1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
+2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field and click "Add". Once the field is visible in the form you can enter the appropriate
+ data which will be validated according to the custom field "data type".
+3. Fields can be removed by hovering over the field name revealing a "Remove" button.
+
+!!! important
+
+ Added / removed fields, as well as any data is not saved to the document until you
+ actually hit the "Save" button, similar to other changes on the document details page.
+
+!!! note
+
+ Once the data type for a field is set, it cannot be changed.
+
+Multiple fields may be attached to a document but the same field name cannot be assigned multiple times to the a single document.
+
+The following custom field types are supported:
+
+- `Text`: any text
+- `Boolean`: true / false (check / unchecked) field
+- `Date`: date
+- `URL`: a valid url
+- `Integer`: integer number e.g. 12
+- `Number`: float number e.g. 12.3456
+- `Monetary`: float number with exactly two decimals, e.g. 12.30
+
## Best practices {#basic-searching}
Paperless offers a couple tools that help you organize your document
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 0.951 }
},
+ {
+ "startedDateTime": "2023-05-14T07:03:41.086Z",
+ "time": 0.951,
+ "request": {
+ "method": "GET",
+ "url": "http://localhost:8000/api/custom_fields/?page=1&page_size=100000",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ { "name": "Accept", "value": "application/json; version=3" },
+ { "name": "Accept-Encoding", "value": "gzip, deflate, br" },
+ { "name": "Accept-Language", "value": "en-US" },
+ { "name": "Connection", "value": "keep-alive" },
+ { "name": "Host", "value": "localhost:8000" },
+ { "name": "Origin", "value": "http://localhost:4200" },
+ { "name": "Referer", "value": "http://localhost:4200/" },
+ { "name": "Sec-Fetch-Dest", "value": "empty" },
+ { "name": "Sec-Fetch-Mode", "value": "cors" },
+ { "name": "Sec-Fetch-Site", "value": "same-site" },
+ { "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
+ ],
+ "queryString": [
+ {
+ "name": "page",
+ "value": "1"
+ },
+ {
+ "name": "page_size",
+ "value": "100000"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": -1
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ { "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
+ { "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
+ { "name": "Content-Encoding", "value": "br" },
+ { "name": "Content-Language", "value": "en-us" },
+ { "name": "Content-Length", "value": "851" },
+ { "name": "Content-Type", "value": "application/json" },
+ { "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
+ { "name": "Referrer-Policy", "value": "same-origin" },
+ { "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
+ { "name": "X-Api-Version", "value": "3" },
+ { "name": "X-Content-Type-Options", "value": "nosniff" },
+ { "name": "X-Frame-Options", "value": "ANY" },
+ { "name": "X-Version", "value": "1.14.4" }
+ ],
+ "content": {
+ "size": -1,
+ "mimeType": "application/json",
+ "text": "{\"count\":0,\"next\":null,\"previous\":null,\"all\":[],\"results\":[]}"
+ },
+ "headersSize": -1,
+ "bodySize": -1,
+ "redirectURL": ""
+ },
+ "cache": {},
+ "timings": { "send": -1, "wait": -1, "receive": 0.951 }
+ },
{
"startedDateTime": "2023-05-14T07:03:41.087Z",
"time": 0.54,
"content": {
"size": -1,
"mimeType": "application/json",
- "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2}]}"
+ "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2}],\"custom_fields\":[]}"
},
"headersSize": -1,
"bodySize": -1,
"cache": {},
"timings": { "send": -1, "wait": -1, "receive": 1.02 }
},
+ {
+ "startedDateTime": "2023-05-14T07:03:41.086Z",
+ "time": 0.951,
+ "request": {
+ "method": "GET",
+ "url": "http://localhost:8000/api/custom_fields/?page=1&page_size=100000",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ { "name": "Accept", "value": "application/json; version=3" },
+ { "name": "Accept-Encoding", "value": "gzip, deflate, br" },
+ { "name": "Accept-Language", "value": "en-US" },
+ { "name": "Connection", "value": "keep-alive" },
+ { "name": "Host", "value": "localhost:8000" },
+ { "name": "Origin", "value": "http://localhost:4200" },
+ { "name": "Referer", "value": "http://localhost:4200/" },
+ { "name": "Sec-Fetch-Dest", "value": "empty" },
+ { "name": "Sec-Fetch-Mode", "value": "cors" },
+ { "name": "Sec-Fetch-Site", "value": "same-site" },
+ { "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
+ ],
+ "queryString": [
+ {
+ "name": "page",
+ "value": "1"
+ },
+ {
+ "name": "page_size",
+ "value": "100000"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": -1
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ { "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
+ { "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
+ { "name": "Content-Encoding", "value": "br" },
+ { "name": "Content-Language", "value": "en-us" },
+ { "name": "Content-Length", "value": "851" },
+ { "name": "Content-Type", "value": "application/json" },
+ { "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
+ { "name": "Referrer-Policy", "value": "same-origin" },
+ { "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
+ { "name": "X-Api-Version", "value": "3" },
+ { "name": "X-Content-Type-Options", "value": "nosniff" },
+ { "name": "X-Frame-Options", "value": "ANY" },
+ { "name": "X-Version", "value": "1.14.4" }
+ ],
+ "content": {
+ "size": -1,
+ "mimeType": "application/json",
+ "text": "{\"count\":0,\"next\":null,\"previous\":null,\"all\":[],\"results\":[]}"
+ },
+ "headersSize": -1,
+ "bodySize": -1,
+ "redirectURL": ""
+ },
+ "cache": {},
+ "timings": { "send": -1, "wait": -1, "receive": 0.951 }
+ },
{
"startedDateTime": "2023-05-28T06:08:41.662Z",
"time": 1.012,
"content": {
"size": -1,
"mimeType": "application/json",
- "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":null,\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2},{\"id\":74,\"note\":\"This is a new note\",\"created\":\"2023-05-28T05:49:25.763951Z\",\"document\":175,\"user\":2}]}"
+ "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":null,\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2},{\"id\":74,\"note\":\"This is a new note\",\"created\":\"2023-05-28T05:49:25.763951Z\",\"document\":175,\"user\":2}],\"custom_fields\":[]}"
},
"headersSize": -1,
"bodySize": -1,
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">65</context>
+ <context context-type="linenumber">81</context>
</context-group>
</trans-unit>
<trans-unit id="1241348629231510663" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">218</context>
+ <context context-type="linenumber">225</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">221</context>
+ <context context-type="linenumber">228</context>
</context-group>
</trans-unit>
<trans-unit id="3894950702316166331" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">196</context>
+ <context context-type="linenumber">203</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">199</context>
+ <context context-type="linenumber">206</context>
</context-group>
</trans-unit>
<trans-unit id="1685061484835793745" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">192</context>
+ <context context-type="linenumber">221</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
<context context-type="linenumber">9</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
+ <context context-type="linenumber">8</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
<context context-type="linenumber">10</context>
<context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+ <context context-type="linenumber">14</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">17</context>
<context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+ <context context-type="linenumber">16</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">19</context>
<context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+ <context context-type="linenumber">34</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">42</context>
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
<context context-type="linenumber">21</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
+ <context context-type="linenumber">14</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
<context context-type="linenumber">23</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">208</context>
+ <context context-type="linenumber">93</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">210</context>
+ <context context-type="linenumber">217</context>
</context-group>
</trans-unit>
<trans-unit id="103921551219467537" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
- <context context-type="linenumber">2</context>
+ <context context-type="linenumber">4</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">203</context>
+ <context context-type="linenumber">210</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">206</context>
+ <context context-type="linenumber">213</context>
</context-group>
</trans-unit>
<trans-unit id="4555457172864212828" datatype="html">
<context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
<context context-type="linenumber">31</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+ <context context-type="linenumber">29</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">656</context>
+ <context context-type="linenumber">677</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.ts</context>
<context context-type="linenumber">91</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">76</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">114</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">658</context>
+ <context context-type="linenumber">679</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.ts</context>
<context context-type="linenumber">93</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">78</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">116</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">225</context>
+ <context context-type="linenumber">232</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">228</context>
+ <context context-type="linenumber">235</context>
</context-group>
</trans-unit>
<trans-unit id="6570363013146073520" datatype="html">
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
- <trans-unit id="3079652255369035" datatype="html">
- <source>Document types</source>
+ <trans-unit id="4369111787961525769" datatype="html">
+ <source>Document Types</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">162</context>
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">165</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
+ <context context-type="linenumber">61</context>
+ </context-group>
</trans-unit>
- <trans-unit id="8835528846812581148" datatype="html">
- <source>Storage paths</source>
+ <trans-unit id="5421255270838137624" datatype="html">
+ <source>Storage Paths</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">169</context>
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">172</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
+ <context context-type="linenumber">67</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="3188389494264426470" datatype="html">
+ <source>Custom Fields</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+ <context context-type="linenumber">176</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+ <context context-type="linenumber">179</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
+ <context context-type="linenumber">6</context>
+ </context-group>
</trans-unit>
<trans-unit id="4462691404891390153" datatype="html">
<source>Consumption templates</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">176</context>
+ <context context-type="linenumber">183</context>
</context-group>
</trans-unit>
<trans-unit id="5433675495457939071" datatype="html">
<source>Templates</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">179</context>
+ <context context-type="linenumber">186</context>
</context-group>
</trans-unit>
<trans-unit id="1292737233370901804" datatype="html">
<source>Mail</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">183</context>
+ <context context-type="linenumber">190</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">186</context>
+ <context context-type="linenumber">193</context>
</context-group>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">192</context>
+ <context context-type="linenumber">199</context>
</context-group>
</trans-unit>
<trans-unit id="5537285341303594392" datatype="html">
<source>File Tasks<x id="START_TAG_SPAN_1" ctype="x-span_1" equiv-text="<span *ngIf="tasksService.failedFileTasks.length > 0">"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">214</context>
+ <context context-type="linenumber">221</context>
</context-group>
</trans-unit>
<trans-unit id="1534029177398918729" datatype="html">
<source>GitHub</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">234</context>
+ <context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="4112664765954374539" datatype="html">
<source>is available.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">240</context>
+ <context context-type="linenumber">247</context>
</context-group>
</trans-unit>
<trans-unit id="1175891574282637937" datatype="html">
<source>Click to view.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">240</context>
+ <context context-type="linenumber">247</context>
</context-group>
</trans-unit>
<trans-unit id="9811291095862612" datatype="html">
<source>Paperless-ngx can automatically check for updates</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">244</context>
+ <context context-type="linenumber">251</context>
</context-group>
</trans-unit>
<trans-unit id="894819944961861800" datatype="html">
<source> How does this work? </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">251,253</context>
+ <context context-type="linenumber">258,260</context>
</context-group>
</trans-unit>
<trans-unit id="509090351011426949" datatype="html">
<source>Update available</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
- <context context-type="linenumber">262</context>
+ <context context-type="linenumber">269</context>
</context-group>
</trans-unit>
<trans-unit id="1542489069631984294" datatype="html">
<context context-type="linenumber">439</context>
</context-group>
</trans-unit>
+ <trans-unit id="3972154626835212608" datatype="html">
+ <source>Create New Field</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
+ <context context-type="linenumber">25</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="3249513483374643425" datatype="html">
+ <source>Add</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
+ <context context-type="linenumber">30</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
+ <context context-type="linenumber">7</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="7827616268749044116" datatype="html">
+ <source>Choose field</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</context>
+ <context context-type="linenumber">52</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="7284517513296281043" datatype="html">
+ <source>No unused fields found</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</context>
+ <context context-type="linenumber">56</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6973528734330066202" datatype="html">
+ <source>Saved field "<x id="PH" equiv-text="newField.name"/>".</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</context>
+ <context context-type="linenumber">120</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">59</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="1841172489943868696" datatype="html">
+ <source>Error saving field.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</context>
+ <context context-type="linenumber">128</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">66</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="6052766076365105714" datatype="html">
<source>now</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
+ <context context-type="linenumber">13</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
<context context-type="linenumber">22</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
+ <trans-unit id="4894226280476434291" datatype="html">
+ <source>Data type</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
+ <context context-type="linenumber">9</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="5933665691581884232" datatype="html">
+ <source>Data type cannot be changed after a field is created</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
+ <context context-type="linenumber">10</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="528950215505228201" datatype="html">
+ <source>Create new custom field</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts</context>
+ <context context-type="linenumber">39</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="8751213029607178010" datatype="html">
+ <source>Edit custom field</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts</context>
+ <context context-type="linenumber">43</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="6672809941092516947" datatype="html">
<source>Create new document type</source>
<context-group purpose="location">
</context-group>
<note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
</trans-unit>
+ <trans-unit id="4814285799071780083" datatype="html">
+ <source>Remove</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/check/check.component.html</context>
+ <context context-type="linenumber">8</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
+ <context context-type="linenumber">8</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/number/number.component.html</context>
+ <context context-type="linenumber">8</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
+ <context context-type="linenumber">8</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/text/text.component.html</context>
+ <context context-type="linenumber">8</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
+ <context context-type="linenumber">8</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="445302259125375799" datatype="html">
<source>Invalid date.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
- <context context-type="linenumber">18</context>
+ <context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="524422427194414813" datatype="html">
<source>Suggestions:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
- <context context-type="linenumber">21</context>
+ <context context-type="linenumber">30</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
- <context context-type="linenumber">37</context>
+ <context context-type="linenumber">47</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
- <context context-type="linenumber">43</context>
+ <context context-type="linenumber">46</context>
</context-group>
</trans-unit>
<trans-unit id="6344437738844463465" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context>
- <context context-type="linenumber">149</context>
+ <context context-type="linenumber">155</context>
</context-group>
</trans-unit>
<trans-unit id="594042705136125260" datatype="html">
<source>Add item</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
- <context context-type="linenumber">12</context>
+ <context context-type="linenumber">21</context>
</context-group>
<note priority="1" from="description">Used for both types, correspondents, storage paths</note>
</trans-unit>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
+ <trans-unit id="2504502765849142619" datatype="html">
+ <source>No items found</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context>
+ <context context-type="linenumber">92</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="6560126119609945418" datatype="html">
<source>Add tag</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
- <context context-type="linenumber">12</context>
+ <context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="2561408369057364131" datatype="html">
<source>Filter documents with these Tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
- <context context-type="linenumber">35</context>
+ <context context-type="linenumber">38</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="1388712764439031120" datatype="html">
+ <source>Open link</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
+ <context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="7062872617520618723" datatype="html">
<context context-type="linenumber">5</context>
</context-group>
</trans-unit>
- <trans-unit id="3249513483374643425" datatype="html">
- <source>Add</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
- <context context-type="linenumber">7</context>
- </context-group>
- </trans-unit>
<trans-unit id="1230154438678955604" datatype="html">
<source>Change</source>
<context-group purpose="location">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">85</context>
+ <context context-type="linenumber">103</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">201</context>
+ <context context-type="linenumber">203</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">89</context>
+ <context context-type="linenumber">107</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
- <trans-unit id="4369111787961525769" datatype="html">
- <source>Document Types</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
- <context context-type="linenumber">61</context>
- </context-group>
- </trans-unit>
- <trans-unit id="5421255270838137624" datatype="html">
- <source>Storage Paths</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
- <context context-type="linenumber">67</context>
- </context-group>
- </trans-unit>
<trans-unit id="8693603235657020323" datatype="html">
<source>Other</source>
<context-group purpose="location">
<source>Close</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">53</context>
+ <context context-type="linenumber">71</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
<source>Previous</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">60</context>
+ <context context-type="linenumber">76</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="3823219296477075982" datatype="html">
+ <source>Discard</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+ <context context-type="linenumber">89</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="5129524307369213584" datatype="html">
+ <source>Save & next</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+ <context context-type="linenumber">91</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="4910102545766233758" datatype="html">
+ <source>Save & close</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+ <context context-type="linenumber">92</context>
</context-group>
</trans-unit>
<trans-unit id="5028777105388019087" datatype="html">
<source>Details</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">82</context>
+ <context context-type="linenumber">100</context>
</context-group>
</trans-unit>
<trans-unit id="1379170675585571971" datatype="html">
<source>Archive serial number</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">86</context>
+ <context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="5114742157723900905" datatype="html">
<source>Date created</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">87</context>
+ <context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="5066119607229701477" datatype="html">
<source>Document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">91</context>
+ <context context-type="linenumber">109</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<source>Storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">93</context>
+ <context context-type="linenumber">111</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
<source>Default</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">94</context>
+ <context context-type="linenumber">112</context>
</context-group>
</trans-unit>
<trans-unit id="6205355627445317276" datatype="html">
<source>Content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">101</context>
+ <context context-type="linenumber">130</context>
</context-group>
</trans-unit>
<trans-unit id="218403386307979629" datatype="html">
<source>Metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">110</context>
+ <context context-type="linenumber">139</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
<source>Date modified</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">116</context>
+ <context context-type="linenumber">145</context>
</context-group>
</trans-unit>
<trans-unit id="6392918669949841614" datatype="html">
<source>Date added</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">120</context>
+ <context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="146828917013192897" datatype="html">
<source>Media filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">124</context>
+ <context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="4500855521601039868" datatype="html">
<source>Original filename</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">128</context>
+ <context context-type="linenumber">157</context>
</context-group>
</trans-unit>
<trans-unit id="7985558498848210210" datatype="html">
<source>Original MD5 checksum</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">132</context>
+ <context context-type="linenumber">161</context>
</context-group>
</trans-unit>
<trans-unit id="5888243105821763422" datatype="html">
<source>Original file size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">136</context>
+ <context context-type="linenumber">165</context>
</context-group>
</trans-unit>
<trans-unit id="2696647325713149563" datatype="html">
<source>Original mime type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">140</context>
+ <context context-type="linenumber">169</context>
</context-group>
</trans-unit>
<trans-unit id="342875990758166588" datatype="html">
<source>Archive MD5 checksum</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">144</context>
+ <context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="6033581412811562084" datatype="html">
<source>Archive file size</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">148</context>
+ <context context-type="linenumber">177</context>
</context-group>
</trans-unit>
<trans-unit id="6992781481378431874" datatype="html">
<source>Original document metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">154</context>
+ <context context-type="linenumber">183</context>
</context-group>
</trans-unit>
<trans-unit id="2846565152091361585" datatype="html">
<source>Archived document metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">155</context>
+ <context context-type="linenumber">184</context>
</context-group>
</trans-unit>
<trans-unit id="1295614462098694869" datatype="html">
<source>Preview</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">161</context>
+ <context context-type="linenumber">190</context>
</context-group>
</trans-unit>
<trans-unit id="8191371354890763172" datatype="html">
<source>Enter Password</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">177</context>
+ <context context-type="linenumber">206</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">228</context>
+ <context context-type="linenumber">249</context>
</context-group>
</trans-unit>
<trans-unit id="8460995830263484763" datatype="html">
<source>Notes <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="document?.notes.length" class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</a>"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">185,186</context>
- </context-group>
- </trans-unit>
- <trans-unit id="3823219296477075982" datatype="html">
- <source>Discard</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">204</context>
- </context-group>
- </trans-unit>
- <trans-unit id="5129524307369213584" datatype="html">
- <source>Save & next</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">206</context>
- </context-group>
- </trans-unit>
- <trans-unit id="4910102545766233758" datatype="html">
- <source>Save & close</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
- <context context-type="linenumber">207</context>
+ <context context-type="linenumber">214,215</context>
</context-group>
</trans-unit>
<trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">253,255</context>
+ <context context-type="linenumber">265,267</context>
</context-group>
</trans-unit>
<trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">398</context>
+ <context context-type="linenumber">413</context>
</context-group>
</trans-unit>
<trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">419</context>
+ <context context-type="linenumber">434</context>
</context-group>
</trans-unit>
<trans-unit id="8348337312757497317" datatype="html">
<source>Document saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">532</context>
+ <context context-type="linenumber">552</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">540</context>
+ <context context-type="linenumber">561</context>
</context-group>
</trans-unit>
<trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">544</context>
+ <context context-type="linenumber">565</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">585</context>
+ <context context-type="linenumber">606</context>
</context-group>
</trans-unit>
<trans-unit id="9021887951960049161" datatype="html">
<source>Confirm delete</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">611</context>
+ <context context-type="linenumber">632</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<source>Do you really want to delete document "<x id="PH" equiv-text="this.document.title"/>"?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">612</context>
+ <context context-type="linenumber">633</context>
</context-group>
</trans-unit>
<trans-unit id="6691075929777935948" datatype="html">
<source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">613</context>
+ <context context-type="linenumber">634</context>
</context-group>
</trans-unit>
<trans-unit id="719892092227206532" datatype="html">
<source>Delete document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">615</context>
+ <context context-type="linenumber">636</context>
</context-group>
</trans-unit>
<trans-unit id="7295637485862454066" datatype="html">
<source>Error deleting document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">634</context>
+ <context context-type="linenumber">655</context>
</context-group>
</trans-unit>
<trans-unit id="7362691899087997122" datatype="html">
<source>Redo OCR confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">654</context>
+ <context context-type="linenumber">675</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<source>This operation will permanently redo OCR for this document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">655</context>
+ <context context-type="linenumber">676</context>
</context-group>
</trans-unit>
<trans-unit id="5729001209753056399" datatype="html">
<source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">666</context>
+ <context context-type="linenumber">687</context>
</context-group>
</trans-unit>
<trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
- <context context-type="linenumber">677</context>
+ <context context-type="linenumber">698</context>
</context-group>
</trans-unit>
<trans-unit id="6857598786757174736" datatype="html">
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">206</context>
+ <context context-type="linenumber">208</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">118,120</context>
+ <context context-type="linenumber">120,122</context>
</context-group>
</trans-unit>
<trans-unit id="8170755470576301659" datatype="html">
<source>Without correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">122</context>
+ <context context-type="linenumber">124</context>
</context-group>
</trans-unit>
<trans-unit id="317796810569008208" datatype="html">
)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">128,130</context>
+ <context context-type="linenumber">130,132</context>
</context-group>
</trans-unit>
<trans-unit id="4362173610367509215" datatype="html">
<source>Without document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">132</context>
+ <context context-type="linenumber">134</context>
</context-group>
</trans-unit>
<trans-unit id="232202047340644471" datatype="html">
)?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">138,140</context>
+ <context context-type="linenumber">140,142</context>
</context-group>
</trans-unit>
<trans-unit id="1562820715074533164" datatype="html">
<source>Without storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">142</context>
+ <context context-type="linenumber">144</context>
</context-group>
</trans-unit>
<trans-unit id="8180755793012580465" datatype="html">
?.name"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">146,147</context>
+ <context context-type="linenumber">148,149</context>
</context-group>
</trans-unit>
<trans-unit id="6494566478302448576" datatype="html">
<source>Without any tag</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">151</context>
+ <context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="6523384805359286307" datatype="html">
<source>Title: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">155</context>
+ <context context-type="linenumber">157</context>
</context-group>
</trans-unit>
<trans-unit id="1872523635812236432" datatype="html">
<source>ASN: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">158</context>
+ <context context-type="linenumber">160</context>
</context-group>
</trans-unit>
<trans-unit id="102674688969746976" datatype="html">
<source>Owner: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">161</context>
+ <context context-type="linenumber">163</context>
</context-group>
</trans-unit>
<trans-unit id="3550877650686009106" datatype="html">
<source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">164</context>
+ <context context-type="linenumber">166</context>
</context-group>
</trans-unit>
<trans-unit id="1082034558646673343" datatype="html">
<source>Without an owner</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">167</context>
+ <context context-type="linenumber">169</context>
</context-group>
</trans-unit>
<trans-unit id="3100631071441658964" datatype="html">
<source>Title & content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">204</context>
+ <context context-type="linenumber">206</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="9149498548977462220" datatype="html">
+ <source>Custom fields</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
+ <context context-type="linenumber">211</context>
</context-group>
</trans-unit>
<trans-unit id="1010505078885609376" datatype="html">
<source>Advanced search</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">209</context>
+ <context context-type="linenumber">215</context>
</context-group>
</trans-unit>
<trans-unit id="2649431021108393503" datatype="html">
<source>More like</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">215</context>
+ <context context-type="linenumber">221</context>
</context-group>
</trans-unit>
<trans-unit id="3697582909018473071" datatype="html">
<source>equals</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">234</context>
+ <context context-type="linenumber">240</context>
</context-group>
</trans-unit>
<trans-unit id="5325481293405718739" datatype="html">
<source>is empty</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">238</context>
+ <context context-type="linenumber">244</context>
</context-group>
</trans-unit>
<trans-unit id="6166785695326182482" datatype="html">
<source>is not empty</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">242</context>
+ <context context-type="linenumber">248</context>
</context-group>
</trans-unit>
<trans-unit id="4686622206659266699" datatype="html">
<source>greater than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">246</context>
+ <context context-type="linenumber">252</context>
</context-group>
</trans-unit>
<trans-unit id="8014012170270529279" datatype="html">
<source>less than</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
- <context context-type="linenumber">250</context>
+ <context context-type="linenumber">256</context>
</context-group>
</trans-unit>
<trans-unit id="7210076240260527720" datatype="html">
<context context-type="linenumber">67</context>
</context-group>
</trans-unit>
+ <trans-unit id="8019331026479399960" datatype="html">
+ <source>Add Field</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+ <context context-type="linenumber">6</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6122252173137142810" datatype="html">
+ <source>Data Type</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+ <context context-type="linenumber">15</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="651372623796033489" datatype="html">
+ <source>No fields defined.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+ <context context-type="linenumber">40</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="3032792139967609806" datatype="html">
+ <source>Confirm delete field</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">74</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="2939457975223185057" datatype="html">
+ <source>This operation will permanently delete this field.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">75</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="5137089475515834162" datatype="html">
+ <source>Deleted field</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">84</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6352403551920829405" datatype="html">
+ <source>Error deleting field.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+ <context context-type="linenumber">89</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="8084492669582894778" datatype="html">
<source>document type</source>
<context-group purpose="location">
<context context-type="linenumber">46</context>
</context-group>
</trans-unit>
+ <trans-unit id="969459137986754249" datatype="html">
+ <source>Boolean</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+ <context context-type="linenumber">16</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="3973931101896534797" datatype="html">
+ <source>Date</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+ <context context-type="linenumber">20</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="362956598863566327" datatype="html">
+ <source>Integer</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+ <context context-type="linenumber">24</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6370642728789544052" datatype="html">
+ <source>Number</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+ <context context-type="linenumber">28</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6430409302408843009" datatype="html">
+ <source>Monetary</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+ <context context-type="linenumber">32</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6162693758764653365" datatype="html">
+ <source>Text</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+ <context context-type="linenumber">36</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="8308045076391224954" datatype="html">
+ <source>Url</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+ <context context-type="linenumber">40</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="5948496158474272829" datatype="html">
<source>Warning: You have unsaved changes to your document(s).</source>
<context-group purpose="location">
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
+import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
},
},
},
+ {
+ path: 'customfields',
+ component: CustomFieldsComponent,
+ canActivate: [PermissionsGuard],
+ data: {
+ requiredPermission: {
+ action: PermissionAction.View,
+ type: PermissionType.CustomField,
+ },
+ },
+ },
{
path: 'templates',
component: ConsumptionTemplatesComponent,
import { TextComponent } from './components/common/input/text/text.component'
import { SelectComponent } from './components/common/input/select/select.component'
import { CheckComponent } from './components/common/input/check/check.component'
+import { UrlComponent } from './components/common/input/url/url.component'
import { PasswordComponent } from './components/common/input/password/password.component'
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'
import { TagsComponent } from './components/common/input/tags/tags.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { FileDropComponent } from './components/file-drop/file-drop.component'
+import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
+import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
TextComponent,
SelectComponent,
CheckComponent,
+ UrlComponent,
PasswordComponent,
SaveViewConfigDialogComponent,
TagsComponent,
MailComponent,
UsersAndGroupsComponent,
FileDropComponent,
+ CustomFieldsComponent,
+ CustomFieldEditDialogComponent,
+ CustomFieldsDropdownComponent,
],
imports: [
BrowserModule,
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
- <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
+ <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hash"/>
- </svg><span> <ng-container i18n>Document types</ng-container></span>
+ </svg><span> <ng-container i18n>Document Types</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
- <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
+ <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#folder"/>
- </svg><span> <ng-container i18n>Storage paths</ng-container></span>
+ </svg><span> <ng-container i18n>Storage Paths</ng-container></span>
+ </a>
+ </li>
+ <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
+ <a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
+ <svg class="sidebaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#ui-radios"/>
+ </svg><span> <ng-container i18n>Custom Fields</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }" tourAnchor="tour.consumption-templates">
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
-import { CdkDragDrop } from '@angular/cdk/drag-drop'
+import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
const saved_views = [
NgbModule,
FormsModule,
ReactiveFormsModule,
+ DragDropModule,
],
providers: [
SettingsService,
--- /dev/null
+<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
+ <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
+ <svg class="toolbaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#ui-radios" />
+ </svg>
+ <div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div>
+ </button>
+ <div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
+ <ul class="list-group list-group-flush">
+ <li class="list-group-item">
+ <pngx-input-select class="mb-3"
+ [items]="unusedFields"
+ bindLabel="name"
+ [(ngModel)]="field"
+ [placeholder]="placeholderText"
+ [notFoundText]="notFoundText"
+ [disableCreateNew]="!canCreateFields"
+ (createNew)="createField($event)"
+ bindValue="id">
+ </pngx-input-select>
+ <div class="btn-toolbar" role="toolbar">
+ <button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields">
+ <svg fill="currentColor" class="buttonicon-sm me-1 mb-1">
+ <use xlink:href="assets/bootstrap-icons.svg#asterisk"/>
+ </svg><ng-container i18n>Create New Field</ng-container>
+ </button>
+ <button class="btn btn-sm btn-outline-primary me-1" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined">
+ <svg fill="currentColor" class="buttonicon me-1">
+ <use xlink:href="assets/bootstrap-icons.svg#plus-circle"/>
+ </svg><ng-container i18n>Add</ng-container>
+ </button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</div>
--- /dev/null
+.custom-fields-dropdown {
+ min-width: 350px;
+
+ // correct position on mobile
+ @media (max-width: 575.98px) {
+ &.show {
+ margin-left: -175px !important;
+ }
+ }
+}
+
+::ng-deep .ng-select .ng-select-container .ng-value-container .ng-placeholder,
+::ng-deep .ng-select .ng-option,
+::ng-deep .ng-select .ng-select-container .ng-value-container .ng-value {
+ font-size: 0.875rem;
+}
+
+::ng-deep .paperless-input-select .ng-select {
+ min-height: calc(1em + 0.75rem + 5px);
+}
+
+::ng-deep .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
+ top: 4px;
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+} from '@angular/common/http/testing'
+import { ToastService } from 'src/app/services/toast.service'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { of } from 'rxjs'
+import {
+ PaperlessCustomField,
+ PaperlessCustomFieldDataType,
+} from 'src/app/data/paperless-custom-field'
+import { SelectComponent } from '../input/select/select.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import {
+ NgbDropdownModule,
+ NgbModal,
+ NgbModalModule,
+ NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { By } from '@angular/platform-browser'
+
+const fields: PaperlessCustomField[] = [
+ {
+ id: 0,
+ name: 'Field 1',
+ data_type: PaperlessCustomFieldDataType.Integer,
+ },
+ {
+ id: 1,
+ name: 'Field 2',
+ data_type: PaperlessCustomFieldDataType.String,
+ },
+]
+
+describe('CustomFieldsDropdownComponent', () => {
+ let component: CustomFieldsDropdownComponent
+ let fixture: ComponentFixture<CustomFieldsDropdownComponent>
+ let customFieldService: CustomFieldsService
+ let toastService: ToastService
+ let modalService: NgbModal
+ let httpController: HttpTestingController
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [CustomFieldsDropdownComponent, SelectComponent],
+ imports: [
+ HttpClientTestingModule,
+ NgSelectModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgbModalModule,
+ NgbDropdownModule,
+ ],
+ })
+ customFieldService = TestBed.inject(CustomFieldsService)
+ httpController = TestBed.inject(HttpTestingController)
+ toastService = TestBed.inject(ToastService)
+ modalService = TestBed.inject(NgbModal)
+ jest.spyOn(customFieldService, 'listAll').mockReturnValue(
+ of({
+ all: fields.map((f) => f.id),
+ count: fields.length,
+ results: fields.concat([]),
+ })
+ )
+ fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
+ component = fixture.componentInstance
+ fixture.detectChanges()
+ })
+
+ it('should support add field', () => {
+ let addedField
+ component.added.subscribe((f) => (addedField = f))
+ component.documentId = 11
+ component.field = fields[0].id
+ component.addField()
+ expect(addedField).not.toBeUndefined()
+ })
+
+ it('should clear field on open / close, updated unused fields', () => {
+ component.field = fields[1].id
+ component.onOpenClose()
+ expect(component.field).toBeUndefined()
+
+ expect(component.unusedFields).toEqual(fields)
+ const updateSpy = jest.spyOn(
+ CustomFieldsDropdownComponent.prototype as any,
+ 'updateUnusedFields'
+ )
+ component.existingFields = [{ field: fields[1].id } as any]
+ component.onOpenClose()
+ expect(updateSpy).toHaveBeenCalled()
+ expect(component.unusedFields).toEqual([fields[0]])
+ })
+
+ it('should support creating field, show error if necessary', () => {
+ let modal: NgbModalRef
+ modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+ const toastErrorSpy = jest.spyOn(toastService, 'showError')
+ const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+ const getFieldsSpy = jest.spyOn(
+ CustomFieldsDropdownComponent.prototype as any,
+ 'getFields'
+ )
+
+ const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
+ createButton.triggerEventHandler('click')
+
+ expect(modal).not.toBeUndefined()
+ const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+
+ // fail first
+ editDialog.failed.emit({ error: 'error creating field' })
+ expect(toastErrorSpy).toHaveBeenCalled()
+ expect(getFieldsSpy).not.toHaveBeenCalled()
+
+ // succeed
+ editDialog.succeeded.emit(fields[0])
+ expect(toastInfoSpy).toHaveBeenCalled()
+ expect(getFieldsSpy).toHaveBeenCalled()
+ })
+
+ it('should support creating field with name', () => {
+ let modal: NgbModalRef
+ modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+ component.createField('Foo bar')
+
+ expect(modal).not.toBeUndefined()
+ const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+ expect(editDialog.object.name).toEqual('Foo bar')
+ })
+})
--- /dev/null
+import {
+ Component,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ Output,
+} from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { Subject, first, takeUntil } from 'rxjs'
+import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
+import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import {
+ PermissionAction,
+ PermissionType,
+ PermissionsService,
+} from 'src/app/services/permissions.service'
+
+@Component({
+ selector: 'pngx-custom-fields-dropdown',
+ templateUrl: './custom-fields-dropdown.component.html',
+ styleUrls: ['./custom-fields-dropdown.component.scss'],
+})
+export class CustomFieldsDropdownComponent implements OnDestroy {
+ @Input()
+ documentId: number
+
+ @Input()
+ disabled: boolean = false
+
+ @Input()
+ existingFields: PaperlessCustomFieldInstance[] = []
+
+ @Output()
+ added: EventEmitter<PaperlessCustomField> = new EventEmitter()
+
+ @Output()
+ created: EventEmitter<PaperlessCustomField> = new EventEmitter()
+
+ private customFields: PaperlessCustomField[] = []
+ public unusedFields: PaperlessCustomField[]
+
+ public name: string
+
+ public field: number
+
+ private unsubscribeNotifier: Subject<any> = new Subject()
+
+ get placeholderText(): string {
+ return $localize`Choose field`
+ }
+
+ get notFoundText(): string {
+ return $localize`No unused fields found`
+ }
+
+ get canCreateFields(): boolean {
+ return this.permissionsService.currentUserCan(
+ PermissionAction.Add,
+ PermissionType.CustomField
+ )
+ }
+
+ constructor(
+ private customFieldsService: CustomFieldsService,
+ private modalService: NgbModal,
+ private toastService: ToastService,
+ private permissionsService: PermissionsService
+ ) {
+ this.getFields()
+ }
+
+ ngOnDestroy(): void {
+ this.unsubscribeNotifier.next(this)
+ this.unsubscribeNotifier.complete()
+ }
+
+ private getFields() {
+ this.customFieldsService
+ .listAll()
+ .pipe(first(), takeUntil(this.unsubscribeNotifier))
+ .subscribe((result) => {
+ this.customFields = result.results
+ this.updateUnusedFields()
+ })
+ }
+
+ public getCustomFieldFromInstance(
+ instance: PaperlessCustomFieldInstance
+ ): PaperlessCustomField {
+ return this.customFields.find((f) => f.id === instance.field)
+ }
+
+ private updateUnusedFields() {
+ this.unusedFields = this.customFields.filter(
+ (f) =>
+ !this.existingFields?.find(
+ (e) => this.getCustomFieldFromInstance(e)?.id === f.id
+ )
+ )
+ }
+
+ onOpenClose() {
+ this.field = undefined
+ this.updateUnusedFields()
+ }
+
+ addField() {
+ this.added.emit(this.customFields.find((f) => f.id === this.field))
+ }
+
+ createField(newName: string = null) {
+ const modal = this.modalService.open(CustomFieldEditDialogComponent)
+ if (newName) modal.componentInstance.object = { name: newName }
+ modal.componentInstance.succeeded
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((newField) => {
+ this.toastService.showInfo($localize`Saved field "${newField.name}".`)
+ this.customFieldsService.clearCache()
+ this.getFields()
+ this.created.emit(newField)
+ })
+ modal.componentInstance.failed
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((e) => {
+ this.toastService.showError($localize`Error saving field.`, e)
+ })
+ }
+}
--- /dev/null
+<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
+ <div class="modal-header">
+ <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
+ <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
+ </button>
+ </div>
+ <div class="modal-body">
+ <pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
+ <pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
+ <small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
+ <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
+ </div>
+ </form>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldEditDialogComponent } from './custom-field-edit-dialog.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { SettingsService } from 'src/app/services/settings.service'
+import { SelectComponent } from '../../input/select/select.component'
+import { TextComponent } from '../../input/text/text.component'
+import { EditDialogMode } from '../edit-dialog.component'
+
+describe('CustomFieldEditDialogComponent', () => {
+ let component: CustomFieldEditDialogComponent
+ let settingsService: SettingsService
+ let fixture: ComponentFixture<CustomFieldEditDialogComponent>
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ CustomFieldEditDialogComponent,
+ IfPermissionsDirective,
+ IfOwnerDirective,
+ SelectComponent,
+ TextComponent,
+ SafeHtmlPipe,
+ ],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgSelectModule,
+ NgbModule,
+ ],
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
+ settingsService = TestBed.inject(SettingsService)
+ settingsService.currentUser = { id: 99, username: 'user99' }
+ component = fixture.componentInstance
+
+ fixture.detectChanges()
+ })
+
+ it('should support create and edit modes', () => {
+ component.dialogMode = EditDialogMode.CREATE
+ const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+ const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+ fixture.detectChanges()
+ expect(createTitleSpy).toHaveBeenCalled()
+ expect(editTitleSpy).not.toHaveBeenCalled()
+ component.dialogMode = EditDialogMode.EDIT
+ fixture.detectChanges()
+ expect(editTitleSpy).toHaveBeenCalled()
+ })
+
+ it('should disable data type select on edit', () => {
+ component.dialogMode = EditDialogMode.EDIT
+ fixture.detectChanges()
+ component.ngOnInit()
+ expect(component.objectForm.get('data_type').disabled).toBeTruthy()
+ })
+})
--- /dev/null
+import { Component, OnInit } from '@angular/core'
+import { FormGroup, FormControl } from '@angular/forms'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import {
+ DATA_TYPE_LABELS,
+ PaperlessCustomField,
+} from 'src/app/data/paperless-custom-field'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { UserService } from 'src/app/services/rest/user.service'
+import { SettingsService } from 'src/app/services/settings.service'
+import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
+
+@Component({
+ selector: 'pngx-custom-field-edit-dialog',
+ templateUrl: './custom-field-edit-dialog.component.html',
+ styleUrls: ['./custom-field-edit-dialog.component.scss'],
+})
+export class CustomFieldEditDialogComponent
+ extends EditDialogComponent<PaperlessCustomField>
+ implements OnInit
+{
+ constructor(
+ service: CustomFieldsService,
+ activeModal: NgbActiveModal,
+ userService: UserService,
+ settingsService: SettingsService
+ ) {
+ super(service, activeModal, userService, settingsService)
+ }
+
+ ngOnInit(): void {
+ super.ngOnInit()
+ if (this.typeFieldDisabled) {
+ this.objectForm.get('data_type').disable()
+ }
+ }
+
+ getCreateTitle() {
+ return $localize`Create new custom field`
+ }
+
+ getEditTitle() {
+ return $localize`Edit custom field`
+ }
+
+ getForm(): FormGroup {
+ return new FormGroup({
+ name: new FormControl(null),
+ data_type: new FormControl(null),
+ })
+ }
+
+ getDataTypes() {
+ return DATA_TYPE_LABELS
+ }
+
+ get typeFieldDisabled(): boolean {
+ return this.dialogMode === EditDialogMode.EDIT
+ }
+}
-import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core'
+import {
+ Directive,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnInit,
+ Output,
+ ViewChild,
+} from '@angular/core'
import { ControlValueAccessor } from '@angular/forms'
import { v4 as uuidv4 } from 'uuid'
@Input()
error: string
+ @Input()
+ hint: string
+
+ @Input()
+ horizontal: boolean = false
+
+ @Input()
+ removable: boolean = false
+
+ @Output()
+ removed: EventEmitter<AbstractInputComponent<any>> = new EventEmitter()
+
value: T
ngOnInit(): void {
}
inputId: string
-
- @Input()
- hint: string
}
-<div class="mb-3 form-check">
- <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
- <label class="form-check-label" [for]="inputId">{{title}}</label>
- <div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
+<div class="mb-3">
+ <div class="row">
+ <div *ngIf="horizontal" class="d-flex align-items-center position-relative hidden-button-container col-md-3">
+ <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+ <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+ <svg class="sidebaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ </svg> <ng-container i18n>Remove</ng-container>
+ </button>
+ </div>
+ <div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
+ <div class="form-check">
+ <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
+ <label *ngIf="!horizontal" class="form-check-label" [for]="inputId">{{title}}</label>
+ <div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
+ </div>
+ </div>
+ </div>
</div>
-<div class="mb-3">
- <label class="form-label" [for]="inputId">{{title}}</label>
- <div class="input-group" [class.is-invalid]="error">
- <input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
- (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
- name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
- <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
- <path 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"/>
- </svg>
- </button>
- <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#filter" />
- </svg>
- </button>
+<div class="mb-3" [class.pb-3]="error">
+ <div class="row">
+ <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+ <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+ <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+ <svg class="sidebaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ </svg> <ng-container i18n>Remove</ng-container>
+ </button>
+ </div>
+ <div class="position-relative" [class.col-md-9]="horizontal">
+ <div class="input-group" [class.is-invalid]="error">
+ <input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
+ (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
+ name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
+ <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
+ <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="buttonicon">
+ <use _ngcontent-ng-c3750736003="" xlink:href="assets/bootstrap-icons.svg#calendar"></use>
+ </svg>
+ </button>
+ <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#filter" />
+ </svg>
+ </button>
+ </div>
+ <div class="invalid-feedback position-absolute top-100" i18n>Invalid date.</div>
+ <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
+ <small *ngIf="getSuggestions().length > 0">
+ <span i18n>Suggestions:</span>
+ <ng-container *ngFor="let s of getSuggestions()">
+ <a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>
+ </ng-container>
+ </small>
+ </div>
</div>
- <div class="invalid-feedback" i18n>Invalid date.</div>
- <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
- <small *ngIf="getSuggestions().length > 0">
- <span i18n>Suggestions:</span>
- <ng-container *ngFor="let s of getSuggestions()">
- <a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>
- </ng-container>
- </small>
</div>
-<div class="mb-3">
- <label class="form-label" [for]="inputId">{{title}}</label>
- <div class="input-group" [class.is-invalid]="error">
- <input #inputField type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
- <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
+<div class="mb-3" [class.pb-3]="error">
+ <div class="row">
+ <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+ <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+ <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+ <svg class="sidebaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ </svg> <ng-container i18n>Remove</ng-container>
+ </button>
+ </div>
+ <div class="position-relative" [class.col-md-9]="horizontal">
+ <div class="input-group" [class.is-invalid]="error">
+ <input #inputField type="number" class="form-control" [step]="step" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
+ <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
+ </div>
+ <div class="invalid-feedback position-absolute top-100">
+ {{error}}
+ </div>
+ <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
+ </div>
</div>
- <div class="invalid-feedback">
- {{error}}
- </div>
- <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
-
</div>
component.nextAsn()
expect(component.value).toEqual(1002)
})
+
+ it('should support float & monetary values', () => {
+ component.writeValue(11.13)
+ expect(component.value).toEqual(11)
+ component.step = 0.01
+ component.writeValue(11.1)
+ expect(component.value).toEqual('11.10')
+ component.step = 0.1
+ component.writeValue(12.3456)
+ expect(component.value).toEqual(12.3456)
+ // float (step = .1) doesnt force 2 decimals
+ component.writeValue(11.1)
+ expect(component.value).toEqual(11.1)
+ })
})
@Input()
showAdd: boolean = true
+ @Input()
+ step: number = 1
+
constructor(private documentService: DocumentService) {
super()
}
this.onChange(this.value)
})
}
+
+ writeValue(newValue: any): void {
+ if (this.step === 1) newValue = parseInt(newValue, 10)
+ if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
+ super.writeValue(newValue)
+ }
}
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
- <label *ngIf="title" class="form-label" [for]="inputId">{{title}}</label>
- <div [class.input-group]="allowCreateNew || showFilter">
- <ng-select name="inputId" [(ngModel)]="value"
- [disabled]="disabled"
- [style.color]="textColor"
- [style.background]="backgroundColor"
- [class.private]="isPrivate"
- [clearable]="allowNull"
- [items]="items"
- [addTag]="allowCreateNew && addItemRef"
- addTagText="Add item"
- i18n-addTagText="Used for both types, correspondents, storage paths"
- [placeholder]="placeholder"
- [multiple]="multiple"
- [bindLabel]="bindLabel"
- bindValue="id"
- (change)="onChange(value)"
- (search)="onSearch($event)"
- (focus)="clearLastSearchTerm()"
- (clear)="clearLastSearchTerm()"
- (blur)="onBlur()">
- </ng-select>
- <button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#plus" />
- </svg>
- </button>
- <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#filter" />
- </svg>
+ <div class="row">
+ <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+ <label *ngIf="title" class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+ <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+ <svg class="sidebaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ </svg> <ng-container i18n>Remove</ng-container>
</button>
</div>
- <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
- <small *ngIf="getSuggestions().length > 0">
- <span i18n>Suggestions:</span>
- <ng-container *ngFor="let s of getSuggestions()">
- <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
- </ng-container>
-
-
- </small>
+ <div [class.col-md-9]="horizontal">
+ <div [class.input-group]="allowCreateNew || showFilter">
+ <ng-select name="inputId" [(ngModel)]="value"
+ [disabled]="disabled"
+ [style.color]="textColor"
+ [style.background]="backgroundColor"
+ [class.private]="isPrivate"
+ [clearable]="allowNull"
+ [items]="items"
+ [addTag]="allowCreateNew && addItemRef"
+ addTagText="Add item"
+ i18n-addTagText="Used for both types, correspondents, storage paths"
+ [placeholder]="placeholder"
+ [notFoundText]="notFoundText"
+ [multiple]="multiple"
+ [bindLabel]="bindLabel"
+ bindValue="id"
+ (change)="onChange(value)"
+ (search)="onSearch($event)"
+ (focus)="clearLastSearchTerm()"
+ (clear)="clearLastSearchTerm()"
+ (blur)="onBlur()">
+ </ng-select>
+ <button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#plus" />
+ </svg>
+ </button>
+ <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#filter" />
+ </svg>
+ </button>
+ </div>
+ <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
+ <small *ngIf="getSuggestions().length > 0">
+ <span i18n>Suggestions:</span>
+ <ng-container *ngFor="let s of getSuggestions()">
+ <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
+ </ng-container>
+ </small>
+ </div>
+ </div>
</div>
@Input()
showFilter: boolean = false
+ @Input()
+ notFoundText: string = $localize`No items found`
+
+ @Input()
+ disableCreateNew: boolean = false
+
@Output()
createNew = new EventEmitter<string>()
private _lastSearchTerm: string
get allowCreateNew(): boolean {
- return this.createNew.observers.length > 0
+ return !this.disableCreateNew && this.createNew.observers.length > 0
}
get isPrivate(): boolean {
-<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
- <label class="form-label" for="tags" i18n>{{title}}</label>
+<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="suggestions">
+ <div class="row">
+ <div class="d-flex align-items-center" [class.col-md-3]="horizontal">
+ <label class="form-label mb-md-0" for="tags" i18n>{{title}}</label>
+ </div>
+ <div class="position-relative" [class.col-md-9]="horizontal">
+ <div class="input-group flex-nowrap">
+ <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
+ [disabled]="disabled"
+ [multiple]="true"
+ [closeOnSelect]="false"
+ [clearSearchOnAdd]="true"
+ [hideSelected]="tags.length > 0"
+ [addTag]="allowCreate ? createTagRef : false"
+ addTagText="Add tag"
+ i18n-addTagText
+ (change)="onChange(value)">
- <div class="input-group flex-nowrap">
- <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
- [disabled]="disabled"
- [multiple]="true"
- [closeOnSelect]="false"
- [clearSearchOnAdd]="true"
- [hideSelected]="tags.length > 0"
- [addTag]="allowCreate ? createTagRef : false"
- addTagText="Add tag"
- i18n-addTagText
- (change)="onChange(value)">
-
- <ng-template ng-label-tmp let-item="item">
- <span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
- <svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
- <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ <ng-template ng-label-tmp let-item="item">
+ <span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
+ <svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ </svg>
+ <pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
+ </span>
+ </ng-template>
+ <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
+ <div class="tag-wrap">
+ <pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
+ </div>
+ </ng-template>
+ </ng-select>
+ <button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#plus" />
+ </svg>
+ </button>
+ <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>
- <pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
- </span>
- </ng-template>
- <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
- <div class="tag-wrap">
- <pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
- </div>
- </ng-template>
- </ng-select>
- <button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#plus" />
- </svg>
- </button>
- <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#filter" />
- </svg>
- </button>
+ </button>
+ </div>
+ <small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
+ <small *ngIf="getSuggestions().length > 0" class="position-absolute top-100">
+ <span i18n>Suggestions:</span>
+ <ng-container *ngFor="let tag of getSuggestions()">
+ <a (click)="addTag(tag.id)" [routerLink]="[]">{{tag?.name}}</a>
+ </ng-container>
+ </small>
+ </div>
</div>
- <small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
- <small *ngIf="getSuggestions().length > 0">
- <span i18n>Suggestions:</span>
- <ng-container *ngFor="let tag of getSuggestions()">
- <a (click)="addTag(tag.id)" [routerLink]="[]">{{tag.name}}</a>
- </ng-container>
-
-
- </small>
-
</div>
@Input()
showFilter: boolean = false
+ @Input()
+ horizontal: boolean = false
+
@Output()
filterDocuments = new EventEmitter<PaperlessTag[]>()
-<div class="mb-3">
- <label class="form-label" [for]="inputId">{{title}}</label>
- <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
- <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
- <div class="invalid-feedback">
- {{error}}
+<div class="mb-3" [class.pb-3]="error">
+ <div class="row">
+ <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+ <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+ <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+ <svg class="sidebaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ </svg> <ng-container i18n>Remove</ng-container>
+ </button>
+ </div>
+ <div class="position-relative" [class.col-md-9]="horizontal">
+ <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
+ <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
+ <div class="invalid-feedback position-absolute top-100">
+ {{error}}
+ </div>
+ </div>
</div>
</div>
--- /dev/null
+<div class="mb-3" [class.pb-3]="error">
+ <div class="row">
+ <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+ <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+ <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+ <svg class="sidebaricon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#x"/>
+ </svg> <ng-container i18n>Remove</ng-container>
+ </button>
+ </div>
+ <div [class.col-md-9]="horizontal">
+ <div class="input-group" [class.is-invalid]="error">
+ <input #inputField type="url" class="form-control" [class.is-invalid]="error" placeholder="https://" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
+ <a class="btn btn-outline-secondary rounded-end" title="Open link" i18n-title [href]="value" target="_blank">
+ <svg class="buttonicon mb-1" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#box-arrow-up-right" />
+ </svg>
+ </a>
+ <div class="invalid-feedback position-absolute top-100">
+ {{error}}
+ </div>
+ </div>
+ <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
+ </div>
+ </div>
+ </div>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+ FormsModule,
+ ReactiveFormsModule,
+ NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { UrlComponent } from './url.component'
+
+describe('TextComponent', () => {
+ let component: UrlComponent
+ let fixture: ComponentFixture<UrlComponent>
+ let input: HTMLInputElement
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ declarations: [UrlComponent],
+ providers: [],
+ imports: [FormsModule, ReactiveFormsModule],
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(UrlComponent)
+ fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+ component = fixture.componentInstance
+ fixture.detectChanges()
+ input = component.inputField.nativeElement
+ })
+
+ it('should support use of input field', () => {
+ expect(component.value).toBeUndefined()
+ // TODO: why doesnt this work?
+ // input.value = 'foo'
+ // input.dispatchEvent(new Event('change'))
+ // fixture.detectChanges()
+ // expect(component.value).toEqual('foo')
+ })
+})
--- /dev/null
+import { Component, forwardRef } from '@angular/core'
+import { NG_VALUE_ACCESSOR } from '@angular/forms'
+import { AbstractInputComponent } from '../abstract-input'
+
+@Component({
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => UrlComponent),
+ multi: true,
+ },
+ ],
+ selector: 'pngx-input-url',
+ templateUrl: './url.component.html',
+ styleUrls: ['./url.component.scss'],
+})
+export class UrlComponent extends AbstractInputComponent<string> {
+ constructor() {
+ super()
+ }
+}
-.h2 {
+h3 {
min-height: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
- .h2 {
+ h3 {
min-height: 2.8rem;
}
}
<div ngbDropdown>
- <button class="btn btn-sm btn-outline-primary me-2" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
+ <button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#link" />
</svg>
</div>
</div>
- <div ngbDropdown>
+ <div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
</div>
</div>
- <pngx-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
-
- <button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#x" />
- </svg>
- </button>
-
- <div class="button-group">
- <button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
- </svg>
- </button>
- <button type="button" class="btn btn-sm btn-outline-primary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
- <svg class="buttonicon" fill="currentColor">
- <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
- </svg>
- </button>
- </div>
+ <pngx-custom-fields-dropdown
+ *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
+ class="me-2"
+ [documentId]="documentId"
+ [disabled]="!userIsOwner"
+ [existingFields]="document?.custom_fields"
+ (created)="refreshCustomFields()"
+ (added)="addField($event)">
+ </pngx-custom-fields-dropdown>
+ <pngx-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
</pngx-page-header>
-
<div class="row">
<div class="col-md-6 col-xl-4 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()">
- <ul ngbNav #nav="ngbNav" class="nav-tabs" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
+ <div class="btn-toolbar mb-1 pb-3 border-bottom">
+ <div class="btn-group">
+ <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#x" />
+ </svg>
+ </button>
+ <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
+ </svg>
+ </button>
+ <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
+ <svg class="buttonicon" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
+ </svg>
+ </button>
+ </div>
+
+ <div class="btn-group ms-auto">
+ <button type="button" class="btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
+ <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
+ <button *ngIf="hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & next</button>
+ <button *ngIf="!hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & close</button>
+ <button type="submit" class="btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
+ </ng-container>
+ </div>
+ </div>
+
+ <ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
<li [ngbNavItem]="DocumentDetailNavIDs.Details">
<a ngbNavLink i18n>Details</a>
<ng-template ngbNavContent>
-
- <pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
- <pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></pngx-input-number>
- <pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
- [error]="error?.created_date"></pngx-input-date>
- <pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
- (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
- <pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
- (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
- <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
- (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
- <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
-
+ <div>
+ <pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
+ <pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
+ <pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+ [error]="error?.created_date"></pngx-input-date>
+ <pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+ (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
+ <pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+ (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
+ <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+ (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
+ <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
+ <ng-container *ngFor="let fieldInstance of document?.custom_fields; let i = index">
+ <div [formGroup]="customFieldFormFields.controls[i]">
+ <pngx-input-text formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.String" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-text>
+ <pngx-input-date formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Date" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-date>
+ <pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Integer" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [error]="getCustomFieldError(i)"></pngx-input-number>
+ <pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Float" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".1" [error]="getCustomFieldError(i)"></pngx-input-number>
+ <pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Monetary" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
+ <pngx-input-check formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Boolean" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
+ <pngx-input-url formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Url" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
+ </div>
+ </ng-container>
+ </div>
</ng-template>
</li>
<li [ngbNavItem]="DocumentDetailNavIDs.Content">
<a ngbNavLink i18n>Content</a>
<ng-template ngbNavContent>
- <div class="mb-3">
+ <div>
<textarea class="form-control" id="content" rows="20" formControlName='content' [class.rtl]="isRTL"></textarea>
</div>
</ng-template>
</li>
</ul>
- <div [ngbNavOutlet]="nav" class="mt-2"></div>
+ <div [ngbNavOutlet]="nav" class="mt-3"></div>
- <ng-container>
- <button type="button" class="btn btn-outline-secondary me-2" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
- <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
- <button *ngIf="hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & next</button>
- <button *ngIf="!hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & close</button>
- <button type="submit" class="btn btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
- </ng-container>
- </ng-container>
</form>
</div>
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { DocumentDetailComponent } from './document-detail.component'
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
+import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
+import { PaperlessCustomFieldDataType } from 'src/app/data/paperless-custom-field'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
const doc: PaperlessDocument = {
id: 3,
user: 2,
},
],
+ custom_fields: [
+ {
+ field: 0,
+ document: 3,
+ created: new Date(),
+ value: 'custom foo bar',
+ },
+ ],
}
+const customFields = [
+ {
+ id: 0,
+ name: 'Field 1',
+ data_type: PaperlessCustomFieldDataType.String,
+ created: new Date(),
+ },
+ {
+ id: 1,
+ name: 'Custom Field 2',
+ data_type: PaperlessCustomFieldDataType.Integer,
+ created: new Date(),
+ },
+]
+
describe('DocumentDetailComponent', () => {
let component: DocumentDetailComponent
let fixture: ComponentFixture<DocumentDetailComponent>
let toastService: ToastService
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
+ let customFieldsService: CustomFieldsService
let currentUserCan = true
let currentUserHasObjectPermissions = true
PdfViewerComponent,
SafeUrlPipe,
ShareLinksDropdownComponent,
+ CustomFieldsDropdownComponent,
],
providers: [
DocumentTitlePipe,
}),
},
},
+ CustomFieldsService,
{
provide: PermissionsService,
useValue: {
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService)
+ customFieldsService = TestBed.inject(CustomFieldsService)
fixture = TestBed.createComponent(DocumentDetailComponent)
component = fixture.componentInstance
})
it('should load already-opened document via param', () => {
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc)
+ jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
+ of({
+ count: customFields.length,
+ all: customFields.map((f) => f.id),
+ results: customFields,
+ })
+ )
fixture.detectChanges() // calls ngOnInit
expect(component.document).toEqual(doc)
})
expect(toastSpy).toHaveBeenCalledWith('Error retrieving metadata', error)
})
+ it('should display custom fields', () => {
+ initNormally()
+ expect(fixture.debugElement.nativeElement.textContent).toContain(
+ customFields[0].name
+ )
+ })
+
+ it('should support add custom field, correctly send via post', () => {
+ initNormally()
+ const initialLength = doc.custom_fields.length
+ expect(component.customFieldFormFields).toHaveLength(initialLength)
+ component.addField(customFields[1])
+ fixture.detectChanges()
+ expect(component.document.custom_fields).toHaveLength(initialLength + 1)
+ expect(component.customFieldFormFields).toHaveLength(initialLength + 1)
+ expect(fixture.debugElement.nativeElement.textContent).toContain(
+ customFields[1].name
+ )
+ const updateSpy = jest.spyOn(documentService, 'update')
+ component.save(true)
+ expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(2)
+ expect(updateSpy.mock.lastCall[0].custom_fields[1]).toEqual({
+ field: customFields[1].id,
+ value: null,
+ })
+ })
+
+ it('should support remove custom field, correctly send via post', () => {
+ initNormally()
+ const initialLength = doc.custom_fields.length
+ expect(component.customFieldFormFields).toHaveLength(initialLength)
+ component.removeField(doc.custom_fields[0])
+ fixture.detectChanges()
+ expect(component.document.custom_fields).toHaveLength(initialLength - 1)
+ expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
+ expect(fixture.debugElement.nativeElement.textContent).not.toContain(
+ 'Field 1'
+ )
+ const updateSpy = jest.spyOn(documentService, 'update')
+ component.save(true)
+ expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(
+ initialLength - 1
+ )
+ })
+
+ it('should show custom field errors', () => {
+ initNormally()
+ component.error = {
+ custom_fields: [
+ {},
+ {},
+ { value: ['This field may not be null.'] },
+ {},
+ { non_field_errors: ['Enter a valid URL.'] },
+ ],
+ }
+ expect(component.getCustomFieldError(2)).toEqual([
+ 'This field may not be null.',
+ ])
+ expect(component.getCustomFieldError(4)).toEqual(['Enter a valid URL.'])
+ })
+
+ it('should refresh custom fields when created', () => {
+ initNormally()
+ const refreshSpy = jest.spyOn(component, 'refreshCustomFields')
+ fixture.debugElement
+ .query(By.directive(CustomFieldsDropdownComponent))
+ .triggerEventHandler('created')
+ expect(refreshSpy).toHaveBeenCalled()
+ })
+
function initNormally() {
- jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
+ jest
+ .spyOn(documentService, 'get')
+ .mockReturnValueOnce(of(Object.assign({}, doc)))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
+ jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
+ of({
+ count: customFields.length,
+ all: customFields.map((f) => f.id),
+ results: customFields,
+ })
+ )
fixture.detectChanges()
}
})
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'
-import { FormControl, FormGroup } from '@angular/forms'
+import { FormArray, FormControl, FormGroup } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import {
NgbDateStruct,
import { ObjectWithId } from 'src/app/data/object-with-id'
import { FilterRule } from 'src/app/data/filter-rule'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
-import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
+import {
+ PaperlessCustomField,
+ PaperlessCustomFieldDataType,
+} from 'src/app/data/paperless-custom-field'
+import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
enum DocumentDetailNavIDs {
Details = 1,
archive_serial_number: new FormControl(),
tags: new FormControl([]),
permissions_form: new FormControl(null),
+ custom_fields: new FormArray([]),
})
previewCurrentPage: number = 1
ogDate: Date
+ customFields: PaperlessCustomField[]
+ public readonly PaperlessCustomFieldDataType = PaperlessCustomFieldDataType
+
@ViewChild('nav') nav: NgbNav
@ViewChild('pdfPreview') set pdfPreview(element) {
// this gets called when compontent added or removed from DOM
private storagePathService: StoragePathService,
private permissionsService: PermissionsService,
private userService: UserService,
+ private customFieldsService: CustomFieldsService,
private http: HttpClient
) {
super()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.users = result.results))
+ this.getCustomFields()
+
this.route.paramMap
.pipe(
takeUntil(this.unsubscribeNotifier),
owner: doc.owner,
set_permissions: doc.permissions,
},
+ custom_fields: doc.custom_fields,
})
this.isDirty$ = dirtyCheck(
updateComponent(doc: PaperlessDocument) {
this.document = doc
this.requiresPassword = false
+ // this.customFields = doc.custom_fields.concat([])
+ this.updateFormForCustomFields()
this.documentsService
.getMetadata(doc.id)
.pipe(first())
if (!this.userCanEdit) this.documentForm.disable()
}
+ get customFieldFormFields(): FormArray {
+ return this.documentForm.get('custom_fields') as FormArray
+ }
+
createDocumentType(newName: string) {
var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
backdrop: 'static',
set_permissions: doc.permissions,
}
this.title = doc.title
+ this.updateFormForCustomFields()
this.documentForm.patchValue(doc)
this.openDocumentService.setDirty(doc, false)
},
close && this.close()
this.networkActive = false
this.error = null
+ this.openDocumentService.refreshDocument(this.documentId)
},
error: (error) => {
this.networkActive = false
this.documentListViewService.quickFilter(filterRules)
}
+
+ private getCustomFields() {
+ this.customFieldsService
+ .listAll()
+ .pipe(first(), takeUntil(this.unsubscribeNotifier))
+ .subscribe((result) => (this.customFields = result.results))
+ }
+
+ public refreshCustomFields() {
+ this.customFieldsService.clearCache()
+ this.getCustomFields()
+ }
+
+ public getCustomFieldFromInstance(
+ instance: PaperlessCustomFieldInstance
+ ): PaperlessCustomField {
+ return this.customFields?.find((f) => f.id === instance.field)
+ }
+
+ public getCustomFieldError(index: number) {
+ const fieldError = this.error?.custom_fields?.[index]
+ return fieldError?.['non_field_errors'] ?? fieldError?.['value']
+ }
+
+ private updateFormForCustomFields(emitEvent: boolean = false) {
+ this.customFieldFormFields.clear({ emitEvent: false })
+ this.document.custom_fields?.forEach((fieldInstance) => {
+ this.customFieldFormFields.push(
+ new FormGroup({
+ field: new FormControl(
+ this.getCustomFieldFromInstance(fieldInstance)?.id
+ ),
+ value: new FormControl(fieldInstance.value),
+ }),
+ { emitEvent }
+ )
+ })
+ }
+
+ public addField(field: PaperlessCustomField) {
+ this.document.custom_fields.push({
+ field: field.id,
+ value: null,
+ document: this.documentId,
+ created: new Date(),
+ })
+ this.updateFormForCustomFields(true)
+ }
+
+ public removeField(fieldInstance: PaperlessCustomFieldInstance) {
+ this.document.custom_fields.splice(
+ this.document.custom_fields.indexOf(fieldInstance),
+ 1
+ )
+ this.updateFormForCustomFields(true)
+ this.documentForm.updateValueAndValidity()
+ }
}
FILTER_OWNER_ANY,
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
+ FILTER_CUSTOM_FIELDS,
} from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
}))
+ it('should ingest text filter rules for custom fields', fakeAsync(() => {
+ expect(component.textFilter).toEqual(null)
+ component.filterRules = [
+ {
+ rule_type: FILTER_CUSTOM_FIELDS,
+ value: 'foo',
+ },
+ ]
+ expect(component.textFilter).toEqual('foo')
+ expect(component.textFilterTarget).toEqual('custom-fields') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
+ }))
+
it('should ingest text filter rules for doc asn is null', fakeAsync(() => {
expect(component.textFilterTarget).toEqual('title-content')
expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
])
}))
- it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
+ it('should convert user input to correct filter rules on custom fields query', fakeAsync(() => {
component.textFilterInput.nativeElement.value = 'foo'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[3]
+ textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
+ fixture.detectChanges()
+ tick(400)
+ expect(component.textFilterTarget).toEqual('custom-fields')
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_CUSTOM_FIELDS,
+ value: 'foo',
+ },
+ ])
+ }))
+
+ it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
+ component.textFilterInput.nativeElement.value = 'foo'
+ component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+ const textFieldTargetDropdown = fixture.debugElement.queryAll(
+ By.directive(NgbDropdownItem)
+ )[4]
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
fixture.detectChanges()
tick(400)
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY,
+ FILTER_CUSTOM_FIELDS,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
const TEXT_FILTER_TARGET_ASN = 'asn'
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
+const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
const TEXT_FILTER_MODIFIER_NULL = 'is null'
name: $localize`Title & content`,
},
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
+ {
+ id: TEXT_FILTER_TARGET_CUSTOM_FIELDS,
+ name: $localize`Custom fields`,
+ },
{
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
name: $localize`Advanced search`,
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break
+ case FILTER_CUSTOM_FIELDS:
+ this._textFilter = rule.value
+ this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
+ break
case FILTER_FULLTEXT_QUERY:
let allQueryArgs = rule.value.split(',')
let textQueryArgs = []
})
}
}
+ if (
+ this._textFilter &&
+ this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
+ ) {
+ filterRules.push({
+ rule_type: FILTER_CUSTOM_FIELDS,
+ value: this._textFilter,
+ })
+ }
if (
this._textFilter &&
this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY
--- /dev/null
+<pngx-page-header title="Custom Fields">
+ <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
+ <svg class="sidebaricon me-1" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
+ </svg>
+ <ng-container i18n>Add Field</ng-container>
+ </button>
+</pngx-page-header>
+
+<ul class="list-group">
+
+ <li class="list-group-item">
+ <div class="row">
+ <div class="col" i18n>Name</div>
+ <div class="col" i18n>Data Type</div>
+ <div class="col" i18n>Actions</div>
+ </div>
+ </li>
+
+ <li *ngFor="let field of fields" class="list-group-item">
+ <div class="row">
+ <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
+ <div class="col d-flex align-items-center">{{getDataType(field)}}</div>
+ <div class="col">
+ <div class="btn-group">
+ <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
+ <svg class="buttonicon-sm" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#pencil" />
+ </svg> <ng-container i18n>Edit</ng-container>
+ </button>
+ <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
+ <svg class="buttonicon-sm" fill="currentColor">
+ <use xlink:href="assets/bootstrap-icons.svg#trash" />
+ </svg> <ng-container i18n>Delete</ng-container>
+ </button>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li *ngIf="fields.length === 0" class="list-group-item" i18n>No fields defined.</li>
+</ul>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldsComponent } from './custom-fields.component'
+import {
+ PaperlessCustomField,
+ PaperlessCustomFieldDataType,
+} from 'src/app/data/paperless-custom-field'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { By } from '@angular/platform-browser'
+import {
+ NgbModal,
+ NgbPaginationModule,
+ NgbModalModule,
+ NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { of, throwError } from 'rxjs'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+
+const fields: PaperlessCustomField[] = [
+ {
+ id: 0,
+ name: 'Field 1',
+ data_type: PaperlessCustomFieldDataType.String,
+ },
+ {
+ id: 1,
+ name: 'Field 2',
+ data_type: PaperlessCustomFieldDataType.Integer,
+ },
+]
+
+describe('CustomFieldsComponent', () => {
+ let component: CustomFieldsComponent
+ let fixture: ComponentFixture<CustomFieldsComponent>
+ let customFieldsService: CustomFieldsService
+ let modalService: NgbModal
+ let toastService: ToastService
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ CustomFieldsComponent,
+ IfPermissionsDirective,
+ PageHeaderComponent,
+ ConfirmDialogComponent,
+ ],
+ providers: [
+ {
+ provide: PermissionsService,
+ useValue: {
+ currentUserCan: () => true,
+ currentUserHasObjectPermissions: () => true,
+ currentUserOwnsObject: () => true,
+ },
+ },
+ ],
+ imports: [
+ HttpClientTestingModule,
+ NgbPaginationModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgbModalModule,
+ ],
+ })
+
+ customFieldsService = TestBed.inject(CustomFieldsService)
+ jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
+ of({
+ count: fields.length,
+ all: fields.map((o) => o.id),
+ results: fields,
+ })
+ )
+ modalService = TestBed.inject(NgbModal)
+ toastService = TestBed.inject(ToastService)
+
+ fixture = TestBed.createComponent(CustomFieldsComponent)
+ component = fixture.componentInstance
+ fixture.detectChanges()
+ })
+
+ it('should support create, show notification on error / success', () => {
+ let modal: NgbModalRef
+ modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+ const toastErrorSpy = jest.spyOn(toastService, 'showError')
+ const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+ const reloadSpy = jest.spyOn(component, 'reload')
+
+ const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
+ createButton.triggerEventHandler('click')
+
+ expect(modal).not.toBeUndefined()
+ const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+
+ // fail first
+ editDialog.failed.emit({ error: 'error creating item' })
+ expect(toastErrorSpy).toHaveBeenCalled()
+ expect(reloadSpy).not.toHaveBeenCalled()
+
+ // succeed
+ editDialog.succeeded.emit(fields[0])
+ expect(toastInfoSpy).toHaveBeenCalled()
+ expect(reloadSpy).toHaveBeenCalled()
+ })
+
+ it('should support edit, show notification on error / success', () => {
+ let modal: NgbModalRef
+ modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+ const toastErrorSpy = jest.spyOn(toastService, 'showError')
+ const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+ const reloadSpy = jest.spyOn(component, 'reload')
+
+ const editButton = fixture.debugElement.queryAll(By.css('button'))[1]
+ editButton.triggerEventHandler('click')
+
+ expect(modal).not.toBeUndefined()
+ const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+ expect(editDialog.object).toEqual(fields[0])
+
+ // fail first
+ editDialog.failed.emit({ error: 'error editing item' })
+ expect(toastErrorSpy).toHaveBeenCalled()
+ expect(reloadSpy).not.toHaveBeenCalled()
+
+ // succeed
+ editDialog.succeeded.emit(fields[0])
+ expect(toastInfoSpy).toHaveBeenCalled()
+ expect(reloadSpy).toHaveBeenCalled()
+ })
+
+ it('should support delete, show notification on error / success', () => {
+ let modal: NgbModalRef
+ modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+ const toastErrorSpy = jest.spyOn(toastService, 'showError')
+ const deleteSpy = jest.spyOn(customFieldsService, 'delete')
+ const reloadSpy = jest.spyOn(component, 'reload')
+
+ const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
+ deleteButton.triggerEventHandler('click')
+
+ expect(modal).not.toBeUndefined()
+ const editDialog = modal.componentInstance as ConfirmDialogComponent
+
+ // fail first
+ deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
+ editDialog.confirmClicked.emit()
+ expect(toastErrorSpy).toHaveBeenCalled()
+ expect(reloadSpy).not.toHaveBeenCalled()
+
+ // succeed
+ deleteSpy.mockReturnValueOnce(of(true))
+ editDialog.confirmClicked.emit()
+ expect(reloadSpy).toHaveBeenCalled()
+ })
+})
--- /dev/null
+import { Component, OnInit } from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { Subject, takeUntil } from 'rxjs'
+import {
+ DATA_TYPE_LABELS,
+ PaperlessCustomField,
+} from 'src/app/data/paperless-custom-field'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
+import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+
+@Component({
+ selector: 'pngx-custom-fields',
+ templateUrl: './custom-fields.component.html',
+ styleUrls: ['./custom-fields.component.scss'],
+})
+export class CustomFieldsComponent
+ extends ComponentWithPermissions
+ implements OnInit
+{
+ public fields: PaperlessCustomField[] = []
+
+ private unsubscribeNotifier: Subject<any> = new Subject()
+ constructor(
+ private customFieldsService: CustomFieldsService,
+ public permissionsService: PermissionsService,
+ private modalService: NgbModal,
+ private toastService: ToastService
+ ) {
+ super()
+ }
+
+ ngOnInit() {
+ this.reload()
+ }
+
+ reload() {
+ this.customFieldsService
+ .listAll()
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((r) => {
+ this.fields = r.results
+ })
+ }
+
+ editField(field: PaperlessCustomField) {
+ const modal = this.modalService.open(CustomFieldEditDialogComponent)
+ modal.componentInstance.dialogMode = field
+ ? EditDialogMode.EDIT
+ : EditDialogMode.CREATE
+ modal.componentInstance.object = field
+ modal.componentInstance.succeeded
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((newField) => {
+ this.toastService.showInfo($localize`Saved field "${newField.name}".`)
+ this.customFieldsService.clearCache()
+ this.reload()
+ })
+ modal.componentInstance.failed
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((e) => {
+ this.toastService.showError($localize`Error saving field.`, e)
+ })
+ }
+
+ deleteField(field: PaperlessCustomField) {
+ const modal = this.modalService.open(ConfirmDialogComponent, {
+ backdrop: 'static',
+ })
+ modal.componentInstance.title = $localize`Confirm delete field`
+ modal.componentInstance.messageBold = $localize`This operation will permanently delete this field.`
+ modal.componentInstance.message = $localize`This operation cannot be undone.`
+ modal.componentInstance.btnClass = 'btn-danger'
+ modal.componentInstance.btnCaption = $localize`Proceed`
+ modal.componentInstance.confirmClicked.subscribe(() => {
+ modal.componentInstance.buttonsEnabled = false
+ this.customFieldsService.delete(field).subscribe({
+ next: () => {
+ modal.close()
+ this.toastService.showInfo($localize`Deleted field`)
+ this.customFieldsService.clearCache()
+ this.reload()
+ },
+ error: (e) => {
+ this.toastService.showError($localize`Error deleting field.`, e)
+ },
+ })
+ })
+ }
+
+ getDataType(field: PaperlessCustomField): string {
+ return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
+ }
+}
export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
+export const FILTER_CUSTOM_FIELDS = 36
+
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
id: FILTER_TITLE,
datatype: 'number',
multi: true,
},
+ {
+ id: FILTER_CUSTOM_FIELDS,
+ filtervar: 'custom_fields__icontains',
+ datatype: 'string',
+ multi: false,
+ },
]
export interface FilterRuleType {
--- /dev/null
+import { ObjectWithId } from './object-with-id'
+import { PaperlessCustomField } from './paperless-custom-field'
+
+export interface PaperlessCustomFieldInstance extends ObjectWithId {
+ document: number // PaperlessDocument
+ field: number // PaperlessCustomField
+ created: Date
+ value?: any
+}
--- /dev/null
+import { ObjectWithId } from './object-with-id'
+
+export enum PaperlessCustomFieldDataType {
+ String = 'string',
+ Url = 'url',
+ Date = 'date',
+ Boolean = 'boolean',
+ Integer = 'integer',
+ Float = 'float',
+ Monetary = 'monetary',
+}
+
+export const DATA_TYPE_LABELS = [
+ {
+ id: PaperlessCustomFieldDataType.Boolean,
+ name: $localize`Boolean`,
+ },
+ {
+ id: PaperlessCustomFieldDataType.Date,
+ name: $localize`Date`,
+ },
+ {
+ id: PaperlessCustomFieldDataType.Integer,
+ name: $localize`Integer`,
+ },
+ {
+ id: PaperlessCustomFieldDataType.Float,
+ name: $localize`Number`,
+ },
+ {
+ id: PaperlessCustomFieldDataType.Monetary,
+ name: $localize`Monetary`,
+ },
+ {
+ id: PaperlessCustomFieldDataType.String,
+ name: $localize`Text`,
+ },
+ {
+ id: PaperlessCustomFieldDataType.Url,
+ name: $localize`Url`,
+ },
+]
+
+export interface PaperlessCustomField extends ObjectWithId {
+ data_type: PaperlessCustomFieldDataType
+ name: string
+ created?: Date
+}
import { PaperlessStoragePath } from './paperless-storage-path'
import { ObjectWithPermissions } from './object-with-permissions'
import { PaperlessDocumentNote } from './paperless-document-note'
+import { PaperlessCustomFieldInstance } from './paperless-custom-field-instance'
export interface SearchHit {
score?: number
notes?: PaperlessDocumentNote[]
__search_hit__?: SearchHit
+
+ custom_fields?: PaperlessCustomFieldInstance[]
}
'view_consumptiontemplate',
'change_consumptiontemplate',
'delete_consumptiontemplate',
+ 'add_customfield',
+ 'view_customfield',
+ 'change_customfield',
+ 'delete_customfield',
],
{
username: 'testuser',
Admin = '%s_logentry',
ShareLink = '%s_sharelink',
ConsumptionTemplate = '%s_consumptiontemplate',
+ CustomField = '%s_customfield',
}
@Injectable({
--- /dev/null
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+import { CustomFieldsService } from './custom-fields.service'
+
+let httpTestingController: HttpTestingController
+let service: CustomFieldsService
+let subscription: Subscription
+const endpoint = 'custom_fields'
+
+// run common tests
+commonAbstractPaperlessServiceTests(endpoint, CustomFieldsService)
--- /dev/null
+import { Injectable } from '@angular/core'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { AbstractPaperlessService } from './abstract-paperless-service'
+import { Observable } from 'rxjs'
+import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
+import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
+
+@Injectable({
+ providedIn: 'root',
+})
+export class CustomFieldsService extends AbstractPaperlessService<PaperlessCustomField> {
+ constructor(http: HttpClient) {
+ super(http, 'custom_fields')
+ }
+}
}
}
+.nav-underline {
+ .nav-link {
+ &.active, &:hover, &:focus {
+ color: var(--bs-primary);
+ }
+ }
+}
+
.ng-select-container,
.ng-select.ng-select-opened > .ng-select-container,
.ng-dropdown-panel,
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0, 0, 0.2, 1);
}
+
+.hidden-button-container {
+ button {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity .2s ease;
+ }
+ &:hover {
+ button {
+ opacity: 1;
+ pointer-events: initial;
+ }
+ }
+}
from guardian.admin import GuardedModelAdmin
from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import Note
list_display_links = ("created",)
+class CustomFieldsAdmin(GuardedModelAdmin):
+ fields = ("name", "created", "data_type")
+ readonly_fields = ("created", "data_type")
+ list_display = ("name", "created", "data_type")
+ list_filter = ("created", "data_type")
+
+
+class CustomFieldInstancesAdmin(GuardedModelAdmin):
+ fields = ("field", "document", "created", "value")
+ readonly_fields = ("field", "document", "created", "value")
+ list_display = ("field", "document", "value", "created")
+ list_filter = ("document", "created")
+
+
admin.site.register(Correspondent, CorrespondentAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(DocumentType, DocumentTypeAdmin)
admin.site.register(PaperlessTask, TaskAdmin)
admin.site.register(Note, NotesAdmin)
admin.site.register(ShareLink, ShareLinksAdmin)
+admin.site.register(CustomField, CustomFieldsAdmin)
+admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin)
if settings.AUDIT_LOG_ENABLED:
return qs
+class CustomFieldsFilter(Filter):
+ def filter(self, qs, value):
+ if value:
+ return (
+ qs.filter(custom_fields__field__name__icontains=value)
+ | qs.filter(custom_fields__value_text__icontains=value)
+ | qs.filter(custom_fields__value_bool__icontains=value)
+ | qs.filter(custom_fields__value_int__icontains=value)
+ | qs.filter(custom_fields__value_date__icontains=value)
+ | qs.filter(custom_fields__value_url__icontains=value)
+ )
+ else:
+ return qs
+
+
class DocumentFilterSet(FilterSet):
is_tagged = BooleanFilter(
label="Is tagged",
owner__id__none = ObjectFilter(field_name="owner", exclude=True)
+ custom_fields__icontains = CustomFieldsFilter()
+
class Meta:
model = Document
fields = {
"storage_path__name": CHAR_KWARGS,
"owner": ["isnull"],
"owner__id": ID_KWARGS,
+ "custom_fields": ["icontains"],
}
from whoosh.searching import Searcher
from whoosh.writing import AsyncWriter
+# from documents.models import CustomMetadata
+from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import Note
from documents.models import User
has_path=BOOLEAN(),
notes=TEXT(),
num_notes=NUMERIC(sortable=True, signed=False),
+ custom_fields=TEXT(),
+ custom_field_count=NUMERIC(sortable=True, signed=False),
owner=TEXT(),
owner_id=NUMERIC(),
has_owner=BOOLEAN(),
)
-def open_index(recreate=False):
+def open_index(recreate=False) -> FileIndex:
try:
if exists_in(settings.INDEX_DIR) and not recreate:
return open_dir(settings.INDEX_DIR, schema=get_schema())
@contextmanager
-def open_index_writer(optimize=False):
+def open_index_writer(optimize=False) -> AsyncWriter:
writer = AsyncWriter(open_index())
try:
@contextmanager
-def open_index_searcher():
+def open_index_searcher() -> Searcher:
searcher = open_index().searcher()
try:
tags = ",".join([t.name for t in doc.tags.all()])
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
+ custom_fields = ",".join(
+ [str(c) for c in CustomFieldInstance.objects.filter(document=doc)],
+ )
asn = doc.archive_serial_number
if asn is not None and (
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
has_path=doc.storage_path is not None,
notes=notes,
num_notes=len(notes),
+ custom_fields=custom_fields,
+ custom_field_count=len(doc.custom_fields.all()),
owner=doc.owner.username if doc.owner else None,
owner_id=doc.owner.id if doc.owner else None,
has_owner=doc.owner is not None,
)
-def remove_document(writer, doc):
+def remove_document(writer: AsyncWriter, doc: Document):
remove_document_by_id(writer, doc.pk)
-def remove_document_by_id(writer, doc_id):
+def remove_document_by_id(writer: AsyncWriter, doc_id):
writer.delete_by_term("id", doc_id)
-def add_or_update_document(document):
+def add_or_update_document(document: Document):
with open_index_writer() as writer:
update_document(writer, document)
-def remove_document_from_index(document):
+def remove_document_from_index(document: Document):
with open_index_writer() as writer:
remove_document(writer, document)
"created": ("created", ["date__lt", "date__gt"]),
"checksum": ("checksum", ["icontains", "istartswith"]),
"original_filename": ("original_filename", ["icontains", "istartswith"]),
+ "custom_fields": ("custom_fields", ["icontains", "istartswith"]),
}
def _get_query(self):
def _get_query(self):
q_str = self.query_params["query"]
qp = MultifieldParser(
- ["content", "title", "correspondent", "tag", "type", "notes"],
+ [
+ "content",
+ "title",
+ "correspondent",
+ "tag",
+ "type",
+ "notes",
+ "custom_fields",
+ ],
self.searcher.ixreader.schema,
)
qp.add_plugin(DateParserPlugin(basedate=timezone.now()))
--- /dev/null
+# Generated by Django 4.2.6 on 2023-11-02 17:38
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "1039_consumptiontemplate"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="CustomField",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ models.DateTimeField(
+ db_index=True,
+ default=django.utils.timezone.now,
+ editable=False,
+ verbose_name="created",
+ ),
+ ),
+ ("name", models.CharField(max_length=128)),
+ (
+ "data_type",
+ models.CharField(
+ choices=[
+ ("string", "String"),
+ ("url", "URL"),
+ ("date", "Date"),
+ ("boolean", "Boolean"),
+ ("integer", "Integer"),
+ ("float", "Float"),
+ ("monetary", "Monetary"),
+ ],
+ editable=False,
+ max_length=50,
+ verbose_name="data type",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "custom field",
+ "verbose_name_plural": "custom fields",
+ "ordering": ("created",),
+ },
+ ),
+ migrations.CreateModel(
+ name="CustomFieldInstance",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ models.DateTimeField(
+ db_index=True,
+ default=django.utils.timezone.now,
+ editable=False,
+ verbose_name="created",
+ ),
+ ),
+ ("value_text", models.CharField(max_length=128, null=True)),
+ ("value_bool", models.BooleanField(null=True)),
+ ("value_url", models.URLField(null=True)),
+ ("value_date", models.DateField(null=True)),
+ ("value_int", models.IntegerField(null=True)),
+ ("value_float", models.FloatField(null=True)),
+ (
+ "value_monetary",
+ models.DecimalField(decimal_places=2, max_digits=12, null=True),
+ ),
+ (
+ "document",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="custom_fields",
+ to="documents.document",
+ ),
+ ),
+ (
+ "field",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="fields",
+ to="documents.customfield",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "custom field instance",
+ "verbose_name_plural": "custom field instances",
+ "ordering": ("created",),
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="customfield",
+ constraint=models.UniqueConstraint(
+ fields=("name",),
+ name="documents_customfield_unique_name",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="customfieldinstance",
+ constraint=models.UniqueConstraint(
+ fields=("document", "field"),
+ name="documents_customfieldinstance_unique_document_field",
+ ),
+ ),
+ ]
return f"{self.name}"
+class CustomField(models.Model):
+ """
+ Defines the name and type of a custom field
+ """
+
+ class FieldDataType(models.TextChoices):
+ STRING = ("string", _("String"))
+ URL = ("url", _("URL"))
+ DATE = ("date", _("Date"))
+ BOOL = ("boolean"), _("Boolean")
+ INT = ("integer", _("Integer"))
+ FLOAT = ("float", _("Float"))
+ MONETARY = ("monetary", _("Monetary"))
+
+ created = models.DateTimeField(
+ _("created"),
+ default=timezone.now,
+ db_index=True,
+ editable=False,
+ )
+
+ name = models.CharField(max_length=128)
+
+ data_type = models.CharField(
+ _("data type"),
+ max_length=50,
+ choices=FieldDataType.choices,
+ editable=False,
+ )
+
+ class Meta:
+ ordering = ("created",)
+ verbose_name = _("custom field")
+ verbose_name_plural = _("custom fields")
+ constraints = [
+ models.UniqueConstraint(
+ fields=["name"],
+ name="%(app_label)s_%(class)s_unique_name",
+ ),
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.name} : {self.data_type}"
+
+
+class CustomFieldInstance(models.Model):
+ """
+ A single instance of a field, attached to a CustomField for the name and type
+ and attached to a single Document to be metadata for it
+ """
+
+ created = models.DateTimeField(
+ _("created"),
+ default=timezone.now,
+ db_index=True,
+ editable=False,
+ )
+
+ document = models.ForeignKey(
+ Document,
+ blank=False,
+ null=False,
+ on_delete=models.CASCADE,
+ related_name="custom_fields",
+ editable=False,
+ )
+
+ field = models.ForeignKey(
+ CustomField,
+ blank=False,
+ null=False,
+ on_delete=models.CASCADE,
+ related_name="fields",
+ editable=False,
+ )
+
+ # Actual data storage
+ value_text = models.CharField(max_length=128, null=True)
+
+ value_bool = models.BooleanField(null=True)
+
+ value_url = models.URLField(null=True)
+
+ value_date = models.DateField(null=True)
+
+ value_int = models.IntegerField(null=True)
+
+ value_float = models.FloatField(null=True)
+
+ value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12)
+
+ class Meta:
+ ordering = ("created",)
+ verbose_name = _("custom field instance")
+ verbose_name_plural = _("custom field instances")
+ constraints = [
+ models.UniqueConstraint(
+ fields=["document", "field"],
+ name="%(app_label)s_%(class)s_unique_document_field",
+ ),
+ ]
+
+ def __str__(self) -> str:
+ return str(self.field.name) + f" : {self.value}"
+
+ @property
+ def value(self):
+ """
+ Based on the data type, access the actual value the instance stores
+ A little shorthand/quick way to get what is actually here
+ """
+ if self.field.data_type == CustomField.FieldDataType.STRING:
+ return self.value_text
+ elif self.field.data_type == CustomField.FieldDataType.URL:
+ return self.value_url
+ elif self.field.data_type == CustomField.FieldDataType.DATE:
+ return self.value_date
+ elif self.field.data_type == CustomField.FieldDataType.BOOL:
+ return self.value_bool
+ elif self.field.data_type == CustomField.FieldDataType.INT:
+ return self.value_int
+ elif self.field.data_type == CustomField.FieldDataType.FLOAT:
+ return self.value_float
+ elif self.field.data_type == CustomField.FieldDataType.MONETARY:
+ return self.value_monetary
+ raise NotImplementedError(self.field.data_type)
+
+
if settings.AUDIT_LOG_ENABLED:
auditlog.register(Document, m2m_fields={"tags"})
auditlog.register(Correspondent)
auditlog.register(Tag)
auditlog.register(DocumentType)
auditlog.register(Note)
+ auditlog.register(CustomField)
+ auditlog.register(CustomFieldInstance)
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
+from django.core.validators import URLValidator
from django.utils.crypto import get_random_string
from django.utils.text import slugify
from django.utils.translation import gettext as _
+from drf_writable_nested.serializers import NestedUpdateMixin
from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_users_with_perms
from rest_framework import fields
from documents.data_models import DocumentSource
from documents.models import ConsumptionTemplate
from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import MatchingModel
return StoragePath.objects.all()
-class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
+class CustomFieldSerializer(serializers.ModelSerializer):
+ data_type = serializers.ChoiceField(
+ choices=CustomField.FieldDataType,
+ read_only=False,
+ )
+
+ class Meta:
+ model = CustomField
+ fields = [
+ "id",
+ "name",
+ "data_type",
+ ]
+
+
+class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
+ """
+ Based on https://stackoverflow.com/a/62579804
+ """
+
+ def __init__(self, method_name=None, *args, **kwargs):
+ self.method_name = method_name
+ kwargs["source"] = "*"
+ super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
+
+ def to_internal_value(self, data):
+ return {self.field_name: data}
+
+
+class CustomFieldInstanceSerializer(serializers.ModelSerializer):
+ field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
+ value = ReadWriteSerializerMethodField()
+
+ def create(self, validated_data):
+ type_to_data_store_name_map = {
+ CustomField.FieldDataType.STRING: "value_text",
+ CustomField.FieldDataType.URL: "value_url",
+ CustomField.FieldDataType.DATE: "value_date",
+ CustomField.FieldDataType.BOOL: "value_bool",
+ CustomField.FieldDataType.INT: "value_int",
+ CustomField.FieldDataType.FLOAT: "value_float",
+ CustomField.FieldDataType.MONETARY: "value_monetary",
+ }
+ # An instance is attached to a document
+ document: Document = validated_data["document"]
+ # And to a CustomField
+ custom_field: CustomField = validated_data["field"]
+ # This key must exist, as it is validated
+ data_store_name = type_to_data_store_name_map[custom_field.data_type]
+
+ # Actually update or create the instance, providing the value
+ # to fill in the correct attribute based on the type
+ instance, _ = CustomFieldInstance.objects.update_or_create(
+ document=document,
+ field=custom_field,
+ defaults={data_store_name: validated_data["value"]},
+ )
+ return instance
+
+ def get_value(self, obj: CustomFieldInstance):
+ return obj.value
+
+ def validate(self, data):
+ """
+ For some reason, URLField validation is not run against the value
+ automatically. Force it to run against the value
+ """
+ data = super().validate(data)
+ field: CustomField = data["field"]
+ if field.data_type == CustomField.FieldDataType.URL:
+ URLValidator()(data["value"])
+ return data
+
+ class Meta:
+ model = CustomFieldInstance
+ fields = [
+ "value",
+ "field",
+ ]
+
+
+class DocumentSerializer(
+ OwnedObjectSerializer,
+ NestedUpdateMixin,
+ DynamicFieldsModelSerializer,
+):
correspondent = CorrespondentField(allow_null=True)
tags = TagsField(many=True)
document_type = DocumentTypeField(allow_null=True)
archived_file_name = SerializerMethodField()
created_date = serializers.DateField(required=False)
+ custom_fields = CustomFieldInstanceSerializer(many=True, allow_null=True)
+
owner = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
doc["content"] = doc.get("content")[0:550]
return doc
- def update(self, instance, validated_data):
+ def update(self, instance: Document, validated_data):
if "created_date" in validated_data and "created" not in validated_data:
new_datetime = datetime.datetime.combine(
validated_data.get("created_date"),
"user_can_change",
"set_permissions",
"notes",
+ "custom_fields",
)
from documents.data_models import DocumentSource
from documents.models import ConsumptionTemplate
from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import MatchingModel
tag_2 = Tag.objects.create(name="t2")
tag_3 = Tag.objects.create(name="t3")
+ cf1 = CustomField.objects.create(
+ name="stringfield",
+ data_type=CustomField.FieldDataType.STRING,
+ )
+ cf2 = CustomField.objects.create(
+ name="numberfield",
+ data_type=CustomField.FieldDataType.INT,
+ )
+
doc1.tags.add(tag_inbox)
doc2.tags.add(tag_2)
doc3.tags.add(tag_2)
doc3.tags.add(tag_3)
+ cf1_d1 = CustomFieldInstance.objects.create(
+ document=doc1,
+ field=cf1,
+ value_text="foobard1",
+ )
+ CustomFieldInstance.objects.create(
+ document=doc1,
+ field=cf2,
+ value_int=999,
+ )
+ cf1_d3 = CustomFieldInstance.objects.create(
+ document=doc3,
+ field=cf1,
+ value_text="foobard3",
+ )
+
response = self.client.get("/api/documents/?is_in_inbox=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"]
results = response.data["results"]
self.assertEqual(len(results), 0)
+ # custom field name
+ response = self.client.get(
+ f"/api/documents/?custom_fields__icontains={cf1.name}",
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ results = response.data["results"]
+ self.assertEqual(len(results), 2)
+
+ # custom field value
+ response = self.client.get(
+ f"/api/documents/?custom_fields__icontains={cf1_d1.value}",
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ results = response.data["results"]
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0]["id"], doc1.id)
+
+ response = self.client.get(
+ f"/api/documents/?custom_fields__icontains={cf1_d3.value}",
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ results = response.data["results"]
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0]["id"], doc3.id)
+
def test_document_checksum_filter(self):
Document.objects.create(
title="none1",
dt2 = DocumentType.objects.create(name="type2")
sp = StoragePath.objects.create(name="path")
sp2 = StoragePath.objects.create(name="path2")
+ cf1 = CustomField.objects.create(
+ name="string field",
+ data_type=CustomField.FieldDataType.STRING,
+ )
+ cf2 = CustomField.objects.create(
+ name="number field",
+ data_type=CustomField.FieldDataType.INT,
+ )
d1 = Document.objects.create(checksum="1", correspondent=c, content="test")
d2 = Document.objects.create(checksum="2", document_type=dt, content="test")
content="test",
)
+ cf1_d1 = CustomFieldInstance.objects.create(
+ document=d1,
+ field=cf1,
+ value_text="foobard1",
+ )
+ cf2_d1 = CustomFieldInstance.objects.create(
+ document=d1,
+ field=cf2,
+ value_int=999,
+ )
+ cf1_d4 = CustomFieldInstance.objects.create(
+ document=d4,
+ field=cf1,
+ value_text="foobard4",
+ )
+
with AsyncWriter(index.open_index()) as writer:
for doc in Document.objects.all():
index.update_document(writer, doc)
+ datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"),
),
)
+
self.assertIn(
d5.id,
search_query(
[d4.id, d5.id],
)
+ self.assertIn(
+ d1.id,
+ search_query(
+ "&custom_fields__icontains=" + cf1_d1.value,
+ ),
+ )
+
+ self.assertIn(
+ d1.id,
+ search_query(
+ "&custom_fields__icontains=" + str(cf2_d1.value),
+ ),
+ )
+
+ self.assertIn(
+ d4.id,
+ search_query(
+ "&custom_fields__icontains=" + cf1_d4.value,
+ ),
+ )
+
def test_search_filtering_respect_owner(self):
"""
GIVEN:
f"/api/documents/{doc.pk}/notes/",
format="json",
)
- self.assertEqual(resp.content, b"Insufficient permissions to view")
+ self.assertEqual(resp.content, b"Insufficient permissions to view notes")
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
assign_perm("view_document", user1, doc)
f"/api/documents/{doc.pk}/notes/",
data={"note": "this is a posted note"},
)
- self.assertEqual(resp.content, b"Insufficient permissions to create")
+ self.assertEqual(resp.content, b"Insufficient permissions to create notes")
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
note = Note.objects.create(
format="json",
)
- self.assertEqual(response.content, b"Insufficient permissions to delete")
+ self.assertEqual(response.content, b"Insufficient permissions to delete notes")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_delete_note(self):
f"/api/documents/{doc.pk}/share_links/",
format="json",
)
- self.assertEqual(resp.content, b"Insufficient permissions")
+ self.assertEqual(resp.content, b"Insufficient permissions to add share link")
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
assign_perm("change_document", user1, doc)
--- /dev/null
+from datetime import date
+
+from django.contrib.auth.models import User
+from rest_framework import status
+from rest_framework.test import APITestCase
+
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
+from documents.models import Document
+from documents.tests.utils import DirectoriesMixin
+
+
+class TestCustomField(DirectoriesMixin, APITestCase):
+ ENDPOINT = "/api/custom_fields/"
+
+ def setUp(self):
+ self.user = User.objects.create_superuser(username="temp_admin")
+ self.client.force_authenticate(user=self.user)
+ return super().setUp()
+
+ def test_create_custom_field(self):
+ """
+ GIVEN:
+ - Each of the supported data types is created
+ WHEN:
+ - API request to create custom metadata is made
+ THEN:
+ - the field is created
+ - the field returns the correct fields
+ """
+ for field_type, name in [
+ ("string", "Custom Text"),
+ ("url", "Wikipedia Link"),
+ ("date", "Invoiced Date"),
+ ("integer", "Invoice #"),
+ ("boolean", "Is Active"),
+ ("float", "Total Paid"),
+ ]:
+ resp = self.client.post(
+ self.ENDPOINT,
+ data={
+ "data_type": field_type,
+ "name": name,
+ },
+ )
+ self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
+
+ data = resp.json()
+
+ self.assertEqual(len(data), 3)
+ self.assertEqual(data["name"], name)
+ self.assertEqual(data["data_type"], field_type)
+
+ def test_create_custom_field_instance(self):
+ """
+ GIVEN:
+ - Field of each data type is created
+ WHEN:
+ - API request to create custom metadata instance with each data type
+ THEN:
+ - the field instance is created
+ - the field returns the correct fields and values
+ - the field is attached to the given document
+ """
+ doc = Document.objects.create(
+ title="WOW",
+ content="the content",
+ checksum="123",
+ mime_type="application/pdf",
+ )
+ custom_field_string = CustomField.objects.create(
+ name="Test Custom Field String",
+ data_type=CustomField.FieldDataType.STRING,
+ )
+ custom_field_date = CustomField.objects.create(
+ name="Test Custom Field Date",
+ data_type=CustomField.FieldDataType.DATE,
+ )
+ custom_field_int = CustomField.objects.create(
+ name="Test Custom Field Int",
+ data_type=CustomField.FieldDataType.INT,
+ )
+ custom_field_boolean = CustomField.objects.create(
+ name="Test Custom Field Boolean",
+ data_type=CustomField.FieldDataType.BOOL,
+ )
+ custom_field_url = CustomField.objects.create(
+ name="Test Custom Field Url",
+ data_type=CustomField.FieldDataType.URL,
+ )
+ custom_field_float = CustomField.objects.create(
+ name="Test Custom Field Float",
+ data_type=CustomField.FieldDataType.FLOAT,
+ )
+ custom_field_monetary = CustomField.objects.create(
+ name="Test Custom Field Monetary",
+ data_type=CustomField.FieldDataType.MONETARY,
+ )
+
+ date_value = date.today()
+
+ resp = self.client.patch(
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_string.id,
+ "value": "test value",
+ },
+ {
+ "field": custom_field_date.id,
+ "value": date_value.isoformat(),
+ },
+ {
+ "field": custom_field_int.id,
+ "value": 3,
+ },
+ {
+ "field": custom_field_boolean.id,
+ "value": True,
+ },
+ {
+ "field": custom_field_url.id,
+ "value": "https://example.com",
+ },
+ {
+ "field": custom_field_float.id,
+ "value": 12.3456,
+ },
+ {
+ "field": custom_field_monetary.id,
+ "value": 11.10,
+ },
+ ],
+ },
+ format="json",
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+
+ resp_data = resp.json()["custom_fields"]
+
+ self.assertCountEqual(
+ resp_data,
+ [
+ {"field": custom_field_string.id, "value": "test value"},
+ {"field": custom_field_date.id, "value": date_value.isoformat()},
+ {"field": custom_field_int.id, "value": 3},
+ {"field": custom_field_boolean.id, "value": True},
+ {"field": custom_field_url.id, "value": "https://example.com"},
+ {"field": custom_field_float.id, "value": 12.3456},
+ {"field": custom_field_monetary.id, "value": 11.10},
+ ],
+ )
+
+ doc.refresh_from_db()
+ self.assertEqual(len(doc.custom_fields.all()), 7)
+
+ def test_change_custom_field_instance_value(self):
+ """
+ GIVEN:
+ - Custom field instance is created and attached to document
+ WHEN:
+ - API request to create change the value of the custom field
+ THEN:
+ - the field instance is updated
+ - the field returns the correct fields and values
+ """
+ doc = Document.objects.create(
+ title="WOW",
+ content="the content",
+ checksum="123",
+ mime_type="application/pdf",
+ )
+ custom_field_string = CustomField.objects.create(
+ name="Test Custom Field String",
+ data_type=CustomField.FieldDataType.STRING,
+ )
+
+ self.assertEqual(CustomFieldInstance.objects.count(), 0)
+
+ # Create
+ resp = self.client.patch(
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_string.id,
+ "value": "test value",
+ },
+ ],
+ },
+ format="json",
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(CustomFieldInstance.objects.count(), 1)
+ self.assertEqual(doc.custom_fields.first().value, "test value")
+
+ # Update
+ resp = self.client.patch(
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_string.id,
+ "value": "a new test value",
+ },
+ ],
+ },
+ format="json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(CustomFieldInstance.objects.count(), 1)
+ self.assertEqual(doc.custom_fields.first().value, "a new test value")
+
+ def test_delete_custom_field_instance(self):
+ """
+ GIVEN:
+ - Multiple custom field instances are created and attached to document
+ WHEN:
+ - API request to remove a field
+ THEN:
+ - the field instance is removed
+ - the other field remains unchanged
+ - the field returns the correct fields and values
+ """
+ doc = Document.objects.create(
+ title="WOW",
+ content="the content",
+ checksum="123",
+ mime_type="application/pdf",
+ )
+ custom_field_string = CustomField.objects.create(
+ name="Test Custom Field String",
+ data_type=CustomField.FieldDataType.STRING,
+ )
+ custom_field_date = CustomField.objects.create(
+ name="Test Custom Field Date",
+ data_type=CustomField.FieldDataType.DATE,
+ )
+
+ date_value = date.today()
+
+ resp = self.client.patch(
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_string.id,
+ "value": "a new test value",
+ },
+ {
+ "field": custom_field_date.id,
+ "value": date_value.isoformat(),
+ },
+ ],
+ },
+ format="json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(CustomFieldInstance.objects.count(), 2)
+ self.assertEqual(len(doc.custom_fields.all()), 2)
+
+ resp = self.client.patch(
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_date.id,
+ "value": date_value.isoformat(),
+ },
+ ],
+ },
+ format="json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(CustomFieldInstance.objects.count(), 1)
+ self.assertEqual(Document.objects.count(), 1)
+ self.assertEqual(len(doc.custom_fields.all()), 1)
+ self.assertEqual(doc.custom_fields.first().value, date_value)
+
+ def test_custom_field_validation(self):
+ """
+ GIVEN:
+ - Document exists with no fields
+ WHEN:
+ - API request to remove a field
+ - API request is not valid
+ THEN:
+ - HTTP 400 is returned
+ - No field created
+ - No field attached to the document
+ """
+ doc = Document.objects.create(
+ title="WOW",
+ content="the content",
+ checksum="123",
+ mime_type="application/pdf",
+ )
+ custom_field_string = CustomField.objects.create(
+ name="Test Custom Field String",
+ data_type=CustomField.FieldDataType.STRING,
+ )
+
+ resp = self.client.patch(
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_string.id,
+ # Whoops, spelling
+ "valeu": "a new test value",
+ },
+ ],
+ },
+ format="json",
+ )
+ from pprint import pprint
+
+ pprint(resp.json())
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(CustomFieldInstance.objects.count(), 0)
+ self.assertEqual(len(doc.custom_fields.all()), 0)
+
+ def test_custom_field_value_validation(self):
+ """
+ GIVEN:
+ - Document & custom field exist
+ WHEN:
+ - API request to set a field value
+ THEN:
+ - HTTP 400 is returned
+ - No field instance is created or attached to the document
+ """
+ doc = Document.objects.create(
+ title="WOW",
+ content="the content",
+ checksum="123",
+ mime_type="application/pdf",
+ )
+ custom_field_url = CustomField.objects.create(
+ name="Test Custom Field URL",
+ data_type=CustomField.FieldDataType.URL,
+ )
+ custom_field_int = CustomField.objects.create(
+ name="Test Custom Field INT",
+ data_type=CustomField.FieldDataType.INT,
+ )
+
+ resp = self.client.patch(
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_url.id,
+ "value": "not a url",
+ },
+ ],
+ },
+ format="json",
+ )
+
+ self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(CustomFieldInstance.objects.count(), 0)
+ self.assertEqual(len(doc.custom_fields.all()), 0)
+
+ self.assertRaises(
+ Exception,
+ self.client.patch,
+ f"/api/documents/{doc.id}/",
+ data={
+ "custom_fields": [
+ {
+ "field": custom_field_int.id,
+ "value": "not an int",
+ },
+ ],
+ },
+ format="json",
+ )
+
+ self.assertEqual(CustomFieldInstance.objects.count(), 0)
+ self.assertEqual(len(doc.custom_fields.all()), 0)
manifest = self._do_export(use_filename_format=use_filename_format)
- self.assertEqual(len(manifest), 159)
+ self.assertEqual(len(manifest), 169)
# dont include consumer or AnonymousUser users
self.assertEqual(
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
self.assertEqual(GroupObjectPermission.objects.count(), 1)
self.assertEqual(UserObjectPermission.objects.count(), 1)
- self.assertEqual(Permission.objects.count(), 116)
+ self.assertEqual(Permission.objects.count(), 124)
messages = check_sanity()
# everything is alright after the test
self.assertEqual(len(messages), 0)
os.path.join(self.dirs.media_dir, "documents"),
)
- self.assertEqual(ContentType.objects.count(), 29)
- self.assertEqual(Permission.objects.count(), 116)
+ self.assertEqual(ContentType.objects.count(), 31)
+ self.assertEqual(Permission.objects.count(), 124)
manifest = self._do_export()
with paperless_environment():
self.assertEqual(
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
- 116,
+ 124,
)
# add 1 more to db to show objects are not re-created by import
Permission.objects.create(
codename="test_perm",
content_type_id=1,
)
- self.assertEqual(Permission.objects.count(), 117)
+ self.assertEqual(Permission.objects.count(), 125)
# will cause an import error
self.user.delete()
with self.assertRaises(IntegrityError):
call_command("document_importer", "--no-progress-bar", self.target)
- self.assertEqual(ContentType.objects.count(), 29)
- self.assertEqual(Permission.objects.count(), 117)
+ self.assertEqual(ContentType.objects.count(), 31)
+ self.assertEqual(Permission.objects.count(), 125)
from documents.matching import match_tags
from documents.models import ConsumptionTemplate
from documents.models import Correspondent
+from documents.models import CustomField
from documents.models import Document
from documents.models import DocumentType
from documents.models import Note
from documents.serialisers import BulkEditSerializer
from documents.serialisers import ConsumptionTemplateSerializer
from documents.serialisers import CorrespondentSerializer
+from documents.serialisers import CustomFieldSerializer
from documents.serialisers import DocumentListSerializer
from documents.serialisers import DocumentSerializer
from documents.serialisers import DocumentTypeSerializer
"view_document",
doc,
):
- return HttpResponseForbidden("Insufficient permissions to view")
+ return HttpResponseForbidden("Insufficient permissions to view notes")
except Document.DoesNotExist:
raise Http404
except Exception as e:
logger.warning(f"An error occurred retrieving notes: {e!s}")
return Response(
- {"error": "Error retreiving notes, check logs for more detail."},
+ {"error": "Error retrieving notes, check logs for more detail."},
)
elif request.method == "POST":
try:
"change_document",
doc,
):
- return HttpResponseForbidden("Insufficient permissions to create")
+ return HttpResponseForbidden(
+ "Insufficient permissions to create notes",
+ )
c = Note.objects.create(
document=doc,
"change_document",
doc,
):
- return HttpResponseForbidden("Insufficient permissions to delete")
+ return HttpResponseForbidden("Insufficient permissions to delete notes")
note = Note.objects.get(id=int(request.GET.get("id")))
if settings.AUDIT_LOG_ENABLED:
"change_document",
doc,
):
- return HttpResponseForbidden("Insufficient permissions")
+ return HttpResponseForbidden(
+ "Insufficient permissions to add share link",
+ )
except Document.DoesNotExist:
raise Http404
return response
-class RemoteVersionView(GenericAPIView):
- def get(self, request, format=None):
- remote_version = "0.0.0"
- is_greater_than_current = False
- current_version = packaging_version.parse(version.__full_version_str__)
- try:
- req = urllib.request.Request(
- "https://api.github.com/repos/paperless-ngx/"
- "paperless-ngx/releases/latest",
- )
- # Ensure a JSON response
- req.add_header("Accept", "application/json")
-
- with urllib.request.urlopen(req) as response:
- remote = response.read().decode("utf-8")
- try:
- remote_json = json.loads(remote)
- remote_version = remote_json["tag_name"]
- # Basically PEP 616 but that only went in 3.9
- if remote_version.startswith("ngx-"):
- remote_version = remote_version[len("ngx-") :]
- except ValueError:
- logger.debug("An error occurred parsing remote version json")
- except urllib.error.URLError:
- logger.debug("An error occurred checking for available updates")
-
- is_greater_than_current = (
- packaging_version.parse(
- remote_version,
- )
- > current_version
- )
-
- return Response(
- {
- "version": remote_version,
- "update_available": is_greater_than_current,
- },
- )
-
-
class StoragePathViewSet(ModelViewSet, PassUserMixin):
model = StoragePath
)
+class RemoteVersionView(GenericAPIView):
+ def get(self, request, format=None):
+ remote_version = "0.0.0"
+ is_greater_than_current = False
+ current_version = packaging_version.parse(version.__full_version_str__)
+ try:
+ req = urllib.request.Request(
+ "https://api.github.com/repos/paperlessngx/"
+ "paperlessngx/releases/latest",
+ )
+ # Ensure a JSON response
+ req.add_header("Accept", "application/json")
+
+ with urllib.request.urlopen(req) as response:
+ remote = response.read().decode("utf8")
+ try:
+ remote_json = json.loads(remote)
+ remote_version = remote_json["tag_name"]
+ # Basically PEP 616 but that only went in 3.9
+ if remote_version.startswith("ngx-"):
+ remote_version = remote_version[len("ngx-") :]
+ except ValueError:
+ logger.debug("An error occurred parsing remote version json")
+ except urllib.error.URLError:
+ logger.debug("An error occurred checking for available updates")
+
+ is_greater_than_current = (
+ packaging_version.parse(
+ remote_version,
+ )
+ > current_version
+ )
+
+ return Response(
+ {
+ "version": remote_version,
+ "update_available": is_greater_than_current,
+ },
+ )
+
+
class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated,)
serializer_class = TasksViewSerializer
model = ConsumptionTemplate
- queryset = ConsumptionTemplate.objects.all().order_by("order")
+ queryset = ConsumptionTemplate.objects.all().order_by("name")
+
+
+class CustomFieldViewSet(ModelViewSet):
+ permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
+
+ serializer_class = CustomFieldSerializer
+ pagination_class = StandardPagination
+
+ model = CustomField
+
+ queryset = CustomField.objects.all().order_by("-created")
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-10-31 15:23-0700\n"
+"POT-Creation-Date: 2023-11-04 20:12-0700\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
msgstr ""
#: documents/models.py:205 documents/models.py:385 documents/models.py:654
-#: documents/models.py:692
+#: documents/models.py:692 documents/models.py:895 documents/models.py:932
msgid "created"
msgstr ""
msgid "consumption templates"
msgstr ""
-#: documents/serialisers.py:98
+#: documents/models.py:886
+msgid "String"
+msgstr ""
+
+#: documents/models.py:887
+msgid "URL"
+msgstr ""
+
+#: documents/models.py:888
+msgid "Date"
+msgstr ""
+
+#: documents/models.py:889
+msgid "Boolean"
+msgstr ""
+
+#: documents/models.py:890
+msgid "Integer"
+msgstr ""
+
+#: documents/models.py:891
+msgid "Float"
+msgstr ""
+
+#: documents/models.py:892
+msgid "Monetary"
+msgstr ""
+
+#: documents/models.py:904
+msgid "data type"
+msgstr ""
+
+#: documents/models.py:912
+msgid "custom field"
+msgstr ""
+
+#: documents/models.py:913
+msgid "custom fields"
+msgstr ""
+
+#: documents/models.py:973
+msgid "custom field instance"
+msgstr ""
+
+#: documents/models.py:974
+msgid "custom field instances"
+msgstr ""
+
+#: documents/serialisers.py:102
#, python-format
msgid "Invalid regular expression: %(error)s"
msgstr ""
-#: documents/serialisers.py:373
+#: documents/serialisers.py:377
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:749
+#: documents/serialisers.py:841
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:846
+#: documents/serialisers.py:938
msgid "Invalid variable detected."
msgstr ""
msgid "Chinese Simplified"
msgstr ""
-#: paperless/urls.py:184
+#: paperless/urls.py:186
msgid "Paperless-ngx administration"
msgstr ""
from documents.views import BulkEditView
from documents.views import ConsumptionTemplateViewSet
from documents.views import CorrespondentViewSet
+from documents.views import CustomFieldViewSet
from documents.views import DocumentTypeViewSet
from documents.views import IndexView
from documents.views import LogViewSet
api_router.register(r"mail_rules", MailRuleViewSet)
api_router.register(r"share_links", ShareLinkViewSet)
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
+api_router.register(r"custom_fields", CustomFieldViewSet)
urlpatterns = [