mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 19:17:13 -05:00 
			
		
		
		
	Merge branch 'dev'
This commit is contained in:
		
						commit
						ec49284274
					
				
							
								
								
									
										87
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										87
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							@ -28,7 +28,7 @@ jobs:
 | 
			
		||||
          stale-issue-message: >
 | 
			
		||||
            This issue has been automatically marked as stale because it has not had
 | 
			
		||||
            recent activity. It will be closed if no further activity occurs. Thank you
 | 
			
		||||
            for your contributions.
 | 
			
		||||
            for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
 | 
			
		||||
  lock-threads:
 | 
			
		||||
    name: 'Lock Old Threads'
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
@ -43,14 +43,17 @@ jobs:
 | 
			
		||||
            This issue has been automatically locked since there
 | 
			
		||||
            has not been any recent activity after it was closed.
 | 
			
		||||
            Please open a new discussion or issue for related concerns.
 | 
			
		||||
            See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
 | 
			
		||||
          pr-comment: >
 | 
			
		||||
            This pull request has been automatically locked since there
 | 
			
		||||
            has not been any recent activity after it was closed.
 | 
			
		||||
            Please open a new discussion or issue for related concerns.
 | 
			
		||||
            See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
 | 
			
		||||
          discussion-comment: >
 | 
			
		||||
            This discussion has been automatically locked since there
 | 
			
		||||
            has not been any recent activity after it was closed.
 | 
			
		||||
            Please open a new discussion for related concerns.
 | 
			
		||||
            See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.
 | 
			
		||||
  close-answered-discussions:
 | 
			
		||||
    name: 'Close Answered Discussions'
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
@ -90,7 +93,7 @@ jobs:
 | 
			
		||||
              }`;
 | 
			
		||||
              const commentVariables = {
 | 
			
		||||
                discussion: discussion.id,
 | 
			
		||||
                body: 'This discussion has been automatically closed because it was marked as answered.',
 | 
			
		||||
                body: 'This discussion has been automatically closed because it was marked as answered. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.',
 | 
			
		||||
              }
 | 
			
		||||
              await github.graphql(addCommentMutation, commentVariables)
 | 
			
		||||
 | 
			
		||||
@ -180,7 +183,85 @@ jobs:
 | 
			
		||||
                }`;
 | 
			
		||||
                const commentVariables = {
 | 
			
		||||
                  discussion: discussion.id,
 | 
			
		||||
                  body: 'This discussion has been automatically closed due to inactivity.',
 | 
			
		||||
                  body: 'This discussion has been automatically closed due to inactivity. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.',
 | 
			
		||||
                }
 | 
			
		||||
                await github.graphql(addCommentMutation, commentVariables);
 | 
			
		||||
 | 
			
		||||
                const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
 | 
			
		||||
                  closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
 | 
			
		||||
                    clientMutationId
 | 
			
		||||
                  }
 | 
			
		||||
                }`;
 | 
			
		||||
                const closeVariables = {
 | 
			
		||||
                  discussion: discussion.id,
 | 
			
		||||
                  reason: "OUTDATED",
 | 
			
		||||
                }
 | 
			
		||||
                await github.graphql(closeDiscussionMutation, closeVariables);
 | 
			
		||||
 | 
			
		||||
                await sleep(1000);
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
  close-unsupported-feature-requests:
 | 
			
		||||
    name: 'Close Unsupported Feature Requests'
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/github-script@v7
 | 
			
		||||
        with:
 | 
			
		||||
          script: |
 | 
			
		||||
            function sleep(ms) {
 | 
			
		||||
              return new Promise(resolve => setTimeout(resolve, ms));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const CUTOFF_1_DAYS = 180;
 | 
			
		||||
            const CUTOFF_1_COUNT = 5;
 | 
			
		||||
            const CUTOFF_2_DAYS = 365;
 | 
			
		||||
            const CUTOFF_2_COUNT = 10;
 | 
			
		||||
 | 
			
		||||
            const cutoff1Date = new Date();
 | 
			
		||||
            cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
 | 
			
		||||
            const cutoff2Date = new Date();
 | 
			
		||||
            cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
 | 
			
		||||
 | 
			
		||||
            const query = `query(
 | 
			
		||||
                $owner:String!,
 | 
			
		||||
                $name:String!,
 | 
			
		||||
                $featureRequestsCategory:ID!,
 | 
			
		||||
              ) {
 | 
			
		||||
              repository(owner:$owner, name:$name){
 | 
			
		||||
                discussions(
 | 
			
		||||
                  categoryId:$featureRequestsCategory,
 | 
			
		||||
                  last:100,
 | 
			
		||||
                  states:[OPEN],
 | 
			
		||||
                ) {
 | 
			
		||||
                  nodes {
 | 
			
		||||
                    id,
 | 
			
		||||
                    number,
 | 
			
		||||
                    updatedAt,
 | 
			
		||||
                    upvoteCount,
 | 
			
		||||
                  }
 | 
			
		||||
                },
 | 
			
		||||
              }
 | 
			
		||||
            }`;
 | 
			
		||||
            const variables = {
 | 
			
		||||
              owner: context.repo.owner,
 | 
			
		||||
              name: context.repo.repo,
 | 
			
		||||
              featureRequestsCategory: "DIC_kwDOG1Zs184CBNr4"
 | 
			
		||||
            }
 | 
			
		||||
            const result = await github.graphql(query, variables);
 | 
			
		||||
 | 
			
		||||
            for (const discussion of result.repository.discussions.nodes) {
 | 
			
		||||
              const discussionDate = new Date(discussion.updatedAt);
 | 
			
		||||
              if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
 | 
			
		||||
                  (discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
 | 
			
		||||
                console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
 | 
			
		||||
                const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
 | 
			
		||||
                  addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
 | 
			
		||||
                    clientMutationId
 | 
			
		||||
                  }
 | 
			
		||||
                }`;
 | 
			
		||||
                const commentVariables = {
 | 
			
		||||
                  discussion: discussion.id,
 | 
			
		||||
                  body: 'This discussion has been automatically closed due to lack of community support. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-respoistory-maintenance) for more details.',
 | 
			
		||||
                }
 | 
			
		||||
                await github.graphql(addCommentMutation, commentVariables);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -137,3 +137,19 @@ All team members are notified when mentioned or assigned to a relevant issue or
 | 
			
		||||
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
 | 
			
		||||
 | 
			
		||||
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
 | 
			
		||||
 | 
			
		||||
# Automatic Respoistory Maintenance
 | 
			
		||||
 | 
			
		||||
The Paperless-ngx team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other
 | 
			
		||||
community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas:
 | 
			
		||||
 | 
			
		||||
- Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further days of inactivity.
 | 
			
		||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
 | 
			
		||||
- Discussions with a marked answer will be automatically closed.
 | 
			
		||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
 | 
			
		||||
- Feature requests that do not meet the following thresholds will be closed: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days.
 | 
			
		||||
 | 
			
		||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
 | 
			
		||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
 | 
			
		||||
 | 
			
		||||
Thank you all for your contributions.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										67
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										67
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							@ -392,41 +392,42 @@
 | 
			
		||||
        },
 | 
			
		||||
        "cryptography": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380",
 | 
			
		||||
                "sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589",
 | 
			
		||||
                "sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea",
 | 
			
		||||
                "sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65",
 | 
			
		||||
                "sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a",
 | 
			
		||||
                "sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3",
 | 
			
		||||
                "sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008",
 | 
			
		||||
                "sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1",
 | 
			
		||||
                "sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2",
 | 
			
		||||
                "sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635",
 | 
			
		||||
                "sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2",
 | 
			
		||||
                "sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90",
 | 
			
		||||
                "sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee",
 | 
			
		||||
                "sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a",
 | 
			
		||||
                "sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242",
 | 
			
		||||
                "sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12",
 | 
			
		||||
                "sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2",
 | 
			
		||||
                "sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d",
 | 
			
		||||
                "sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be",
 | 
			
		||||
                "sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee",
 | 
			
		||||
                "sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6",
 | 
			
		||||
                "sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529",
 | 
			
		||||
                "sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929",
 | 
			
		||||
                "sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1",
 | 
			
		||||
                "sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6",
 | 
			
		||||
                "sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a",
 | 
			
		||||
                "sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446",
 | 
			
		||||
                "sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9",
 | 
			
		||||
                "sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888",
 | 
			
		||||
                "sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4",
 | 
			
		||||
                "sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33",
 | 
			
		||||
                "sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f"
 | 
			
		||||
                "sha256:01911714117642a3f1792c7f376db572aadadbafcd8d75bb527166009c9f1d1b",
 | 
			
		||||
                "sha256:0e89f7b84f421c56e7ff69f11c441ebda73b8a8e6488d322ef71746224c20fce",
 | 
			
		||||
                "sha256:12d341bd42cdb7d4937b0cabbdf2a94f949413ac4504904d0cdbdce4a22cbf88",
 | 
			
		||||
                "sha256:15a1fb843c48b4a604663fa30af60818cd28f895572386e5f9b8a665874c26e7",
 | 
			
		||||
                "sha256:1cdcdbd117681c88d717437ada72bdd5be9de117f96e3f4d50dab3f59fd9ab20",
 | 
			
		||||
                "sha256:1df6fcbf60560d2113b5ed90f072dc0b108d64750d4cbd46a21ec882c7aefce9",
 | 
			
		||||
                "sha256:3c6048f217533d89f2f8f4f0fe3044bf0b2090453b7b73d0b77db47b80af8dff",
 | 
			
		||||
                "sha256:3e970a2119507d0b104f0a8e281521ad28fc26f2820687b3436b8c9a5fcf20d1",
 | 
			
		||||
                "sha256:44a64043f743485925d3bcac548d05df0f9bb445c5fcca6681889c7c3ab12764",
 | 
			
		||||
                "sha256:4e36685cb634af55e0677d435d425043967ac2f3790ec652b2b88ad03b85c27b",
 | 
			
		||||
                "sha256:5f8907fcf57392cd917892ae83708761c6ff3c37a8e835d7246ff0ad251d9298",
 | 
			
		||||
                "sha256:69b22ab6506a3fe483d67d1ed878e1602bdd5912a134e6202c1ec672233241c1",
 | 
			
		||||
                "sha256:6bfadd884e7280df24d26f2186e4e07556a05d37393b0f220a840b083dc6a824",
 | 
			
		||||
                "sha256:6d0fbe73728c44ca3a241eff9aefe6496ab2656d6e7a4ea2459865f2e8613257",
 | 
			
		||||
                "sha256:6ffb03d419edcab93b4b19c22ee80c007fb2d708429cecebf1dd3258956a563a",
 | 
			
		||||
                "sha256:810bcf151caefc03e51a3d61e53335cd5c7316c0a105cc695f0959f2c638b129",
 | 
			
		||||
                "sha256:831a4b37accef30cccd34fcb916a5d7b5be3cbbe27268a02832c3e450aea39cb",
 | 
			
		||||
                "sha256:887623fe0d70f48ab3f5e4dbf234986b1329a64c066d719432d0698522749929",
 | 
			
		||||
                "sha256:a0298bdc6e98ca21382afe914c642620370ce0470a01e1bef6dd9b5354c36854",
 | 
			
		||||
                "sha256:a1327f280c824ff7885bdeef8578f74690e9079267c1c8bd7dc5cc5aa065ae52",
 | 
			
		||||
                "sha256:c1f25b252d2c87088abc8bbc4f1ecbf7c919e05508a7e8628e6875c40bc70923",
 | 
			
		||||
                "sha256:c3a5cbc620e1e17009f30dd34cb0d85c987afd21c41a74352d1719be33380885",
 | 
			
		||||
                "sha256:ce8613beaffc7c14f091497346ef117c1798c202b01153a8cc7b8e2ebaaf41c0",
 | 
			
		||||
                "sha256:d2a27aca5597c8a71abbe10209184e1a8e91c1fd470b5070a2ea60cafec35bcd",
 | 
			
		||||
                "sha256:dad9c385ba8ee025bb0d856714f71d7840020fe176ae0229de618f14dae7a6e2",
 | 
			
		||||
                "sha256:db4b65b02f59035037fde0998974d84244a64c3265bdef32a827ab9b63d61b18",
 | 
			
		||||
                "sha256:e09469a2cec88fb7b078e16d4adec594414397e8879a4341c6ace96013463d5b",
 | 
			
		||||
                "sha256:e53dc41cda40b248ebc40b83b31516487f7db95ab8ceac1f042626bc43a2f992",
 | 
			
		||||
                "sha256:f1e85a178384bf19e36779d91ff35c7617c885da487d689b05c1366f9933ad74",
 | 
			
		||||
                "sha256:f47be41843200f7faec0683ad751e5ef11b9a56a220d57f300376cd8aba81660",
 | 
			
		||||
                "sha256:fb0cef872d8193e487fc6bdb08559c3aa41b659a7d9be48b2e10747f47863925",
 | 
			
		||||
                "sha256:ffc73996c4fca3d2b6c1c8c12bfd3ad00def8621da24f547626bf06441400449"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "markers": "python_version >= '3.7'",
 | 
			
		||||
            "version": "==42.0.2"
 | 
			
		||||
            "version": "==42.0.4"
 | 
			
		||||
        },
 | 
			
		||||
        "dateparser": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9
									
								
								SECURITY.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								SECURITY.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
			
		||||
# Security Policy
 | 
			
		||||
 | 
			
		||||
## Reporting a Vulnerability
 | 
			
		||||
 | 
			
		||||
The Paperless-ngx team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
 | 
			
		||||
 | 
			
		||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/paperless-ngx/paperless-ngx/security/advisories/new) tab.
 | 
			
		||||
 | 
			
		||||
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
 | 
			
		||||
@ -1491,6 +1491,10 @@
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">96</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">208</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">38</context>
 | 
			
		||||
@ -2017,7 +2021,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">304</context>
 | 
			
		||||
          <context context-type="linenumber">320</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.ts</context>
 | 
			
		||||
@ -2056,7 +2060,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">306</context>
 | 
			
		||||
          <context context-type="linenumber">322</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.ts</context>
 | 
			
		||||
@ -5025,7 +5029,11 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">302</context>
 | 
			
		||||
          <context context-type="linenumber">204</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">318</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5382975254277698192" datatype="html">
 | 
			
		||||
@ -6219,7 +6227,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">289</context>
 | 
			
		||||
          <context context-type="linenumber">305</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4010735610815226758" datatype="html">
 | 
			
		||||
@ -6302,26 +6310,26 @@
 | 
			
		||||
        <source>{VAR_PLURAL, plural, =1 {One <x id="INTERPOLATION"/>} other {<x id="INTERPOLATION_1"/> total <x id="INTERPOLATION_2"/>}}</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">116</context>
 | 
			
		||||
          <context context-type="linenumber">110</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">116</context>
 | 
			
		||||
          <context context-type="linenumber">110</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">116</context>
 | 
			
		||||
          <context context-type="linenumber">110</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">116</context>
 | 
			
		||||
          <context context-type="linenumber">110</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="810888510148304696" datatype="html">
 | 
			
		||||
        <source>Automatic</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">113</context>
 | 
			
		||||
          <context context-type="linenumber">116</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/data/matching-model.ts</context>
 | 
			
		||||
@ -6332,7 +6340,7 @@
 | 
			
		||||
        <source>None</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">115</context>
 | 
			
		||||
          <context context-type="linenumber">118</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/data/matching-model.ts</context>
 | 
			
		||||
@ -6343,63 +6351,70 @@
 | 
			
		||||
        <source>Successfully created <x id="PH" equiv-text="this.typeName"/>.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">158</context>
 | 
			
		||||
          <context context-type="linenumber">161</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3928835053823658072" datatype="html">
 | 
			
		||||
        <source>Error occurred while creating <x id="PH" equiv-text="this.typeName"/>.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">163</context>
 | 
			
		||||
          <context context-type="linenumber">166</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2541368547549828690" datatype="html">
 | 
			
		||||
        <source>Successfully updated <x id="PH" equiv-text="this.typeName"/>.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">178</context>
 | 
			
		||||
          <context context-type="linenumber">181</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6442673774206210733" datatype="html">
 | 
			
		||||
        <source>Error occurred while saving <x id="PH" equiv-text="this.typeName"/>.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">183</context>
 | 
			
		||||
          <context context-type="linenumber">186</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8371896857609524947" datatype="html">
 | 
			
		||||
        <source>Associated documents will not be deleted.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">206</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6639207128255974941" datatype="html">
 | 
			
		||||
        <source>Error while deleting element</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">207</context>
 | 
			
		||||
          <context context-type="linenumber">222</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4863024195229581844" datatype="html">
 | 
			
		||||
        <source>Permissions updated successfully</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">282</context>
 | 
			
		||||
          <context context-type="linenumber">298</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1464476612812630086" datatype="html">
 | 
			
		||||
        <source>This operation will permanently delete all objects.</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">303</context>
 | 
			
		||||
          <context context-type="linenumber">319</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5897787932098828336" datatype="html">
 | 
			
		||||
        <source>Objects deleted successfully</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">317</context>
 | 
			
		||||
          <context context-type="linenumber">333</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8273353839648035634" datatype="html">
 | 
			
		||||
        <source>Error deleting objects</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
 | 
			
		||||
          <context context-type="linenumber">323</context>
 | 
			
		||||
          <context context-type="linenumber">339</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5101757640976222639" datatype="html">
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1252
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1252
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -40,7 +40,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@angular-builders/jest": "17.0.0",
 | 
			
		||||
    "@angular-devkit/build-angular": "~17.1.2",
 | 
			
		||||
    "@angular-devkit/build-angular": "~17.2.0",
 | 
			
		||||
    "@angular-eslint/builder": "17.2.1",
 | 
			
		||||
    "@angular-eslint/eslint-plugin": "17.2.1",
 | 
			
		||||
    "@angular-eslint/eslint-plugin-template": "17.2.1",
 | 
			
		||||
 | 
			
		||||
@ -94,6 +94,10 @@ Object.defineProperty(navigator, 'clipboard', {
 | 
			
		||||
})
 | 
			
		||||
Object.defineProperty(navigator, 'canShare', { value: () => true })
 | 
			
		||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
 | 
			
		||||
Object.defineProperty(window, 'location', {
 | 
			
		||||
  configurable: true,
 | 
			
		||||
  value: { reload: jest.fn() },
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
HTMLCanvasElement.prototype.getContext = <
 | 
			
		||||
  typeof HTMLCanvasElement.prototype.getContext
 | 
			
		||||
 | 
			
		||||
@ -309,10 +309,15 @@ describe('SettingsComponent', () => {
 | 
			
		||||
    component.store.getValue()['displayLanguage'] = 'en-US'
 | 
			
		||||
    component.store.getValue()['updateCheckingEnabled'] = false
 | 
			
		||||
    component.settingsForm.value.displayLanguage = 'en-GB'
 | 
			
		||||
    component.settingsForm.value.updateCheckingEnabled = true
 | 
			
		||||
    jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true))
 | 
			
		||||
    jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
 | 
			
		||||
    component.saveSettings()
 | 
			
		||||
    expect(toast.actionName).toEqual('Reload now')
 | 
			
		||||
 | 
			
		||||
    component.settingsForm.value.updateCheckingEnabled = true
 | 
			
		||||
    component.saveSettings()
 | 
			
		||||
 | 
			
		||||
    expect(toast.actionName).toEqual('Reload now')
 | 
			
		||||
    toast.action()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should allow setting theme color, visually apply change immediately but not save', () => {
 | 
			
		||||
 | 
			
		||||
@ -4,16 +4,16 @@
 | 
			
		||||
    (click)="isMenuCollapsed = !isMenuCollapsed">
 | 
			
		||||
    <span class="navbar-toggler-icon"></span>
 | 
			
		||||
  </button>
 | 
			
		||||
  <a class="navbar-brand d-flex col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0"
 | 
			
		||||
    [ngClass]="{ 'slim': slimSidebarEnabled, 'd-flex col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
 | 
			
		||||
  <a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
 | 
			
		||||
    [ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
 | 
			
		||||
    routerLink="/dashboard"
 | 
			
		||||
    tourAnchor="tour.intro">
 | 
			
		||||
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor">
 | 
			
		||||
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
 | 
			
		||||
      <path
 | 
			
		||||
        d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
 | 
			
		||||
        transform="translate(0 0)" />
 | 
			
		||||
    </svg>
 | 
			
		||||
    <div class="ms-2 d-inline-block" [class.visually-hidden]="slimSidebarEnabled">
 | 
			
		||||
    <div class="ms-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
 | 
			
		||||
      @if (customAppTitle?.length) {
 | 
			
		||||
        <div class="d-flex flex-column align-items-start">
 | 
			
		||||
          <span class="title">{{customAppTitle}}</span>
 | 
			
		||||
 | 
			
		||||
@ -493,12 +493,17 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    expect(changedResult.getExcludedItems()).toEqual(items)
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('FilterableDropdownSelectionModel should sort items by state', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
  it('selection model should sort items by state', () => {
 | 
			
		||||
    component.items = items.concat([{ id: null, name: 'Null B' }])
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    selectionModel.toggle(items[1].id)
 | 
			
		||||
    selectionModel.apply()
 | 
			
		||||
    expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
 | 
			
		||||
    expect(selectionModel.itemsSorted).toEqual([
 | 
			
		||||
      nullItem,
 | 
			
		||||
      { id: null, name: 'Null B' },
 | 
			
		||||
      items[1],
 | 
			
		||||
      items[0],
 | 
			
		||||
    ])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should set support create, keep open model and call createRef method', fakeAsync(() => {
 | 
			
		||||
@ -542,4 +547,34 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    tick(300)
 | 
			
		||||
    expect(createSpy).toHaveBeenCalled()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should exclude item and trigger change event', () => {
 | 
			
		||||
    const id = 1
 | 
			
		||||
    const state = ToggleableItemState.Selected
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.singleSelect = true
 | 
			
		||||
    component.selectionModel.intersection = Intersection.Include
 | 
			
		||||
    component.selectionModel['temporarySelectionStates'].set(id, state)
 | 
			
		||||
    const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
 | 
			
		||||
    component.selectionModel.exclude(id)
 | 
			
		||||
    expect(component.selectionModel.temporaryLogicalOperator).toBe(
 | 
			
		||||
      LogicalOperator.And
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
 | 
			
		||||
      ToggleableItemState.Excluded
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.selectionModel['temporarySelectionStates'].size).toBe(1)
 | 
			
		||||
    expect(changedSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should initialize selection states and apply changes', () => {
 | 
			
		||||
    selectionModel.items = items
 | 
			
		||||
    const map = new Map<number, ToggleableItemState>()
 | 
			
		||||
    map.set(1, ToggleableItemState.Selected)
 | 
			
		||||
    map.set(2, ToggleableItemState.Excluded)
 | 
			
		||||
    selectionModel.init(map)
 | 
			
		||||
    expect(selectionModel.getSelectedItems()).toEqual([items[0]])
 | 
			
		||||
    expect(selectionModel.getExcludedItems()).toEqual([items[1]])
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -275,7 +275,7 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init(map) {
 | 
			
		||||
  init(map: Map<number, ToggleableItemState>) {
 | 
			
		||||
    this.temporarySelectionStates = map
 | 
			
		||||
    this.apply()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -118,4 +118,18 @@ describe('SelectComponent', () => {
 | 
			
		||||
    tick(3000)
 | 
			
		||||
    expect(clearSpy).toHaveBeenCalled()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should emit filtered documents', () => {
 | 
			
		||||
    component.value = 10
 | 
			
		||||
    component.items = items
 | 
			
		||||
    const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
 | 
			
		||||
    component.onFilterDocuments()
 | 
			
		||||
    expect(emitSpy).toHaveBeenCalledWith([items[2]])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should return the correct filter button title', () => {
 | 
			
		||||
    component.title = 'Tag'
 | 
			
		||||
    const expectedTitle = `Filter documents with this ${component.title}`
 | 
			
		||||
    expect(component.filterButtonTitle).toEqual(expectedTitle)
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -169,4 +169,12 @@ describe('TagsComponent', () => {
 | 
			
		||||
    expect(component.getTag(2)).toEqual(tags[1])
 | 
			
		||||
    expect(component.getTag(4)).toBeUndefined()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should emit filtered documents', () => {
 | 
			
		||||
    component.value = [10]
 | 
			
		||||
    component.tags = tags
 | 
			
		||||
    const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
 | 
			
		||||
    component.onFilterDocuments()
 | 
			
		||||
    expect(emitSpy).toHaveBeenCalledWith([tags[2]])
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -119,6 +119,8 @@ describe('UploadFileWidgetComponent', () => {
 | 
			
		||||
    const processingStatus = new FileStatus()
 | 
			
		||||
    processingStatus.phase = FileStatusPhase.WORKING
 | 
			
		||||
    expect(component.getStatusColor(processingStatus)).toEqual('primary')
 | 
			
		||||
    processingStatus.phase = FileStatusPhase.UPLOADING
 | 
			
		||||
    expect(component.getStatusColor(processingStatus)).toEqual('primary')
 | 
			
		||||
    const failedStatus = new FileStatus()
 | 
			
		||||
    failedStatus.phase = FileStatusPhase.FAILED
 | 
			
		||||
    expect(component.getStatusColor(failedStatus)).toEqual('danger')
 | 
			
		||||
 | 
			
		||||
@ -634,11 +634,14 @@ export class DocumentDetailComponent
 | 
			
		||||
          // in case data changed while saving eg removing inbox_tags
 | 
			
		||||
          this.documentForm.patchValue(docValues)
 | 
			
		||||
          this.store.next(this.documentForm.value)
 | 
			
		||||
          this.openDocumentService.setDirty(this.document, false)
 | 
			
		||||
          this.toastService.showInfo($localize`Document saved successfully.`)
 | 
			
		||||
          close && this.close()
 | 
			
		||||
          this.networkActive = false
 | 
			
		||||
          this.error = null
 | 
			
		||||
          close &&
 | 
			
		||||
            this.close(() =>
 | 
			
		||||
              this.openDocumentService.refreshDocument(this.documentId)
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
        error: (error) => {
 | 
			
		||||
          this.networkActive = false
 | 
			
		||||
@ -693,12 +696,13 @@ export class DocumentDetailComponent
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  close() {
 | 
			
		||||
  close(closedCallback: () => void = null) {
 | 
			
		||||
    this.openDocumentService
 | 
			
		||||
      .closeDocument(this.document)
 | 
			
		||||
      .pipe(first())
 | 
			
		||||
      .subscribe((closed) => {
 | 
			
		||||
        if (!closed) return
 | 
			
		||||
        if (closedCallback) closedCallback()
 | 
			
		||||
        if (this.documentListViewService.activeSavedViewId) {
 | 
			
		||||
          this.router.navigate([
 | 
			
		||||
            'view',
 | 
			
		||||
 | 
			
		||||
@ -381,6 +381,28 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    expect(component.textFilter).toBeNull()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest text filter content with relative dates that are not in quick list', fakeAsync(() => {
 | 
			
		||||
    expect(component.dateAddedRelativeDate).toBeNull()
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'added:[-2 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.dateAddedRelativeDate).toBeNull()
 | 
			
		||||
    expect(component.textFilter).toEqual('added:[-2 week to now]')
 | 
			
		||||
 | 
			
		||||
    expect(component.dateCreatedRelativeDate).toBeNull()
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'created:[-2 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.dateCreatedRelativeDate).toBeNull()
 | 
			
		||||
    expect(component.textFilter).toEqual('created:[-2 week to now]')
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest text filter rules for more like', fakeAsync(() => {
 | 
			
		||||
    const moreLikeSpy = jest.spyOn(documentService, 'get')
 | 
			
		||||
    moreLikeSpy.mockReturnValue(of({ id: 1, title: 'Foo Bar' }))
 | 
			
		||||
@ -1372,6 +1394,34 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should leave relative dates not in quick list intact', fakeAsync(() => {
 | 
			
		||||
    component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
 | 
			
		||||
    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
 | 
			
		||||
    const textFieldTargetDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(NgbDropdownItem)
 | 
			
		||||
    )[4]
 | 
			
		||||
    textFieldTargetDropdown.triggerEventHandler('click')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    tick(400)
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'created:[-2 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    component.textFilterInput.nativeElement.value = 'added:[-2 month to now]'
 | 
			
		||||
    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    tick(400)
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'added:[-2 month to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
 | 
			
		||||
    const dateAddedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
 | 
			
		||||
@ -362,10 +362,11 @@ export class FilterEditorComponent
 | 
			
		||||
                    this.dateCreatedRelativeDate =
 | 
			
		||||
                      RELATIVE_DATE_QUERYSTRINGS.find(
 | 
			
		||||
                        (qS) => qS.dateQuery == match[1]
 | 
			
		||||
                      )?.relativeDate
 | 
			
		||||
                      )?.relativeDate ?? null
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              )
 | 
			
		||||
              if (this.dateCreatedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
 | 
			
		||||
            } else if (arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)) {
 | 
			
		||||
              ;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_ADDED)].forEach(
 | 
			
		||||
                (match) => {
 | 
			
		||||
@ -373,10 +374,11 @@ export class FilterEditorComponent
 | 
			
		||||
                    this.dateAddedRelativeDate =
 | 
			
		||||
                      RELATIVE_DATE_QUERYSTRINGS.find(
 | 
			
		||||
                        (qS) => qS.dateQuery == match[1]
 | 
			
		||||
                      )?.relativeDate
 | 
			
		||||
                      )?.relativeDate ?? null
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              )
 | 
			
		||||
              if (this.dateAddedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
 | 
			
		||||
            } else {
 | 
			
		||||
              textQueryArgs.push(arg)
 | 
			
		||||
            }
 | 
			
		||||
@ -787,27 +789,6 @@ export class FilterEditorComponent
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
      this.dateCreatedRelativeDate == null &&
 | 
			
		||||
      this.dateAddedRelativeDate == null
 | 
			
		||||
    ) {
 | 
			
		||||
      const existingRule = filterRules.find(
 | 
			
		||||
        (fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
 | 
			
		||||
      )
 | 
			
		||||
      if (
 | 
			
		||||
        existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_CREATED) ||
 | 
			
		||||
        existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)
 | 
			
		||||
      ) {
 | 
			
		||||
        // remove any existing date query
 | 
			
		||||
        existingRule.value = existingRule.value
 | 
			
		||||
          .replace(RELATIVE_DATE_QUERY_REGEXP_CREATED, '')
 | 
			
		||||
          .replace(RELATIVE_DATE_QUERY_REGEXP_ADDED, '')
 | 
			
		||||
        if (existingRule.value.replace(',', '').trim() === '') {
 | 
			
		||||
          // if its empty now, remove it entirely
 | 
			
		||||
          filterRules.splice(filterRules.indexOf(existingRule), 1)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
 | 
			
		||||
      filterRules.push({
 | 
			
		||||
        rule_type: FILTER_OWNER,
 | 
			
		||||
 | 
			
		||||
@ -2,10 +2,10 @@
 | 
			
		||||
  <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
 | 
			
		||||
    <i-bs  name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
 | 
			
		||||
      <i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
 | 
			
		||||
      <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
 | 
			
		||||
@ -92,15 +92,9 @@
 | 
			
		||||
                    <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
 | 
			
		||||
                      <i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
 | 
			
		||||
                      </button>
 | 
			
		||||
                  <pngx-confirm-button
 | 
			
		||||
                    label="Delete"
 | 
			
		||||
                    i18n-label
 | 
			
		||||
                    (confirm)="deleteObject(object)"
 | 
			
		||||
                    *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }"
 | 
			
		||||
                    [disabled]="!userCanDelete(object)"
 | 
			
		||||
                    buttonClasses=" btn-sm btn-outline-danger"
 | 
			
		||||
                    iconName="trash">
 | 
			
		||||
                  </pngx-confirm-button>
 | 
			
		||||
                      <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
 | 
			
		||||
                        <i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
 | 
			
		||||
                        </button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,6 @@ import {
 | 
			
		||||
  NgbModalModule,
 | 
			
		||||
  NgbModalRef,
 | 
			
		||||
  NgbPaginationModule,
 | 
			
		||||
  NgbPopoverModule,
 | 
			
		||||
} from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { of, throwError } from 'rxjs'
 | 
			
		||||
import { Tag } from 'src/app/data/tag'
 | 
			
		||||
@ -24,7 +23,10 @@ import { TagService } from 'src/app/services/rest/tag.service'
 | 
			
		||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 | 
			
		||||
import { TagListComponent } from '../tag-list/tag-list.component'
 | 
			
		||||
import { ManagementListComponent } from './management-list.component'
 | 
			
		||||
import { PermissionsService } from 'src/app/services/permissions.service'
 | 
			
		||||
import {
 | 
			
		||||
  PermissionAction,
 | 
			
		||||
  PermissionsService,
 | 
			
		||||
} from 'src/app/services/permissions.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
 | 
			
		||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
 | 
			
		||||
@ -38,7 +40,6 @@ import { MATCH_NONE } from 'src/app/data/matching-model'
 | 
			
		||||
import { MATCH_LITERAL } from 'src/app/data/matching-model'
 | 
			
		||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
 | 
			
		||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
 | 
			
		||||
 | 
			
		||||
const tags: Tag[] = [
 | 
			
		||||
@ -67,6 +68,7 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
  let modalService: NgbModal
 | 
			
		||||
  let toastService: ToastService
 | 
			
		||||
  let documentListViewService: DocumentListViewService
 | 
			
		||||
  let permissionsService: PermissionsService
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
@ -78,20 +80,8 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
        SafeHtmlPipe,
 | 
			
		||||
        ConfirmDialogComponent,
 | 
			
		||||
        PermissionsDialogComponent,
 | 
			
		||||
        ConfirmButtonComponent,
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [
 | 
			
		||||
        {
 | 
			
		||||
          provide: PermissionsService,
 | 
			
		||||
          useValue: {
 | 
			
		||||
            currentUserCan: () => true,
 | 
			
		||||
            currentUserHasObjectPermissions: () => true,
 | 
			
		||||
            currentUserOwnsObject: () => true,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        DatePipe,
 | 
			
		||||
        PermissionsGuard,
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [DatePipe, PermissionsGuard],
 | 
			
		||||
      imports: [
 | 
			
		||||
        HttpClientTestingModule,
 | 
			
		||||
        NgbPaginationModule,
 | 
			
		||||
@ -100,7 +90,6 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
        NgbModalModule,
 | 
			
		||||
        RouterTestingModule.withRoutes(routes),
 | 
			
		||||
        NgxBootstrapIconsModule.pick(allIcons),
 | 
			
		||||
        NgbPopoverModule,
 | 
			
		||||
      ],
 | 
			
		||||
    }).compileComponents()
 | 
			
		||||
 | 
			
		||||
@ -119,6 +108,14 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
      )
 | 
			
		||||
    permissionsService = TestBed.inject(PermissionsService)
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
 | 
			
		||||
      .mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserOwnsObject')
 | 
			
		||||
      .mockReturnValue(true)
 | 
			
		||||
    modalService = TestBed.inject(NgbModal)
 | 
			
		||||
    toastService = TestBed.inject(ToastService)
 | 
			
		||||
    documentListViewService = TestBed.inject(DocumentListViewService)
 | 
			
		||||
@ -197,23 +194,27 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  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(tagService, 'delete')
 | 
			
		||||
    const reloadSpy = jest.spyOn(component, 'reloadData')
 | 
			
		||||
 | 
			
		||||
    const deleteButton = fixture.debugElement.query(
 | 
			
		||||
      By.directive(ConfirmButtonComponent)
 | 
			
		||||
    )
 | 
			
		||||
    const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8]
 | 
			
		||||
    deleteButton.triggerEventHandler('click')
 | 
			
		||||
 | 
			
		||||
    expect(modal).not.toBeUndefined()
 | 
			
		||||
    const editDialog = modal.componentInstance as ConfirmDialogComponent
 | 
			
		||||
 | 
			
		||||
    // fail first
 | 
			
		||||
    deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
 | 
			
		||||
    deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
 | 
			
		||||
    editDialog.confirmClicked.emit()
 | 
			
		||||
    expect(toastErrorSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(reloadSpy).not.toHaveBeenCalled()
 | 
			
		||||
 | 
			
		||||
    // succeed
 | 
			
		||||
    deleteSpy.mockReturnValueOnce(of(true))
 | 
			
		||||
    deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
 | 
			
		||||
    editDialog.confirmClicked.emit()
 | 
			
		||||
    expect(reloadSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@ -312,4 +313,10 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
    expect(bulkEditSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(successToastSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should disallow bulk permissions or delete objects if no global perms', () => {
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
 | 
			
		||||
    expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
 | 
			
		||||
    expect(component.userCanBulkEdit(PermissionAction.Change)).toBeFalsy()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,7 @@ import {
 | 
			
		||||
} from 'src/app/directives/sortable.directive'
 | 
			
		||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
 | 
			
		||||
import {
 | 
			
		||||
  PermissionAction,
 | 
			
		||||
  PermissionsService,
 | 
			
		||||
  PermissionType,
 | 
			
		||||
} from 'src/app/services/permissions.service'
 | 
			
		||||
@ -194,21 +195,34 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
 | 
			
		||||
    ])
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  deleteObject(object: T) {
 | 
			
		||||
  openDeleteDialog(object: T) {
 | 
			
		||||
    var activeModal = this.modalService.open(ConfirmDialogComponent, {
 | 
			
		||||
      backdrop: 'static',
 | 
			
		||||
    })
 | 
			
		||||
    activeModal.componentInstance.title = $localize`Confirm delete`
 | 
			
		||||
    activeModal.componentInstance.messageBold = this.getDeleteMessage(object)
 | 
			
		||||
    activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
 | 
			
		||||
    activeModal.componentInstance.btnClass = 'btn-danger'
 | 
			
		||||
    activeModal.componentInstance.btnCaption = $localize`Delete`
 | 
			
		||||
    activeModal.componentInstance.confirmClicked.subscribe(() => {
 | 
			
		||||
      activeModal.componentInstance.buttonsEnabled = false
 | 
			
		||||
      this.service
 | 
			
		||||
        .delete(object)
 | 
			
		||||
        .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
        .subscribe({
 | 
			
		||||
          next: () => {
 | 
			
		||||
            activeModal.close()
 | 
			
		||||
            this.reloadData()
 | 
			
		||||
          },
 | 
			
		||||
          error: (error) => {
 | 
			
		||||
            activeModal.componentInstance.buttonsEnabled = true
 | 
			
		||||
            this.toastService.showError(
 | 
			
		||||
              $localize`Error while deleting element`,
 | 
			
		||||
              error
 | 
			
		||||
            )
 | 
			
		||||
          },
 | 
			
		||||
        })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get nameFilter() {
 | 
			
		||||
@ -234,7 +248,9 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get userOwnsAll(): boolean {
 | 
			
		||||
  userCanBulkEdit(action: PermissionAction): boolean {
 | 
			
		||||
    if (!this.permissionsService.currentUserCan(action, this.permissionType))
 | 
			
		||||
      return false
 | 
			
		||||
    let ownsAll: boolean = true
 | 
			
		||||
    const objects = this.data.filter((o) => this.selectedObjects.has(o.id))
 | 
			
		||||
    ownsAll = objects.every((o) =>
 | 
			
		||||
 | 
			
		||||
@ -17,6 +17,7 @@ describe('DirtyFormGuard', () => {
 | 
			
		||||
  let guard: DirtyFormGuard
 | 
			
		||||
  let component: DirtyComponent
 | 
			
		||||
  let route: ActivatedRoute
 | 
			
		||||
  let modalService: NgbModal
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
@ -37,6 +38,7 @@ describe('DirtyFormGuard', () => {
 | 
			
		||||
 | 
			
		||||
    guard = TestBed.inject(DirtyFormGuard)
 | 
			
		||||
    route = TestBed.inject(ActivatedRoute)
 | 
			
		||||
    modalService = TestBed.inject(NgbModal)
 | 
			
		||||
    const fixture = TestBed.createComponent(GenericDirtyComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
 | 
			
		||||
@ -57,9 +59,14 @@ describe('DirtyFormGuard', () => {
 | 
			
		||||
    component.isDirty$ = true
 | 
			
		||||
    const confirmSpy = jest.spyOn(guard, 'confirmChanges')
 | 
			
		||||
    const canDeactivate = guard.canDeactivate(component, route.snapshot)
 | 
			
		||||
    let modal
 | 
			
		||||
    modalService.activeInstances.subscribe((instances) => {
 | 
			
		||||
      modal = instances[0]
 | 
			
		||||
    })
 | 
			
		||||
    canDeactivate.subscribe()
 | 
			
		||||
 | 
			
		||||
    expect(canDeactivate).toHaveProperty('source') // Observable
 | 
			
		||||
    expect(confirmSpy).toHaveBeenCalled()
 | 
			
		||||
    modal.componentInstance.confirmClicked.next()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -108,6 +108,7 @@ describe('OpenDocumentsService', () => {
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should close documents', () => {
 | 
			
		||||
    openDocumentsService.closeDocument({ id: 999 } as any)
 | 
			
		||||
    subscriptions.push(
 | 
			
		||||
      openDocumentsService.openDocument(documents[0]).subscribe()
 | 
			
		||||
    )
 | 
			
		||||
@ -128,15 +129,21 @@ describe('OpenDocumentsService', () => {
 | 
			
		||||
    subscriptions.push(
 | 
			
		||||
      openDocumentsService.openDocument(documents[0]).subscribe()
 | 
			
		||||
    )
 | 
			
		||||
    openDocumentsService.setDirty({ id: 999 }, true) // coverage
 | 
			
		||||
    openDocumentsService.setDirty(documents[0], false)
 | 
			
		||||
    expect(openDocumentsService.hasDirty()).toBeFalsy()
 | 
			
		||||
    openDocumentsService.setDirty(documents[0], true)
 | 
			
		||||
    expect(openDocumentsService.hasDirty()).toBeTruthy()
 | 
			
		||||
    let openModal
 | 
			
		||||
    modalService.activeInstances.subscribe((instances) => {
 | 
			
		||||
      openModal = instances[0]
 | 
			
		||||
    })
 | 
			
		||||
    const modalSpy = jest.spyOn(modalService, 'open')
 | 
			
		||||
    subscriptions.push(
 | 
			
		||||
      openDocumentsService.closeDocument(documents[0]).subscribe()
 | 
			
		||||
    )
 | 
			
		||||
    expect(modalSpy).toHaveBeenCalled()
 | 
			
		||||
    openModal.componentInstance.confirmClicked.next()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should allow set dirty status, warn on closeAll', () => {
 | 
			
		||||
@ -148,9 +155,14 @@ describe('OpenDocumentsService', () => {
 | 
			
		||||
    )
 | 
			
		||||
    openDocumentsService.setDirty(documents[0], true)
 | 
			
		||||
    expect(openDocumentsService.hasDirty()).toBeTruthy()
 | 
			
		||||
    let openModal
 | 
			
		||||
    modalService.activeInstances.subscribe((instances) => {
 | 
			
		||||
      openModal = instances[0]
 | 
			
		||||
    })
 | 
			
		||||
    const modalSpy = jest.spyOn(modalService, 'open')
 | 
			
		||||
    subscriptions.push(openDocumentsService.closeAll().subscribe())
 | 
			
		||||
    expect(modalSpy).toHaveBeenCalled()
 | 
			
		||||
    openModal.componentInstance.confirmClicked.next()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should load open documents from localStorage', () => {
 | 
			
		||||
 | 
			
		||||
@ -58,12 +58,25 @@ describe(`Additional service tests for MailAccountService`, () => {
 | 
			
		||||
  it('should support patchMany', () => {
 | 
			
		||||
    subscription = service.patchMany(mail_accounts).subscribe()
 | 
			
		||||
    mail_accounts.forEach((mail_account) => {
 | 
			
		||||
      const reqs = httpTestingController.match(
 | 
			
		||||
      const req = httpTestingController.expectOne(
 | 
			
		||||
        `${environment.apiBaseUrl}${endpoint}/${mail_account.id}/`
 | 
			
		||||
      )
 | 
			
		||||
      expect(reqs).toHaveLength(1)
 | 
			
		||||
      expect(reqs[0].request.method).toEqual('PATCH')
 | 
			
		||||
      expect(req.request.method).toEqual('PATCH')
 | 
			
		||||
      req.flush(mail_account)
 | 
			
		||||
    })
 | 
			
		||||
    httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support reload', () => {
 | 
			
		||||
    service['reload']()
 | 
			
		||||
    const req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
    expect(req.request.method).toEqual('GET')
 | 
			
		||||
    req.flush({ results: mail_accounts })
 | 
			
		||||
    expect(service.allAccounts).toEqual(mail_accounts)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
 | 
			
		||||
@ -76,12 +76,26 @@ describe(`Additional service tests for MailRuleService`, () => {
 | 
			
		||||
  it('should support patchMany', () => {
 | 
			
		||||
    subscription = service.patchMany(mail_rules).subscribe()
 | 
			
		||||
    mail_rules.forEach((mail_rule) => {
 | 
			
		||||
      const reqs = httpTestingController.match(
 | 
			
		||||
      const req = httpTestingController.expectOne(
 | 
			
		||||
        `${environment.apiBaseUrl}${endpoint}/${mail_rule.id}/`
 | 
			
		||||
      )
 | 
			
		||||
      expect(reqs).toHaveLength(1)
 | 
			
		||||
      expect(reqs[0].request.method).toEqual('PATCH')
 | 
			
		||||
      expect(req.request.method).toEqual('PATCH')
 | 
			
		||||
      req.flush(mail_rule)
 | 
			
		||||
    })
 | 
			
		||||
    const reloadReq = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
    reloadReq.flush({ results: mail_rules })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support reload', () => {
 | 
			
		||||
    service['reload']()
 | 
			
		||||
    const req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
    expect(req.request.method).toEqual('GET')
 | 
			
		||||
    req.flush({ results: mail_rules })
 | 
			
		||||
    expect(service.allRules).toEqual(mail_rules)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
 | 
			
		||||
@ -262,7 +262,7 @@ a.btn-link:focus-visible,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
 | 
			
		||||
      background-color: var(--pngx-bg-darker) !important;
 | 
			
		||||
      background-color: var(--pngx-bg-alt) !important;
 | 
			
		||||
      color: var(--pngx-body-color-accent) !important;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -439,7 +439,7 @@ ul.pagination {
 | 
			
		||||
    color: var(--bs-body-color);
 | 
			
		||||
 | 
			
		||||
    &:hover, &:focus {
 | 
			
		||||
      background-color: var(--pngx-bg-darker);
 | 
			
		||||
      background-color: var(--pngx-bg-alt);
 | 
			
		||||
      color: var(--bs-body-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -286,10 +286,10 @@ class Command(BaseCommand):
 | 
			
		||||
    def handle_inotify(self, directory, recursive, is_testing: bool):
 | 
			
		||||
        logger.info(f"Using inotify to watch directory for changes: {directory}")
 | 
			
		||||
 | 
			
		||||
        timeout = None
 | 
			
		||||
        timeout_ms = None
 | 
			
		||||
        if is_testing:
 | 
			
		||||
            timeout = self.testing_timeout_ms
 | 
			
		||||
            logger.debug(f"Configuring timeout to {timeout}ms")
 | 
			
		||||
            timeout_ms = self.testing_timeout_ms
 | 
			
		||||
            logger.debug(f"Configuring timeout to {timeout_ms}ms")
 | 
			
		||||
 | 
			
		||||
        inotify = INotify()
 | 
			
		||||
        inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY
 | 
			
		||||
@ -298,7 +298,8 @@ class Command(BaseCommand):
 | 
			
		||||
        else:
 | 
			
		||||
            descriptor = inotify.add_watch(directory, inotify_flags)
 | 
			
		||||
 | 
			
		||||
        inotify_debounce: Final[float] = settings.CONSUMER_INOTIFY_DELAY
 | 
			
		||||
        inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY
 | 
			
		||||
        inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000
 | 
			
		||||
 | 
			
		||||
        finished = False
 | 
			
		||||
 | 
			
		||||
@ -306,7 +307,7 @@ class Command(BaseCommand):
 | 
			
		||||
 | 
			
		||||
        while not finished:
 | 
			
		||||
            try:
 | 
			
		||||
                for event in inotify.read(timeout=timeout):
 | 
			
		||||
                for event in inotify.read(timeout=timeout_ms):
 | 
			
		||||
                    path = inotify.get_path(event.wd) if recursive else directory
 | 
			
		||||
                    filepath = os.path.join(path, event.name)
 | 
			
		||||
                    if flags.MODIFY in flags.from_mask(event.mask):
 | 
			
		||||
@ -323,7 +324,7 @@ class Command(BaseCommand):
 | 
			
		||||
                    # Current time - last time over the configured timeout
 | 
			
		||||
                    waited_long_enough = (
 | 
			
		||||
                        monotonic() - last_event_time
 | 
			
		||||
                    ) > inotify_debounce
 | 
			
		||||
                    ) > inotify_debounce_secs
 | 
			
		||||
 | 
			
		||||
                    # Also make sure the file exists still, some scanners might write a
 | 
			
		||||
                    # temporary file first
 | 
			
		||||
@ -342,11 +343,11 @@ class Command(BaseCommand):
 | 
			
		||||
                # If files are waiting, need to exit read() to check them
 | 
			
		||||
                # Otherwise, go back to infinite sleep time, but only if not testing
 | 
			
		||||
                if len(notified_files) > 0:
 | 
			
		||||
                    timeout = inotify_debounce
 | 
			
		||||
                    timeout_ms = inotify_debounce_ms
 | 
			
		||||
                elif is_testing:
 | 
			
		||||
                    timeout = self.testing_timeout_ms
 | 
			
		||||
                    timeout_ms = self.testing_timeout_ms
 | 
			
		||||
                else:
 | 
			
		||||
                    timeout = None
 | 
			
		||||
                    timeout_ms = None
 | 
			
		||||
 | 
			
		||||
                if self.stop_flag.is_set():
 | 
			
		||||
                    logger.debug("Finishing because event is set")
 | 
			
		||||
 | 
			
		||||
@ -4,26 +4,17 @@ import django.db.models.deletion
 | 
			
		||||
import multiselectfield.db.fields
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.management import create_permissions
 | 
			
		||||
from django.contrib.auth.models import Group
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
 | 
			
		||||
from documents.models import Correspondent
 | 
			
		||||
from documents.models import CustomField
 | 
			
		||||
from documents.models import DocumentType
 | 
			
		||||
from documents.models import StoragePath
 | 
			
		||||
from documents.models import Tag
 | 
			
		||||
from documents.models import Workflow
 | 
			
		||||
from documents.models import WorkflowAction
 | 
			
		||||
from documents.models import WorkflowTrigger
 | 
			
		||||
from paperless_mail.models import MailRule
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_workflow_permissions(apps, schema_editor):
 | 
			
		||||
    app_name = "auth"
 | 
			
		||||
    User = apps.get_model(app_label=app_name, model_name="User")
 | 
			
		||||
    Group = apps.get_model(app_label=app_name, model_name="Group")
 | 
			
		||||
    Permission = apps.get_model(app_label=app_name, model_name="Permission")
 | 
			
		||||
    # create permissions without waiting for post_migrate signal
 | 
			
		||||
    for app_config in apps.get_app_configs():
 | 
			
		||||
        app_config.models_module = True
 | 
			
		||||
@ -43,6 +34,10 @@ def add_workflow_permissions(apps, schema_editor):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def remove_workflow_permissions(apps, schema_editor):
 | 
			
		||||
    app_name = "auth"
 | 
			
		||||
    User = apps.get_model(app_label=app_name, model_name="User")
 | 
			
		||||
    Group = apps.get_model(app_label=app_name, model_name="Group")
 | 
			
		||||
    Permission = apps.get_model(app_label=app_name, model_name="Permission")
 | 
			
		||||
    workflow_permissions = Permission.objects.filter(
 | 
			
		||||
        codename__contains="workflow",
 | 
			
		||||
    )
 | 
			
		||||
@ -59,15 +54,28 @@ def migrate_consumption_templates(apps, schema_editor):
 | 
			
		||||
    Migrate consumption templates to workflows. At this point ConsumptionTemplate still exists
 | 
			
		||||
    but objects are not returned as their true model so we have to manually do that
 | 
			
		||||
    """
 | 
			
		||||
    model_name = "ConsumptionTemplate"
 | 
			
		||||
    app_name = "documents"
 | 
			
		||||
 | 
			
		||||
    ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
 | 
			
		||||
    ConsumptionTemplate = apps.get_model(
 | 
			
		||||
        app_label=app_name,
 | 
			
		||||
        model_name="ConsumptionTemplate",
 | 
			
		||||
    )
 | 
			
		||||
    Workflow = apps.get_model(app_label=app_name, model_name="Workflow")
 | 
			
		||||
    WorkflowAction = apps.get_model(app_label=app_name, model_name="WorkflowAction")
 | 
			
		||||
    WorkflowTrigger = apps.get_model(app_label=app_name, model_name="WorkflowTrigger")
 | 
			
		||||
    DocumentType = apps.get_model(app_label=app_name, model_name="DocumentType")
 | 
			
		||||
    Correspondent = apps.get_model(app_label=app_name, model_name="Correspondent")
 | 
			
		||||
    StoragePath = apps.get_model(app_label=app_name, model_name="StoragePath")
 | 
			
		||||
    Tag = apps.get_model(app_label=app_name, model_name="Tag")
 | 
			
		||||
    CustomField = apps.get_model(app_label=app_name, model_name="CustomField")
 | 
			
		||||
    MailRule = apps.get_model(app_label="paperless_mail", model_name="MailRule")
 | 
			
		||||
    User = apps.get_model(app_label="auth", model_name="User")
 | 
			
		||||
    Group = apps.get_model(app_label="auth", model_name="Group")
 | 
			
		||||
 | 
			
		||||
    with transaction.atomic():
 | 
			
		||||
        for template in ConsumptionTemplate.objects.all():
 | 
			
		||||
            trigger = WorkflowTrigger(
 | 
			
		||||
                type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
 | 
			
		||||
                type=1,  # WorkflowTriggerType.CONSUMPTION
 | 
			
		||||
                sources=template.sources,
 | 
			
		||||
                filter_path=template.filter_path,
 | 
			
		||||
                filter_filename=template.filter_filename,
 | 
			
		||||
@ -143,10 +151,13 @@ def migrate_consumption_templates(apps, schema_editor):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def unmigrate_consumption_templates(apps, schema_editor):
 | 
			
		||||
    model_name = "ConsumptionTemplate"
 | 
			
		||||
    app_name = "documents"
 | 
			
		||||
 | 
			
		||||
    ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
 | 
			
		||||
    ConsumptionTemplate = apps.get_model(
 | 
			
		||||
        app_label=app_name,
 | 
			
		||||
        model_name="ConsumptionTemplate",
 | 
			
		||||
    )
 | 
			
		||||
    Workflow = apps.get_model(app_label=app_name, model_name="Workflow")
 | 
			
		||||
 | 
			
		||||
    for workflow in Workflow.objects.all():
 | 
			
		||||
        template = ConsumptionTemplate.objects.create(
 | 
			
		||||
 | 
			
		||||
@ -575,7 +575,11 @@ def run_workflow(
 | 
			
		||||
                                else ""
 | 
			
		||||
                            ),
 | 
			
		||||
                            timezone.localtime(document.added),
 | 
			
		||||
                            document.original_filename,
 | 
			
		||||
                            (
 | 
			
		||||
                                document.original_filename
 | 
			
		||||
                                if document.original_filename is not None
 | 
			
		||||
                                else ""
 | 
			
		||||
                            ),
 | 
			
		||||
                            timezone.localtime(document.created),
 | 
			
		||||
                        )
 | 
			
		||||
                    except Exception:
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import json
 | 
			
		||||
from unittest import mock
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
@ -310,17 +311,77 @@ class TestBulkEditObjects(APITestCase):
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
        self.assertEqual(StoragePath.objects.count(), 0)
 | 
			
		||||
 | 
			
		||||
    def test_bulk_edit_object_permissions_insufficient_perms(self):
 | 
			
		||||
    def test_bulk_edit_object_permissions_insufficient_global_perms(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - Objects owned by user other than logged in user
 | 
			
		||||
            - Existing objects, user does not have global delete permissions
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - bulk_edit_objects API endpoint is called with delete operation
 | 
			
		||||
        THEN:
 | 
			
		||||
            - User is not able to delete objects
 | 
			
		||||
        """
 | 
			
		||||
        self.t1.owner = User.objects.get(username="temp_admin")
 | 
			
		||||
        self.t1.save()
 | 
			
		||||
        self.client.force_authenticate(user=self.user1)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/bulk_edit_objects/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "objects": [self.t1.id, self.t2.id],
 | 
			
		||||
                    "object_type": "tags",
 | 
			
		||||
                    "operation": "delete",
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 | 
			
		||||
        self.assertEqual(response.content, b"Insufficient permissions")
 | 
			
		||||
 | 
			
		||||
    def test_bulk_edit_object_permissions_sufficient_global_perms(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - Existing objects, user does have global delete permissions
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - bulk_edit_objects API endpoint is called with delete operation
 | 
			
		||||
        THEN:
 | 
			
		||||
            - User is able to delete objects
 | 
			
		||||
        """
 | 
			
		||||
        self.user1.user_permissions.add(
 | 
			
		||||
            *Permission.objects.filter(codename="delete_tag"),
 | 
			
		||||
        )
 | 
			
		||||
        self.user1.save()
 | 
			
		||||
        self.client.force_authenticate(user=self.user1)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/bulk_edit_objects/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "objects": [self.t1.id, self.t2.id],
 | 
			
		||||
                    "object_type": "tags",
 | 
			
		||||
                    "operation": "delete",
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
 | 
			
		||||
    def test_bulk_edit_object_permissions_insufficient_object_perms(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - Objects owned by user other than logged in user
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - bulk_edit_objects API endpoint is called with delete operation
 | 
			
		||||
        THEN:
 | 
			
		||||
            - User is not able to delete objects
 | 
			
		||||
        """
 | 
			
		||||
        self.t2.owner = User.objects.get(username="temp_admin")
 | 
			
		||||
        self.t2.save()
 | 
			
		||||
 | 
			
		||||
        self.user1.user_permissions.add(
 | 
			
		||||
            *Permission.objects.filter(codename="delete_tag"),
 | 
			
		||||
        )
 | 
			
		||||
        self.user1.save()
 | 
			
		||||
        self.client.force_authenticate(user=self.user1)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
 | 
			
		||||
@ -1419,7 +1419,15 @@ class BulkEditObjectsView(GenericAPIView, PassUserMixin):
 | 
			
		||||
        objs = object_class.objects.filter(pk__in=object_ids)
 | 
			
		||||
 | 
			
		||||
        if not user.is_superuser:
 | 
			
		||||
            has_perms = all((obj.owner == user or obj.owner is None) for obj in objs)
 | 
			
		||||
            model_name = object_class._meta.verbose_name
 | 
			
		||||
            perm = (
 | 
			
		||||
                f"documents.change_{model_name}"
 | 
			
		||||
                if operation == "set_permissions"
 | 
			
		||||
                else f"documents.delete_{model_name}"
 | 
			
		||||
            )
 | 
			
		||||
            has_perms = user.has_perm(perm) and all(
 | 
			
		||||
                (obj.owner == user or obj.owner is None) for obj in objs
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            if not has_perms:
 | 
			
		||||
                return HttpResponseForbidden("Insufficient permissions")
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,7 @@ from rest_framework.authtoken.models import Token
 | 
			
		||||
from rest_framework.filters import OrderingFilter
 | 
			
		||||
from rest_framework.generics import GenericAPIView
 | 
			
		||||
from rest_framework.pagination import PageNumberPagination
 | 
			
		||||
from rest_framework.permissions import DjangoObjectPermissions
 | 
			
		||||
from rest_framework.permissions import DjangoModelPermissions
 | 
			
		||||
from rest_framework.permissions import IsAuthenticated
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.views import APIView
 | 
			
		||||
@ -171,7 +171,7 @@ class ApplicationConfigurationViewSet(ModelViewSet):
 | 
			
		||||
    queryset = ApplicationConfiguration.objects
 | 
			
		||||
 | 
			
		||||
    serializer_class = ApplicationConfigurationSerializer
 | 
			
		||||
    permission_classes = (IsAuthenticated, DjangoObjectPermissions)
 | 
			
		||||
    permission_classes = (IsAuthenticated, DjangoModelPermissions)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DisconnectSocialAccountView(GenericAPIView):
 | 
			
		||||
 | 
			
		||||
@ -831,6 +831,7 @@ class MailAccountHandler(LoggingMixin):
 | 
			
		||||
        input_doc = ConsumableDocument(
 | 
			
		||||
            source=DocumentSource.MailFetch,
 | 
			
		||||
            original_file=temp_filename,
 | 
			
		||||
            mailrule_id=rule.pk,
 | 
			
		||||
        )
 | 
			
		||||
        doc_overrides = DocumentMetadataOverrides(
 | 
			
		||||
            title=message.subject,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user