mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			39 Commits
		
	
	
		
			762d2da475
			...
			9581cfa1e3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 9581cfa1e3 | ||
|   | bbfa12f5a9 | ||
|   | 9ddc25ea42 | ||
|   | 286101dae4 | ||
|   | 119bb18dcd | ||
|   | 04ff089ab7 | ||
|   | 1ce0507401 | ||
|   | 129dbf675e | ||
|   | f9e0aee28e | ||
|   | 03bc8331d7 | ||
|   | 1458b67877 | ||
|   | 8f931242ac | ||
|   | 14420188e6 | ||
|   | 403d1f5541 | ||
|   | bff807b423 | ||
|   | d9099773f1 | ||
|   | e6050ccd19 | ||
|   | 12833443af | ||
|   | 4dd6af1904 | ||
|   | 995db79dcd | ||
|   | e88db424a7 | ||
|   | 636b52fcf0 | ||
|   | 5f6335bd22 | ||
|   | e4c123ef88 | ||
|   | 0716758db7 | ||
|   | f0b6e79d14 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 302cb22ec6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4210addb46 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 06746b4b31 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2f5533a179 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2f267341f8 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 88befee527 | ||
|   | c4a7186cd2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d974f092aa | ||
|   | 23501b9060 | ||
|   | f09965464a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ae5bd2d2fd | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2b73007e7e | ||
|   | 8505fa3e54 | 
							
								
								
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ on: | ||||
|     branches-ignore: | ||||
|       - 'translations**' | ||||
| env: | ||||
|   DEFAULT_UV_VERSION: "0.7.x" | ||||
|   DEFAULT_UV_VERSION: "0.8.x" | ||||
|   # This is the default version of Python to use in most steps which aren't specific | ||||
|   DEFAULT_PYTHON_VERSION: "3.11" | ||||
| jobs: | ||||
|   | ||||
| @@ -32,7 +32,7 @@ RUN set -eux \ | ||||
| # Purpose: Installs s6-overlay and rootfs | ||||
| # Comments: | ||||
| #  - Don't leave anything extra in here either | ||||
| FROM ghcr.io/astral-sh/uv:0.7.19-python3.12-bookworm-slim AS s6-overlay-base | ||||
| FROM ghcr.io/astral-sh/uv:0.8.4-python3.12-bookworm-slim AS s6-overlay-base | ||||
|  | ||||
| WORKDIR /usr/src/s6 | ||||
|  | ||||
|   | ||||
							
								
								
									
										12
									
								
								docs/api.md
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								docs/api.md
									
									
									
									
									
								
							| @@ -282,6 +282,18 @@ The following methods are supported: | ||||
|         -   `"merge": true or false` (defaults to false) | ||||
|     -   The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including | ||||
|         removing them) or be merged with existing permissions. | ||||
| -   `edit_pdf` | ||||
|     -   Requires `parameters`: | ||||
|         -   `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit. | ||||
|         -   `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary | ||||
|             with the following keys: | ||||
|             -   `"page": PAGE_NUMBER` The page number to edit (1-based). | ||||
|             -   `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270). | ||||
|             -   `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations. | ||||
|     -   Optional `parameters`: | ||||
|         -   `"delete_original": true` to delete the original documents after editing. | ||||
|         -   `"update_document": true` to update the existing document with the edited PDF. | ||||
|         -   `"include_metadata": true` to copy metadata from the original document to the edited document. | ||||
| -   `merge` | ||||
|     -   No additional `parameters` required. | ||||
|     -   The ordering of the merged document is determined by the list of IDs. | ||||
|   | ||||
| @@ -573,12 +573,14 @@ The following custom field types are supported: | ||||
|  | ||||
| ## PDF Actions | ||||
|  | ||||
| Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files): | ||||
| Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can | ||||
| open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents. | ||||
|  | ||||
| -   Merging documents: available when selecting multiple documents for 'bulk editing'. | ||||
| -   Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page. | ||||
| -   Splitting documents: available from an individual document's details page. | ||||
| -   Deleting pages: available from an individual document's details page. | ||||
| -   Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page. | ||||
| -   Splitting documents: via the pdf editor on an individual document's details page. | ||||
| -   Deleting pages: via the pdf editor on an individual document's details page. | ||||
| -   Re-arranging pages: via the pdf editor on an individual document's details page. | ||||
|  | ||||
| !!! important | ||||
|  | ||||
|   | ||||
| @@ -99,8 +99,8 @@ testing = [ | ||||
|   "daphne", | ||||
|   "factory-boy~=3.3.1", | ||||
|   "imagehash", | ||||
|   "pytest~=8.3.3", | ||||
|   "pytest-cov~=6.0.0", | ||||
|   "pytest~=8.4.1", | ||||
|   "pytest-cov~=6.2.1", | ||||
|   "pytest-django~=4.10.0", | ||||
|   "pytest-env", | ||||
|   "pytest-httpx", | ||||
| @@ -111,7 +111,7 @@ testing = [ | ||||
| ] | ||||
|  | ||||
| lint = [ | ||||
|   "pre-commit~=4.1.0", | ||||
|   "pre-commit~=4.2.0", | ||||
|   "pre-commit-uv~=4.1.3", | ||||
|   "ruff~=0.12.2", | ||||
| ] | ||||
| @@ -239,6 +239,7 @@ testpaths = [ | ||||
|   "src/paperless_mail/tests/", | ||||
|   "src/paperless_tesseract/tests/", | ||||
|   "src/paperless_tika/tests", | ||||
|   "src/paperless_text/tests/", | ||||
| ] | ||||
| addopts = [ | ||||
|   "--pythonwarnings=all", | ||||
|   | ||||
| @@ -5,14 +5,14 @@ | ||||
|       <trans-unit id="ngb.alert.close" datatype="html"> | ||||
|         <source>Close</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/alert/alert.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/alert/alert.ts</context> | ||||
|           <context context-type="linenumber">50</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.carousel.slide-number" datatype="html"> | ||||
|         <source> Slide <x id="INTERPOLATION" equiv-text="ueryList<NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="linenumber">131,135</context> | ||||
|         </context-group> | ||||
|         <note priority="1" from="description">Currently selected slide number read by screen reader</note> | ||||
| @@ -20,212 +20,212 @@ | ||||
|       <trans-unit id="ngb.carousel.previous" datatype="html"> | ||||
|         <source>Previous</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="linenumber">157,159</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.carousel.next" datatype="html"> | ||||
|         <source>Next</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="linenumber">198</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.previous-month" datatype="html"> | ||||
|         <source>Previous month</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">83,85</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">112</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.next-month" datatype="html"> | ||||
|         <source>Next month</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">112</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">112</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.HH" datatype="html"> | ||||
|         <source>HH</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.toast.close-aria" datatype="html"> | ||||
|         <source>Close</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.select-month" datatype="html"> | ||||
|         <source>Select month</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.first" datatype="html"> | ||||
|         <source>««</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.hours" datatype="html"> | ||||
|         <source>Hours</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.previous" datatype="html"> | ||||
|         <source>«</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.MM" datatype="html"> | ||||
|         <source>MM</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.next" datatype="html"> | ||||
|         <source>»</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.select-year" datatype="html"> | ||||
|         <source>Select year</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.minutes" datatype="html"> | ||||
|         <source>Minutes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.last" datatype="html"> | ||||
|         <source>»»</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.first-aria" datatype="html"> | ||||
|         <source>First</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.increment-hours" datatype="html"> | ||||
|         <source>Increment hours</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.previous-aria" datatype="html"> | ||||
|         <source>Previous</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.decrement-hours" datatype="html"> | ||||
|         <source>Decrement hours</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.next-aria" datatype="html"> | ||||
|         <source>Next</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.increment-minutes" datatype="html"> | ||||
|         <source>Increment minutes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.last-aria" datatype="html"> | ||||
|         <source>Last</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.decrement-minutes" datatype="html"> | ||||
|         <source>Decrement minutes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.SS" datatype="html"> | ||||
|         <source>SS</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.seconds" datatype="html"> | ||||
|         <source>Seconds</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.increment-seconds" datatype="html"> | ||||
|         <source>Increment seconds</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.decrement-seconds" datatype="html"> | ||||
|         <source>Decrement seconds</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.PM" datatype="html"> | ||||
|         <source><x id="INTERPOLATION"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
| @@ -233,7 +233,7 @@ | ||||
|         <source><x id="INTERPOLATION" equiv-text="barConfig); | ||||
| 	pu"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.0.6_@angular+core@20.0.6_@angular+_05316f125479eb303e49e3702b630d0f/node_modules/src/progressbar/progressbar.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.1.4_@angular+core@20.1.4_@angular+_4264661dcfc97b5bf5cf26958990f623/node_modules/src/progressbar/progressbar.ts</context> | ||||
|           <context context-type="linenumber">41,42</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
| @@ -1507,64 +1507,64 @@ | ||||
|         <source>Use system language</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">77</context> | ||||
|           <context context-type="linenumber">78</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7729897675462249787" datatype="html"> | ||||
|         <source>Use date format of display language</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">80</context> | ||||
|           <context context-type="linenumber">81</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1235706724900303689" datatype="html"> | ||||
|         <source>Error retrieving users</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">224</context> | ||||
|           <context context-type="linenumber">225</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">55</context> | ||||
|           <context context-type="linenumber">56</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3066660568529853846" datatype="html"> | ||||
|         <source>Error retrieving groups</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">243</context> | ||||
|           <context context-type="linenumber">244</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">67</context> | ||||
|           <context context-type="linenumber">68</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7217000812750597833" datatype="html"> | ||||
|         <source>Settings were saved successfully.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">546</context> | ||||
|           <context context-type="linenumber">547</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="525012668859298131" datatype="html"> | ||||
|         <source>Settings were saved successfully. Reload is required to apply some changes.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">550</context> | ||||
|           <context context-type="linenumber">551</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8491974984518503778" datatype="html"> | ||||
|         <source>Reload now</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">551</context> | ||||
|           <context context-type="linenumber">552</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3011185103048412841" datatype="html"> | ||||
|         <source>An error occurred while saving settings.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> | ||||
|           <context context-type="linenumber">561</context> | ||||
|           <context context-type="linenumber">562</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
| @@ -2229,11 +2229,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">120</context> | ||||
|           <context context-type="linenumber">123</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">173</context> | ||||
|           <context context-type="linenumber">176</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context> | ||||
| @@ -2497,50 +2497,50 @@ | ||||
|         <source>Password has been changed, you will be logged out momentarily.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">93</context> | ||||
|           <context context-type="linenumber">94</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">194</context> | ||||
|           <context context-type="linenumber">195</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2753185112875184719" datatype="html"> | ||||
|         <source>Saved user "<x id="PH" equiv-text="newUser.username"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">100</context> | ||||
|           <context context-type="linenumber">103</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3471101514724661554" datatype="html"> | ||||
|         <source>Error saving user.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">110</context> | ||||
|           <context context-type="linenumber">113</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5565868288871970148" datatype="html"> | ||||
|         <source>Confirm delete user account</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">118</context> | ||||
|           <context context-type="linenumber">121</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8133663925694885325" datatype="html"> | ||||
|         <source>This operation will permanently delete this user account.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">119</context> | ||||
|           <context context-type="linenumber">122</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1181910457994920507" datatype="html"> | ||||
|         <source>Proceed</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">122</context> | ||||
|           <context context-type="linenumber">125</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">175</context> | ||||
|           <context context-type="linenumber">178</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> | ||||
| @@ -2595,56 +2595,56 @@ | ||||
|         <source>Deleted user "<x id="PH" equiv-text="user.username"/>"</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">128</context> | ||||
|           <context context-type="linenumber">131</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="286457042048584728" datatype="html"> | ||||
|         <source>Error deleting user "<x id="PH" equiv-text="user.username"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">135</context> | ||||
|           <context context-type="linenumber">138</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5766640174051730159" datatype="html"> | ||||
|         <source>Saved group "<x id="PH" equiv-text="newGroup.name"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">155</context> | ||||
|           <context context-type="linenumber">158</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8382042988405122578" datatype="html"> | ||||
|         <source>Error saving group.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">163</context> | ||||
|           <context context-type="linenumber">166</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6538873300613683004" datatype="html"> | ||||
|         <source>Confirm delete user group</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">171</context> | ||||
|           <context context-type="linenumber">174</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7710984639498518244" datatype="html"> | ||||
|         <source>This operation will permanently delete this user group.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">172</context> | ||||
|           <context context-type="linenumber">175</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3756187211130340490" datatype="html"> | ||||
|         <source>Deleted group "<x id="PH" equiv-text="group.name"/>"</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">181</context> | ||||
|           <context context-type="linenumber">184</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1697803415975901060" datatype="html"> | ||||
|         <source>Error deleting group "<x id="PH" equiv-text="group.name"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||
|           <context context-type="linenumber">188</context> | ||||
|           <context context-type="linenumber">191</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7931334600001636863" datatype="html"> | ||||
| @@ -5828,85 +5828,85 @@ | ||||
|         <source>Emails must match</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">142</context> | ||||
|           <context context-type="linenumber">143</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5281933990298241826" datatype="html"> | ||||
|         <source>Passwords must match</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">170</context> | ||||
|           <context context-type="linenumber">171</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4219429959475101385" datatype="html"> | ||||
|         <source>Profile updated successfully</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">191</context> | ||||
|           <context context-type="linenumber">192</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3417726855410304962" datatype="html"> | ||||
|         <source>Error saving profile</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">203</context> | ||||
|           <context context-type="linenumber">206</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="154249228726292516" datatype="html"> | ||||
|         <source>Error generating auth token</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">220</context> | ||||
|           <context context-type="linenumber">223</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4153637646944982460" datatype="html"> | ||||
|         <source>Error disconnecting social account</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">245</context> | ||||
|           <context context-type="linenumber">248</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5939111172212776886" datatype="html"> | ||||
|         <source>Error fetching TOTP settings</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">264</context> | ||||
|           <context context-type="linenumber">267</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1030314492414713260" datatype="html"> | ||||
|         <source>TOTP activated successfully</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">285</context> | ||||
|           <context context-type="linenumber">288</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3755006064892435830" datatype="html"> | ||||
|         <source>Error activating TOTP</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">287</context> | ||||
|           <context context-type="linenumber">290</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">293</context> | ||||
|           <context context-type="linenumber">296</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5919827473541889422" datatype="html"> | ||||
|         <source>TOTP deactivated successfully</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">309</context> | ||||
|           <context context-type="linenumber">312</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6214722303383624015" datatype="html"> | ||||
|         <source>Error deactivating TOTP</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">311</context> | ||||
|           <context context-type="linenumber">314</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">316</context> | ||||
|           <context context-type="linenumber">319</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6617773613987957957" datatype="html"> | ||||
|   | ||||
| @@ -11,17 +11,17 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/cdk": "^20.0.4", | ||||
|     "@angular/common": "~20.0.6", | ||||
|     "@angular/compiler": "~20.0.6", | ||||
|     "@angular/core": "~20.0.6", | ||||
|     "@angular/forms": "~20.0.6", | ||||
|     "@angular/localize": "~20.0.6", | ||||
|     "@angular/platform-browser": "~20.0.6", | ||||
|     "@angular/platform-browser-dynamic": "~20.0.6", | ||||
|     "@angular/router": "~20.0.6", | ||||
|     "@angular/cdk": "^20.1.4", | ||||
|     "@angular/common": "~20.1.4", | ||||
|     "@angular/compiler": "~20.1.4", | ||||
|     "@angular/core": "~20.1.4", | ||||
|     "@angular/forms": "~20.1.4", | ||||
|     "@angular/localize": "~20.1.4", | ||||
|     "@angular/platform-browser": "~20.1.4", | ||||
|     "@angular/platform-browser-dynamic": "~20.1.4", | ||||
|     "@angular/router": "~20.1.4", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^19.0.1", | ||||
|     "@ng-select/ng-select": "^15.1.3", | ||||
|     "@ng-select/ng-select": "^20.0.1", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.3", | ||||
|     "@popperjs/core": "^2.11.8", | ||||
|     "bootstrap": "^5.3.7", | ||||
| @@ -32,7 +32,7 @@ | ||||
|     "ngx-color": "^10.0.0", | ||||
|     "ngx-cookie-service": "^20.0.1", | ||||
|     "ngx-device-detector": "^10.0.2", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^17.0.0", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^17.0.1", | ||||
|     "rxjs": "^7.8.2", | ||||
|     "tslib": "^2.8.1", | ||||
|     "utif": "^3.1.0", | ||||
| @@ -42,33 +42,33 @@ | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/custom-webpack": "^20.0.0", | ||||
|     "@angular-builders/jest": "^20.0.0", | ||||
|     "@angular-devkit/core": "^20.0.4", | ||||
|     "@angular-devkit/schematics": "^20.0.4", | ||||
|     "@angular-devkit/core": "^20.1.4", | ||||
|     "@angular-devkit/schematics": "^20.1.4", | ||||
|     "@angular-eslint/builder": "20.1.1", | ||||
|     "@angular-eslint/eslint-plugin": "20.1.1", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.1.1", | ||||
|     "@angular-eslint/schematics": "20.1.1", | ||||
|     "@angular-eslint/template-parser": "20.1.1", | ||||
|     "@angular/build": "^20.0.4", | ||||
|     "@angular/cli": "~20.0.4", | ||||
|     "@angular/compiler-cli": "~20.0.6", | ||||
|     "@angular/build": "^20.1.4", | ||||
|     "@angular/cli": "~20.1.4", | ||||
|     "@angular/compiler-cli": "~20.1.4", | ||||
|     "@codecov/webpack-plugin": "^1.9.1", | ||||
|     "@playwright/test": "^1.53.2", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/node": "^24.0.10", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.35.1", | ||||
|     "@typescript-eslint/parser": "^8.35.1", | ||||
|     "@typescript-eslint/utils": "^8.35.1", | ||||
|     "eslint": "^9.30.1", | ||||
|     "jest": "29.7.0", | ||||
|     "jest-environment-jsdom": "^29.7.0", | ||||
|     "@playwright/test": "^1.54.2", | ||||
|     "@types/jest": "^30.0.0", | ||||
|     "@types/node": "^24.1.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.38.0", | ||||
|     "@typescript-eslint/parser": "^8.38.0", | ||||
|     "@typescript-eslint/utils": "^8.38.0", | ||||
|     "eslint": "^9.32.0", | ||||
|     "jest": "30.0.5", | ||||
|     "jest-environment-jsdom": "^30.0.5", | ||||
|     "jest-junit": "^16.0.0", | ||||
|     "jest-preset-angular": "^14.5.5", | ||||
|     "jest-preset-angular": "^15.0.0", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "prettier-plugin-organize-imports": "^4.1.0", | ||||
|     "prettier-plugin-organize-imports": "^4.2.0", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "typescript": "^5.8.3", | ||||
|     "webpack": "^5.99.9" | ||||
|     "webpack": "^5.101.0" | ||||
|   }, | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|   | ||||
							
								
								
									
										5202
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5202
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,12 +1,16 @@ | ||||
| import '@angular/localize/init' | ||||
| import { jest } from '@jest/globals' | ||||
| import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone' | ||||
| import { TextDecoder, TextEncoder } from 'util' | ||||
| import { TextDecoder, TextEncoder } from 'node:util' | ||||
| if (process.env.NODE_ENV === 'test') { | ||||
|   setupZoneTestEnv() | ||||
| } | ||||
| global.TextEncoder = TextEncoder | ||||
| global.TextDecoder = TextDecoder | ||||
| ;(globalThis as any).TextEncoder = TextEncoder as unknown as { | ||||
|   new (): TextEncoder | ||||
| } | ||||
| ;(globalThis as any).TextDecoder = TextDecoder as unknown as { | ||||
|   new (): TextDecoder | ||||
| } | ||||
|  | ||||
| import { registerLocaleData } from '@angular/common' | ||||
| import localeAf from '@angular/common/locales/af' | ||||
| @@ -116,10 +120,26 @@ if (!URL.revokeObjectURL) { | ||||
|   Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() }) | ||||
| } | ||||
| Object.defineProperty(window, 'ResizeObserver', { value: mock() }) | ||||
| Object.defineProperty(window, 'location', { | ||||
|   configurable: true, | ||||
|   value: { reload: jest.fn() }, | ||||
| }) | ||||
|  | ||||
| if (typeof IntersectionObserver === 'undefined') { | ||||
|   class MockIntersectionObserver { | ||||
|     constructor( | ||||
|       public callback: IntersectionObserverCallback, | ||||
|       public options?: IntersectionObserverInit | ||||
|     ) {} | ||||
|  | ||||
|     observe = jest.fn() | ||||
|     unobserve = jest.fn() | ||||
|     disconnect = jest.fn() | ||||
|     takeRecords = jest.fn() | ||||
|   } | ||||
|  | ||||
|   Object.defineProperty(window, 'IntersectionObserver', { | ||||
|     writable: true, | ||||
|     configurable: true, | ||||
|     value: MockIntersectionObserver, | ||||
|   }) | ||||
| } | ||||
|  | ||||
| HTMLCanvasElement.prototype.getContext = < | ||||
|   typeof HTMLCanvasElement.prototype.getContext | ||||
|   | ||||
| @@ -36,6 +36,7 @@ import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service' | ||||
| import * as navUtils from 'src/app/utils/navigation' | ||||
| import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { CheckComponent } from '../../common/input/check/check.component' | ||||
| @@ -225,6 +226,9 @@ describe('SettingsComponent', () => { | ||||
|   }) | ||||
|  | ||||
|   it('should offer reload if settings changes require', () => { | ||||
|     const reloadSpy = jest | ||||
|       .spyOn(navUtils, 'locationReload') | ||||
|       .mockImplementation(() => {}) | ||||
|     completeSetup() | ||||
|     let toast: Toast | ||||
|     toastService.getToasts().subscribe((t) => (toast = t[0])) | ||||
| @@ -241,6 +245,7 @@ describe('SettingsComponent', () => { | ||||
|  | ||||
|     expect(toast.actionName).toEqual('Reload now') | ||||
|     toast.action() | ||||
|     expect(reloadSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should allow setting theme color, visually apply change immediately but not save', () => { | ||||
| @@ -269,7 +274,7 @@ describe('SettingsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(userService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load groups failure', () => { | ||||
| @@ -281,7 +286,7 @@ describe('SettingsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(groupService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should load system status on initialize, show errors if needed', () => { | ||||
|   | ||||
| @@ -57,6 +57,7 @@ import { | ||||
| } from 'src/app/services/settings.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service' | ||||
| import { locationReload } from 'src/app/utils/navigation' | ||||
| import { CheckComponent } from '../../common/input/check/check.component' | ||||
| import { ColorComponent } from '../../common/input/color/color.component' | ||||
| import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component' | ||||
| @@ -550,7 +551,7 @@ export class SettingsComponent | ||||
|             savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` | ||||
|             savedToast.actionName = $localize`Reload now` | ||||
|             savedToast.action = () => { | ||||
|               location.reload() | ||||
|               locationReload() | ||||
|             } | ||||
|           } | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import * as navUtils from 'src/app/utils/navigation' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' | ||||
| @@ -107,7 +108,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     settingsService.currentUser = users[1] // simulate logged in as different user | ||||
|     editDialog.succeeded.emit(users[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
| @@ -130,7 +131,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       throwError(() => new Error('error deleting user')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
| @@ -142,19 +143,18 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.editUser(users[0]) | ||||
|     const navSpy = jest | ||||
|       .spyOn(navUtils, 'setLocationHref') | ||||
|       .mockImplementation(() => {}) | ||||
|     const editDialog = modal.componentInstance as UserEditDialogComponent | ||||
|     editDialog.passwordIsSet = true | ||||
|     settingsService.currentUser = users[0] // simulate logged in as same user | ||||
|     editDialog.succeeded.emit(users[0]) | ||||
|     fixture.detectChanges() | ||||
|     Object.defineProperty(window, 'location', { | ||||
|       value: { | ||||
|         href: 'http://localhost/', | ||||
|       }, | ||||
|       writable: true, // possibility to override | ||||
|     }) | ||||
|     tick(2600) | ||||
|     expect(window.location.href).toContain('logout') | ||||
|     expect(navSpy).toHaveBeenCalledWith( | ||||
|       `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|     ) | ||||
|   })) | ||||
|  | ||||
|   it('should support edit / create group, show error if needed', () => { | ||||
| @@ -166,7 +166,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     editDialog.succeeded.emit(groups[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved group "${groups[0].name}".` | ||||
| @@ -188,7 +188,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       throwError(() => new Error('error deleting group')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
| @@ -210,7 +210,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(userService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load groups failure', () => { | ||||
| @@ -222,6 +222,6 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(groupService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { setLocationHref } from 'src/app/utils/navigation' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' | ||||
| import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| @@ -93,7 +94,9 @@ export class UsersAndGroupsComponent | ||||
|             $localize`Password has been changed, you will be logged out momentarily.` | ||||
|           ) | ||||
|           setTimeout(() => { | ||||
|             window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|             setLocationHref( | ||||
|               `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|             ) | ||||
|           }, 2500) | ||||
|         } else { | ||||
|           this.toastService.showInfo( | ||||
|   | ||||
| @@ -1,54 +0,0 @@ | ||||
| <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|     <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||
|     </button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <div class="row"> | ||||
|         <div class="col"> | ||||
|             <div class="btn-toolbar flex-nowrap"> | ||||
|                 <div class="input-group input-group-sm"> | ||||
|                     <div class="input-group-text" i18n>Page</div> | ||||
|                     <input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" /> | ||||
|                     <div class="input-group-text" i18n>of {{totalPages}}</div> | ||||
|                 </div> | ||||
|                 <div class="input-group input-group-sm ms-auto"> | ||||
|                     <span class="input-group-text" i18n>Pages to remove</span> | ||||
|                     <input [ngModel]="pagesString" class="form-control" disabled /> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="pdf-viewer-container w-100 mt-3"> | ||||
|                 <pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage" | ||||
|                 [original-size]="false" | ||||
|                 [zoom]="1" | ||||
|                 zoom-scale="page-fit" | ||||
|                 [render-text]="false" | ||||
|                 (pagerendered)="pageRendered($event)" | ||||
|                 (after-load-complete)="pdfPreviewLoaded($event)"> | ||||
|                 </pdf-viewer> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer flex-nowrap"> | ||||
|     <div> | ||||
|         @if (message) { | ||||
|             <p [innerHTML]="message | safeHtml"></p> | ||||
|         } | ||||
|         @if (messageBold) { | ||||
|             <p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p> | ||||
|         } | ||||
|     </div> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|             <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> | ||||
|         </button> | ||||
|     <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|     </button> | ||||
| </div> | ||||
|  | ||||
| <ng-template #pageCheckOverlay let-page="page" let-pages="pages"> | ||||
|     <div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)"> | ||||
|         <input type="checkbox" class="form-check-input" /> | ||||
|     </div> | ||||
| </ng-template> | ||||
| @@ -1,28 +0,0 @@ | ||||
| .pdf-viewer-container { | ||||
|   background-color: gray; | ||||
|   height: 550px; | ||||
|  | ||||
|   pdf-viewer { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .mw-60 { | ||||
|   max-width: 60px; | ||||
| } | ||||
|  | ||||
| div.position-absolute:has(.form-check-input:checked) { | ||||
|   background-color: rgba(var(--bs-dark-rgb), 0.4); | ||||
| } | ||||
|  | ||||
| .form-check-input { | ||||
|   &:checked { | ||||
|     background-color: var(--bs-danger); | ||||
|     border-color: var(--bs-danger); | ||||
|   } | ||||
|   &:focus { | ||||
|     box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha)); | ||||
|     border-color: var(--bs-danger); | ||||
|   } | ||||
| } | ||||
| @@ -1,60 +0,0 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component' | ||||
|  | ||||
| describe('DeletePagesConfirmDialogComponent', () => { | ||||
|   let component: DeletePagesConfirmDialogComponent | ||||
|   let fixture: ComponentFixture<DeletePagesConfirmDialogComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       declarations: [], | ||||
|       imports: [ | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         DeletePagesConfirmDialogComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         NgbActiveModal, | ||||
|         SafeHtmlPipe, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|     fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should return a string with comma-separated pages', () => { | ||||
|     component.pages = [1, 2, 3, 4] | ||||
|     expect(component.pagesString).toEqual('1, 2, 3, 4') | ||||
|   }) | ||||
|  | ||||
|   it('should update totalPages when pdf is loaded', () => { | ||||
|     component.pdfPreviewLoaded({ numPages: 5 } as any) | ||||
|     expect(component.totalPages).toEqual(5) | ||||
|   }) | ||||
|  | ||||
|   it('should update checks when page is rendered', () => { | ||||
|     const event = { | ||||
|       target: document.createElement('div'), | ||||
|       detail: { pageNumber: 1 }, | ||||
|     } as any | ||||
|     component.pageRendered(event) | ||||
|     expect(component['checks'].length).toEqual(1) | ||||
|   }) | ||||
|  | ||||
|   it('should update pages when page check is changed', () => { | ||||
|     component.pageCheckChanged(1) | ||||
|     expect(component.pages).toEqual([1]) | ||||
|     component.pageCheckChanged(1) | ||||
|     expect(component.pages).toEqual([]) | ||||
|   }) | ||||
| }) | ||||
| @@ -1,69 +0,0 @@ | ||||
| import { Component, TemplateRef, ViewChild, inject } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { | ||||
|   PDFDocumentProxy, | ||||
|   PdfViewerComponent, | ||||
|   PdfViewerModule, | ||||
| } from 'ng2-pdf-viewer' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-delete-pages-confirm-dialog', | ||||
|   templateUrl: './delete-pages-confirm-dialog.component.html', | ||||
|   styleUrl: './delete-pages-confirm-dialog.component.scss', | ||||
|   imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe], | ||||
| }) | ||||
| export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent { | ||||
|   private documentService = inject(DocumentService) | ||||
|  | ||||
|   public documentID: number | ||||
|   public pages: number[] = [] | ||||
|   public currentPage: number = 1 | ||||
|   public totalPages: number | ||||
|  | ||||
|   @ViewChild('pdfViewer') pdfViewer: PdfViewerComponent | ||||
|   @ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any> | ||||
|   private checks: HTMLElement[] = [] | ||||
|  | ||||
|   public get pagesString(): string { | ||||
|     return this.pages.join(', ') | ||||
|   } | ||||
|  | ||||
|   public get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   public pdfPreviewLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|   } | ||||
|  | ||||
|   pageRendered(event: CustomEvent) { | ||||
|     const pageDiv = event.target as HTMLDivElement | ||||
|     const check = this.pageCheckOverlay.createEmbeddedView({ | ||||
|       page: event.detail.pageNumber, | ||||
|     }) | ||||
|     this.checks[event.detail.pageNumber - 1] = check.rootNodes[0] | ||||
|     pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild) | ||||
|     this.updateChecks() | ||||
|   } | ||||
|  | ||||
|   pageCheckChanged(pageNumber: number) { | ||||
|     if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber) | ||||
|     else if (this.pages.includes(pageNumber)) | ||||
|       this.pages.splice(this.pages.indexOf(pageNumber), 1) | ||||
|     this.updateChecks() | ||||
|   } | ||||
|  | ||||
|   private updateChecks() { | ||||
|     this.checks.forEach((check, i) => { | ||||
|       const input = check.getElementsByTagName('input')[0] | ||||
|       input.checked = this.pages.includes(i + 1) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -1,59 +0,0 @@ | ||||
| <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|     <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||
|     </button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <p>{{message}}</p> | ||||
|     <div class="row mb-2"> | ||||
|         <div class="col-7"> | ||||
|             <div class="input-group input-group-sm"> | ||||
|                 <div class="input-group-text" i18n>Page</div> | ||||
|                 <input class="form-control" type="number" min="1" [(ngModel)]="page" /> | ||||
|                 <div class="input-group-text" i18n>of {{totalPages}}</div> | ||||
|             </div> | ||||
|             <div class="pdf-viewer-container w-100 mt-3"> | ||||
|                 <pdf-viewer [src]="pdfSrc" [(page)]="page" | ||||
|                 [original-size]="false" | ||||
|                 [zoom]="1" | ||||
|                 zoom-scale="page-fit" | ||||
|                 (after-load-complete)="pdfPreviewLoaded($event)"> | ||||
|                 </pdf-viewer> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="col-5"> | ||||
|             <div class="d-grid"> | ||||
|                 <button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit"> | ||||
|                     <i-bs name="plus-circle"></i-bs>  | ||||
|                     <span i18n>Add Split</span> | ||||
|                 </button> | ||||
|             </div> | ||||
|  | ||||
|             <ul class="list-group mt-3"> | ||||
|                 @for (pageStr of pagesString.split(','); track pageStr; let i = $index) { | ||||
|                     <li class="list-group-item d-flex align-items-center"> | ||||
|                         {{pageStr}} | ||||
|                         @if (pagesString.split(',').length > 1) { | ||||
|                               | ||||
|                             <button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)"> | ||||
|                                 <i-bs name="trash"></i-bs> | ||||
|                             </button> | ||||
|                         } | ||||
|                     </li> | ||||
|                 } | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|     <div class="form-check form-switch me-auto"> | ||||
|        <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument"> | ||||
|        <label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label> | ||||
|      </div> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|             <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> | ||||
|         </button> | ||||
|     <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|     </button> | ||||
| </div> | ||||
| @@ -1,9 +0,0 @@ | ||||
| .pdf-viewer-container { | ||||
|     background-color: gray; | ||||
|     height: 500px; | ||||
|  | ||||
|     pdf-viewer { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|     } | ||||
|   } | ||||
| @@ -1,107 +0,0 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
|  | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of } from 'rxjs' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SplitConfirmDialogComponent } from './split-confirm-dialog.component' | ||||
|  | ||||
| describe('SplitConfirmDialogComponent', () => { | ||||
|   let component: SplitConfirmDialogComponent | ||||
|   let fixture: ComponentFixture<SplitConfirmDialogComponent> | ||||
|   let documentService: DocumentService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         ReactiveFormsModule, | ||||
|         FormsModule, | ||||
|         PdfViewerModule, | ||||
|         SplitConfirmDialogComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         NgbActiveModal, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(SplitConfirmDialogComponent) | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should load document on init', () => { | ||||
|     const getSpy = jest.spyOn(documentService, 'get') | ||||
|     component.documentID = 1 | ||||
|     getSpy.mockReturnValue(of({ id: 1 } as any)) | ||||
|     component.ngOnInit() | ||||
|     expect(documentService.get).toHaveBeenCalledWith(1) | ||||
|   }) | ||||
|  | ||||
|   it('should update pagesString when pages are added', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-5') | ||||
|     component.page = 4 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-4,5') | ||||
|   }) | ||||
|  | ||||
|   it('should update pagesString when pages are removed', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     component.page = 4 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-4,5') | ||||
|     component.removeSplit(0) | ||||
|     expect(component.pagesString).toEqual('1-4,5') | ||||
|   }) | ||||
|  | ||||
|   it('should enable confirm button when pages are added', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     expect(component.confirmButtonEnabled).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should disable confirm button when all pages are removed', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     component.removeSplit(0) | ||||
|     expect(component.confirmButtonEnabled).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should not add split if page is the last page', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 5 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-5') | ||||
|   }) | ||||
|  | ||||
|   it('should update totalPages when pdf is loaded', () => { | ||||
|     component.pdfPreviewLoaded({ numPages: 5 } as any) | ||||
|     expect(component.totalPages).toEqual(5) | ||||
|   }) | ||||
|  | ||||
|   it('should correctly disable split button', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 1 | ||||
|     expect(component.canSplit).toBeTruthy() | ||||
|     component.page = 5 | ||||
|     expect(component.canSplit).toBeFalsy() | ||||
|     component.page = 4 | ||||
|     expect(component.canSplit).toBeTruthy() | ||||
|     component['pages'] = new Set([1, 2, 3, 4]) | ||||
|     expect(component.canSplit).toBeFalsy() | ||||
|   }) | ||||
| }) | ||||
| @@ -1,98 +0,0 @@ | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-split-confirm-dialog', | ||||
|   templateUrl: './split-confirm-dialog.component.html', | ||||
|   styleUrl: './split-confirm-dialog.component.scss', | ||||
|   imports: [ | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     PdfViewerModule, | ||||
|   ], | ||||
| }) | ||||
| export class SplitConfirmDialogComponent | ||||
|   extends ConfirmDialogComponent | ||||
|   implements OnInit | ||||
| { | ||||
|   private documentService = inject(DocumentService) | ||||
|   private permissionService = inject(PermissionsService) | ||||
|  | ||||
|   public get pagesString(): string { | ||||
|     let pagesStr = '' | ||||
|  | ||||
|     let lastPage = 1 | ||||
|     for (let i = 1; i <= this.totalPages; i++) { | ||||
|       if (this.pages.has(i) || i === this.totalPages) { | ||||
|         if (lastPage === i) { | ||||
|           pagesStr += `${i},` | ||||
|           lastPage = Math.min(i + 1, this.totalPages) | ||||
|         } else { | ||||
|           pagesStr += `${lastPage}-${i},` | ||||
|           lastPage = Math.min(i + 1, this.totalPages) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return pagesStr.replace(/,$/, '') | ||||
|   } | ||||
|  | ||||
|   private pages: Set<number> = new Set() | ||||
|  | ||||
|   public documentID: number | ||||
|   private document: Document | ||||
|   public page: number = 1 | ||||
|   public totalPages: number | ||||
|   public deleteOriginal: boolean = false | ||||
|  | ||||
|   public get canSplit(): boolean { | ||||
|     return ( | ||||
|       this.page < this.totalPages && | ||||
|       this.pages.size < this.totalPages - 1 && | ||||
|       !this.pages.has(this.page) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.documentService.get(this.documentID).subscribe((r) => { | ||||
|       this.document = r | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   pdfPreviewLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|   } | ||||
|  | ||||
|   addSplit() { | ||||
|     if (this.page === this.totalPages) return | ||||
|     this.pages.add(this.page) | ||||
|     this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b)) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   removeSplit(i: number) { | ||||
|     let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)] | ||||
|     this.pages.delete(page) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   get userOwnsDocument(): boolean { | ||||
|     return this.permissionService.currentUserOwnsObject(this.document) | ||||
|   } | ||||
| } | ||||
| @@ -30,7 +30,7 @@ | ||||
|       } | ||||
|       <div class="list-group-item"> | ||||
|         <div class="input-group input-group-sm"> | ||||
|           <input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> | ||||
|           <input class="form-control" type="text" spellcheck="false" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> | ||||
|         </div> | ||||
|       </div> | ||||
|       @if (selectionModel.items) { | ||||
|   | ||||
| @@ -0,0 +1,103 @@ | ||||
| <pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer> | ||||
| <div class="modal-header"> | ||||
|   <h4 class="modal-title">{{ title }}</h4> | ||||
|   <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|   <div class="btn-toolbar mb-2"> | ||||
|     <div class="btn-group me-3"> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title> | ||||
|         <i-bs name="check-all"></i-bs> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title> | ||||
|         <i-bs name="x"></i-bs> | ||||
|       </button> | ||||
|     </div> | ||||
|     <div class="btn-group"> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title> | ||||
|         <i-bs name="arrow-counterclockwise"></i-bs> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title> | ||||
|         <i-bs name="arrow-clockwise"></i-bs> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title> | ||||
|         <i-bs name="trash"></i-bs> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-5"> | ||||
|     @for (p of pages; track p.page; let i = $index) { | ||||
|       <div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected"> | ||||
|         <div class="btn-toolbar hover-actions z-10"> | ||||
|           <div class="btn-group me-2"> | ||||
|             <button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title> | ||||
|               <i-bs name="arrow-counterclockwise"></i-bs> | ||||
|             </button> | ||||
|             <button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title> | ||||
|               <i-bs name="arrow-clockwise"></i-bs> | ||||
|             </button> | ||||
|           </div> | ||||
|           <div class="btn-group"> | ||||
|             <button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title> | ||||
|               <i-bs name="trash"></i-bs> | ||||
|             </button> | ||||
|             <button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title> | ||||
|               <i-bs name="scissors"></i-bs> | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="border-end border-bottom bg-light py-1 px-2 document-check z-10"> | ||||
|           <div class="form-check"> | ||||
|             <input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()"> | ||||
|             <label class="form-check-label" for="page{{i}}"></label> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="pdf-viewer-container w-100" [class.selected]="p.selected"> | ||||
|           @defer (on viewport) { | ||||
|             @if (!p.loaded) { | ||||
|               <div class="placeholder-glow w-100 h-100 z-10"> | ||||
|                 <span class="placeholder w-100 h-100"></span> | ||||
|               </div> | ||||
|             } | ||||
|             <pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer> | ||||
|           } @placeholder { | ||||
|             <div class="placeholder-glow w-100 h-100 z-10"> | ||||
|               <span class="placeholder w-100 h-100"></span> | ||||
|             </div> | ||||
|           } | ||||
|         </div> | ||||
|         @if (p.splitAfter) { | ||||
|           <div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">— <span i18n>Split here</span> —</div> | ||||
|         } | ||||
|       </div> | ||||
|     } | ||||
|   </div> | ||||
| </div> | ||||
| <div class="modal-footer flex-column"> | ||||
|   <div class="d-flex w-100 justify-content-between align-items-center"> | ||||
|     <div class="btn-group" role="group"> | ||||
|       <input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode"> | ||||
|       <label for="editModeCreate" class="btn btn-outline-primary btn-sm"> | ||||
|         <i-bs name="plus"></i-bs> | ||||
|         <span class="form-check-label ms-1" i18n>Create new document(s)</span> | ||||
|       </label> | ||||
|       <input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()"> | ||||
|       <label for="editModeUpdate" class="btn btn-outline-primary btn-sm"> | ||||
|         <i-bs name="pencil"></i-bs> | ||||
|         <span class="form-check-label ms-2" i18n>Update existing document</span> | ||||
|       </label> | ||||
|     </div> | ||||
|     @if (editMode === PdfEditorEditMode.Create) { | ||||
|       <div class="form-check ms-3"> | ||||
|         <input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata"> | ||||
|         <label class="form-check-label" for="copyMeta" i18n>Copy metadata</label> | ||||
|       </div> | ||||
|       <div class="form-check ms-3"> | ||||
|         <input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal"> | ||||
|         <label class="form-check-label" for="deleteOriginal" i18n>Delete original</label> | ||||
|       </div> | ||||
|     } | ||||
|     <button type="button" class="btn ms-auto me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button> | ||||
|     <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -0,0 +1,70 @@ | ||||
|  | ||||
|  | ||||
| .page-item { | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   border: 1px solid transparent; | ||||
|   background-origin: border-box; | ||||
|  | ||||
|   &.selected { | ||||
|     background-color: var(--pngx-primary-darken-5); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .pdf-viewer-container { | ||||
|   background-color: gray; | ||||
|   height: 240px; | ||||
|  | ||||
|   pdf-viewer { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| ::ng-deep .ng2-pdf-viewer-container { | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .hover-actions { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .page-item:hover .hover-actions { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .document-check { | ||||
|   display: none; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   padding: 0.5rem; | ||||
|   border-top-left-radius: 0.25rem; | ||||
|   border-bottom-right-radius: 0.25rem; | ||||
|   pointer-events: none; | ||||
|  | ||||
|   .form-check { | ||||
|     padding: 0; | ||||
|     min-height: 0; | ||||
|     margin-bottom: 0; | ||||
|  | ||||
|     .form-check-input { | ||||
|       margin-left: 0; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .page-item:hover .document-check, .selected .document-check { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .z-10 { | ||||
|     z-index: 10; | ||||
| } | ||||
|  | ||||
| .split-after { | ||||
|   writing-mode: vertical-rl; | ||||
| } | ||||
| @@ -0,0 +1,142 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { PDFEditorComponent } from './pdf-editor.component' | ||||
|  | ||||
| describe('PDFEditorComponent', () => { | ||||
|   let component: PDFEditorComponent | ||||
|   let fixture: ComponentFixture<PDFEditorComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|         { provide: NgbActiveModal, useValue: {} }, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|     fixture = TestBed.createComponent(PDFEditorComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should return correct operations with no changes', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false }, | ||||
|       { page: 3, rotate: 0, splitAfter: false }, | ||||
|     ] | ||||
|     const ops = component.getOperations() | ||||
|     expect(ops).toEqual([ | ||||
|       { page: 1, rotate: 0, doc: 0 }, | ||||
|       { page: 2, rotate: 0, doc: 0 }, | ||||
|       { page: 3, rotate: 0, doc: 0 }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should rotate, delete and reorder pages', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false, selected: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false, selected: false }, | ||||
|     ] | ||||
|     component.toggleSelection(0) | ||||
|     component.rotateSelected(90) | ||||
|     expect(component.pages[0].rotate).toBe(90) | ||||
|     component.toggleSelection(0) // deselect | ||||
|     component.toggleSelection(1) | ||||
|     component.deleteSelected() | ||||
|     expect(component.pages.length).toBe(1) | ||||
|     component.pages.push({ page: 2, rotate: 0, splitAfter: false }) | ||||
|     component.drop({ previousIndex: 0, currentIndex: 1 } as any) | ||||
|     expect(component.pages[0].page).toBe(2) | ||||
|     component.rotate(0) | ||||
|     expect(component.pages[0].rotate).toBe(90) | ||||
|   }) | ||||
|  | ||||
|   it('should handle empty pages array', () => { | ||||
|     component.pages = [] | ||||
|     expect(component.getOperations()).toEqual([]) | ||||
|   }) | ||||
|  | ||||
|   it('should increment doc index after splitAfter', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: true }, | ||||
|       { page: 2, rotate: 0, splitAfter: false }, | ||||
|       { page: 3, rotate: 0, splitAfter: true }, | ||||
|       { page: 4, rotate: 0, splitAfter: false }, | ||||
|     ] | ||||
|     const ops = component.getOperations() | ||||
|     expect(ops).toEqual([ | ||||
|       { page: 1, rotate: 0, doc: 0 }, | ||||
|       { page: 2, rotate: 0, doc: 1 }, | ||||
|       { page: 3, rotate: 0, doc: 1 }, | ||||
|       { page: 4, rotate: 0, doc: 2 }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should include rotations in operations', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 90, splitAfter: false }, | ||||
|       { page: 2, rotate: 180, splitAfter: true }, | ||||
|       { page: 3, rotate: 270, splitAfter: false }, | ||||
|     ] | ||||
|     const ops = component.getOperations() | ||||
|     expect(ops).toEqual([ | ||||
|       { page: 1, rotate: 90, doc: 0 }, | ||||
|       { page: 2, rotate: 180, doc: 0 }, | ||||
|       { page: 3, rotate: 270, doc: 1 }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should handle remove operation', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false, selected: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false, selected: true }, | ||||
|       { page: 3, rotate: 0, splitAfter: false, selected: false }, | ||||
|     ] | ||||
|     component.remove(1) // remove page 2 | ||||
|     expect(component.pages.length).toBe(2) | ||||
|     expect(component.pages[0].page).toBe(1) | ||||
|     expect(component.pages[1].page).toBe(3) | ||||
|   }) | ||||
|  | ||||
|   it('should toggle splitAfter correctly', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false }, | ||||
|     ] | ||||
|     component.toggleSplit(0) | ||||
|     expect(component.pages[0].splitAfter).toBeTruthy() | ||||
|     component.toggleSplit(1) | ||||
|     expect(component.pages[1].splitAfter).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should select and deselect all pages', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false, selected: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false, selected: false }, | ||||
|     ] | ||||
|     component.selectAll() | ||||
|     expect(component.pages.every((p) => p.selected)).toBeTruthy() | ||||
|     expect(component.hasSelection()).toBeTruthy() | ||||
|     component.deselectAll() | ||||
|     expect(component.pages.every((p) => !p.selected)).toBeTruthy() | ||||
|     expect(component.hasSelection()).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should handle pdf loading and page generation', () => { | ||||
|     const mockPdf = { | ||||
|       numPages: 3, | ||||
|       getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }), | ||||
|     } | ||||
|     component.pdfLoaded(mockPdf as any) | ||||
|     expect(component.totalPages).toBe(3) | ||||
|     expect(component.pages.length).toBe(3) | ||||
|     expect(component.pages[0].page).toBe(1) | ||||
|     expect(component.pages[1].page).toBe(2) | ||||
|     expect(component.pages[2].page).toBe(3) | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,133 @@ | ||||
| import { | ||||
|   CdkDragDrop, | ||||
|   DragDropModule, | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { FormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' | ||||
|  | ||||
| interface PageOperation { | ||||
|   page: number | ||||
|   rotate: number | ||||
|   splitAfter: boolean | ||||
|   selected?: boolean | ||||
|   loaded?: boolean | ||||
| } | ||||
|  | ||||
| export enum PdfEditorEditMode { | ||||
|   Update = 'update', | ||||
|   Create = 'create', | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-pdf-editor', | ||||
|   templateUrl: './pdf-editor.component.html', | ||||
|   styleUrl: './pdf-editor.component.scss', | ||||
|   imports: [ | ||||
|     DragDropModule, | ||||
|     FormsModule, | ||||
|     PdfViewerModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
| }) | ||||
| export class PDFEditorComponent extends ConfirmDialogComponent { | ||||
|   public PdfEditorEditMode = PdfEditorEditMode | ||||
|  | ||||
|   private documentService = inject(DocumentService) | ||||
|   activeModal: NgbActiveModal = inject(NgbActiveModal) | ||||
|  | ||||
|   documentID: number | ||||
|   pages: PageOperation[] = [] | ||||
|   totalPages = 0 | ||||
|   editMode: PdfEditorEditMode = PdfEditorEditMode.Create | ||||
|   deleteOriginal: boolean = false | ||||
|   includeMetadata: boolean = true | ||||
|  | ||||
|   get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   pdfLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|     this.pages = Array.from({ length: this.totalPages }, (_, i) => ({ | ||||
|       page: i + 1, | ||||
|       rotate: 0, | ||||
|       splitAfter: false, | ||||
|       selected: false, | ||||
|       loaded: false, | ||||
|     })) | ||||
|   } | ||||
|  | ||||
|   toggleSelection(i: number) { | ||||
|     this.pages[i].selected = !this.pages[i].selected | ||||
|   } | ||||
|  | ||||
|   rotate(i: number) { | ||||
|     this.pages[i].rotate = (this.pages[i].rotate + 90) % 360 | ||||
|   } | ||||
|  | ||||
|   rotateSelected(dir: number) { | ||||
|     for (let p of this.pages) { | ||||
|       if (p.selected) { | ||||
|         p.rotate = (p.rotate + dir + 360) % 360 | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   remove(i: number) { | ||||
|     this.pages.splice(i, 1) | ||||
|   } | ||||
|  | ||||
|   toggleSplit(i: number) { | ||||
|     this.pages[i].splitAfter = !this.pages[i].splitAfter | ||||
|     if (this.pages[i].splitAfter) { | ||||
|       // force create mode | ||||
|       this.editMode = PdfEditorEditMode.Create | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   selectAll() { | ||||
|     this.pages.forEach((p) => (p.selected = true)) | ||||
|   } | ||||
|  | ||||
|   deselectAll() { | ||||
|     this.pages.forEach((p) => (p.selected = false)) | ||||
|   } | ||||
|  | ||||
|   deleteSelected() { | ||||
|     this.pages = this.pages.filter((p) => !p.selected) | ||||
|   } | ||||
|  | ||||
|   hasSelection(): boolean { | ||||
|     return this.pages.some((p) => p.selected) | ||||
|   } | ||||
|  | ||||
|   hasSplit(): boolean { | ||||
|     return this.pages.some((p) => p.splitAfter) | ||||
|   } | ||||
|  | ||||
|   drop(event: CdkDragDrop<PageOperation[]>) { | ||||
|     moveItemInArray(this.pages, event.previousIndex, event.currentIndex) | ||||
|   } | ||||
|  | ||||
|   getOperations() { | ||||
|     return this.pages.map((p, idx) => ({ | ||||
|       page: p.page, | ||||
|       rotate: p.rotate, | ||||
|       doc: this.computeDocIndex(idx), | ||||
|     })) | ||||
|   } | ||||
|  | ||||
|   private computeDocIndex(index: number): number { | ||||
|     let docIndex = 0 | ||||
|     for (let i = 0; i <= index; i++) { | ||||
|       if (this.pages[i].splitAfter && i < index) docIndex++ | ||||
|     } | ||||
|     return docIndex | ||||
|   } | ||||
| } | ||||
| @@ -18,6 +18,7 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { ProfileService } from 'src/app/services/profile.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import * as navUtils from 'src/app/utils/navigation' | ||||
| import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component' | ||||
| import { PasswordComponent } from '../input/password/password.component' | ||||
| import { TextComponent } from '../input/text/text.component' | ||||
| @@ -205,16 +206,15 @@ describe('ProfileEditDialogComponent', () => { | ||||
|  | ||||
|     const updateSpy = jest.spyOn(profileService, 'update') | ||||
|     updateSpy.mockReturnValue(of(null)) | ||||
|     Object.defineProperty(window, 'location', { | ||||
|       value: { | ||||
|         href: 'http://localhost/', | ||||
|       }, | ||||
|       writable: true, // possibility to override | ||||
|     }) | ||||
|     const navSpy = jest | ||||
|       .spyOn(navUtils, 'setLocationHref') | ||||
|       .mockImplementation(() => {}) | ||||
|     component.save() | ||||
|     expect(updateSpy).toHaveBeenCalled() | ||||
|     tick(2600) | ||||
|     expect(window.location.href).toContain('logout') | ||||
|     expect(navSpy).toHaveBeenCalledWith( | ||||
|       `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|     ) | ||||
|   })) | ||||
|  | ||||
|   it('should support auth token copy', fakeAsync(() => { | ||||
|   | ||||
| @@ -21,6 +21,7 @@ import { | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { ProfileService } from 'src/app/services/profile.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { setLocationHref } from 'src/app/utils/navigation' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component' | ||||
| import { PasswordComponent } from '../input/password/password.component' | ||||
| @@ -194,7 +195,9 @@ export class ProfileEditDialogComponent | ||||
|               $localize`Password has been changed, you will be logged out momentarily.` | ||||
|             ) | ||||
|             setTimeout(() => { | ||||
|               window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|               setLocationHref( | ||||
|                 `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|               ) | ||||
|             }, 2500) | ||||
|           } | ||||
|           this.activeModal.close() | ||||
|   | ||||
| @@ -58,16 +58,8 @@ | ||||
|         <i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span> | ||||
|       </button> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1"> | ||||
|         <i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span> | ||||
|       </button> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF"> | ||||
|         <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|       </button> | ||||
|  | ||||
|       <button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1"> | ||||
|         <i-bs name="file-earmark-minus"></i-bs> <ng-container i18n>Delete page(s)</ng-container> | ||||
|       <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF"> | ||||
|         <i-bs name="pencil"></i-bs> <ng-container i18n>Edit PDF</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -1158,81 +1158,40 @@ describe('DocumentDetailComponent', () => { | ||||
|     ).not.toBeUndefined() | ||||
|   }) | ||||
|  | ||||
|   it('should support split', () => { | ||||
|   it('should support pdf editor, handle error', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[0])) | ||||
|     initNormally() | ||||
|     component.splitDocument() | ||||
|     component.editPdf() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     modal.componentInstance.documentID = doc.id | ||||
|     modal.componentInstance.totalPages = 5 | ||||
|     modal.componentInstance.page = 2 | ||||
|     modal.componentInstance.addSplit() | ||||
|     modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }] | ||||
|     modal.componentInstance.confirm() | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     expect(req.request.body).toEqual({ | ||||
|       documents: [doc.id], | ||||
|       method: 'split', | ||||
|       parameters: { pages: '1-2,3-5', delete_originals: false }, | ||||
|       method: 'edit_pdf', | ||||
|       parameters: { | ||||
|         operations: [{ page: 1, rotate: 0, doc: 0 }], | ||||
|         delete_original: false, | ||||
|         update_document: false, | ||||
|         include_metadata: true, | ||||
|       }, | ||||
|     }) | ||||
|     req.error(new ProgressEvent('failed')) | ||||
|     modal.componentInstance.confirm() | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     req.flush(true) | ||||
|   }) | ||||
|  | ||||
|   it('should support rotate', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[0])) | ||||
|     initNormally() | ||||
|     component.rotateDocument() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     component.editPdf() | ||||
|     modal.componentInstance.documentID = doc.id | ||||
|     modal.componentInstance.rotate() | ||||
|     modal.componentInstance.confirm() | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     expect(req.request.body).toEqual({ | ||||
|       documents: [doc.id], | ||||
|       method: 'rotate', | ||||
|       parameters: { degrees: 90 }, | ||||
|     }) | ||||
|     req.error(new ProgressEvent('failed')) | ||||
|     modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }] | ||||
|     modal.componentInstance.confirm() | ||||
|     const errorSpy = jest.spyOn(toastService, 'showError') | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     req.flush(true) | ||||
|   }) | ||||
|  | ||||
|   it('should support delete pages', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[0])) | ||||
|     initNormally() | ||||
|     component.deletePages() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|     modal.componentInstance.documentID = doc.id | ||||
|     modal.componentInstance.pages = [1, 2] | ||||
|     modal.componentInstance.confirm() | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     expect(req.request.body).toEqual({ | ||||
|       documents: [doc.id], | ||||
|       method: 'delete_pages', | ||||
|       parameters: { pages: [1, 2] }, | ||||
|     }) | ||||
|     req.error(new ProgressEvent('failed')) | ||||
|     modal.componentInstance.confirm() | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     req.flush(true) | ||||
|     req.error(new ErrorEvent('failed')) | ||||
|     expect(errorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support keyboard shortcuts', () => { | ||||
|   | ||||
| @@ -82,9 +82,6 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http' | ||||
| import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' | ||||
| import * as UTIF from 'utif' | ||||
| import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' | ||||
| import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component' | ||||
| import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' | ||||
| import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' | ||||
| import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' | ||||
| import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' | ||||
| import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' | ||||
| @@ -102,6 +99,10 @@ import { TagsComponent } from '../common/input/tags/tags.component' | ||||
| import { TextComponent } from '../common/input/text/text.component' | ||||
| import { UrlComponent } from '../common/input/url/url.component' | ||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||
| import { | ||||
|   PDFEditorComponent, | ||||
|   PdfEditorEditMode, | ||||
| } from '../common/pdf-editor/pdf-editor.component' | ||||
| import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' | ||||
| import { DocumentHistoryComponent } from '../document-history/document-history.component' | ||||
| import { DocumentNotesComponent } from '../document-notes/document-notes.component' | ||||
| @@ -1349,13 +1350,13 @@ export class DocumentDetailComponent | ||||
|     this.documentForm.updateValueAndValidity() | ||||
|   } | ||||
|  | ||||
|   splitDocument() { | ||||
|     let modal = this.modalService.open(SplitConfirmDialogComponent, { | ||||
|   editPdf() { | ||||
|     let modal = this.modalService.open(PDFEditorComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'lg', | ||||
|       size: 'xl', | ||||
|       scrollable: true, | ||||
|     }) | ||||
|     modal.componentInstance.title = $localize`Split confirm` | ||||
|     modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.` | ||||
|     modal.componentInstance.title = $localize`Edit PDF` | ||||
|     modal.componentInstance.btnCaption = $localize`Proceed` | ||||
|     modal.componentInstance.documentID = this.document.id | ||||
|     modal.componentInstance.confirmClicked | ||||
| @@ -1363,15 +1364,18 @@ export class DocumentDetailComponent | ||||
|       .subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.documentsService | ||||
|           .bulkEdit([this.document.id], 'split', { | ||||
|             pages: modal.componentInstance.pagesString, | ||||
|             delete_originals: modal.componentInstance.deleteOriginal, | ||||
|           .bulkEdit([this.document.id], 'edit_pdf', { | ||||
|             operations: modal.componentInstance.getOperations(), | ||||
|             delete_original: modal.componentInstance.deleteOriginal, | ||||
|             update_document: | ||||
|               modal.componentInstance.editMode == PdfEditorEditMode.Update, | ||||
|             include_metadata: modal.componentInstance.includeMetadata, | ||||
|           }) | ||||
|           .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|           .subscribe({ | ||||
|             next: () => { | ||||
|               this.toastService.showInfo( | ||||
|                 $localize`Split operation for "${this.document.title}" will begin in the background.` | ||||
|                 $localize`PDF edit operation for "${this.document.title}" will begin in the background.` | ||||
|               ) | ||||
|               modal.close() | ||||
|             }, | ||||
| @@ -1380,86 +1384,7 @@ export class DocumentDetailComponent | ||||
|                 modal.componentInstance.buttonsEnabled = true | ||||
|               } | ||||
|               this.toastService.showError( | ||||
|                 $localize`Error executing split operation`, | ||||
|                 error | ||||
|               ) | ||||
|             }, | ||||
|           }) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   rotateDocument() { | ||||
|     let modal = this.modalService.open(RotateConfirmDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'lg', | ||||
|     }) | ||||
|     modal.componentInstance.title = $localize`Rotate confirm` | ||||
|     modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.` | ||||
|     modal.componentInstance.btnCaption = $localize`Proceed` | ||||
|     modal.componentInstance.documentID = this.document.id | ||||
|     modal.componentInstance.showPDFNote = false | ||||
|     modal.componentInstance.confirmClicked | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.documentsService | ||||
|           .bulkEdit([this.document.id], 'rotate', { | ||||
|             degrees: modal.componentInstance.degrees, | ||||
|           }) | ||||
|           .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|           .subscribe({ | ||||
|             next: () => { | ||||
|               this.toastService.show({ | ||||
|                 content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`, | ||||
|                 delay: 8000, | ||||
|                 action: this.close.bind(this), | ||||
|                 actionName: $localize`Close`, | ||||
|               }) | ||||
|               modal.close() | ||||
|             }, | ||||
|             error: (error) => { | ||||
|               if (modal) { | ||||
|                 modal.componentInstance.buttonsEnabled = true | ||||
|               } | ||||
|               this.toastService.showError( | ||||
|                 $localize`Error executing rotate operation`, | ||||
|                 error | ||||
|               ) | ||||
|             }, | ||||
|           }) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   deletePages() { | ||||
|     let modal = this.modalService.open(DeletePagesConfirmDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     modal.componentInstance.title = $localize`Delete pages confirm` | ||||
|     modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.` | ||||
|     modal.componentInstance.btnCaption = $localize`Proceed` | ||||
|     modal.componentInstance.documentID = this.document.id | ||||
|     modal.componentInstance.confirmClicked | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.documentsService | ||||
|           .bulkEdit([this.document.id], 'delete_pages', { | ||||
|             pages: modal.componentInstance.pages, | ||||
|           }) | ||||
|           .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|           .subscribe({ | ||||
|             next: () => { | ||||
|               this.toastService.showInfo( | ||||
|                 $localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.` | ||||
|               ) | ||||
|               modal.close() | ||||
|             }, | ||||
|             error: (error) => { | ||||
|               if (modal) { | ||||
|                 modal.componentInstance.buttonsEnabled = true | ||||
|               } | ||||
|               this.toastService.showError( | ||||
|                 $localize`Error executing delete pages operation`, | ||||
|                 $localize`Error executing PDF edit operation`, | ||||
|                 error | ||||
|               ) | ||||
|             }, | ||||
|   | ||||
| @@ -188,7 +188,7 @@ describe('MailComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     editDialog.succeeded.emit(mailAccounts[0] as any) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved account "${mailAccounts[0].name}".` | ||||
| @@ -211,7 +211,7 @@ describe('MailComponent', () => { | ||||
|       throwError(() => new Error('error deleting mail account')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
| @@ -246,7 +246,7 @@ describe('MailComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     editDialog.succeeded.emit(mailRules[0] as any) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved rule "${mailRules[0].name}".` | ||||
| @@ -280,7 +280,7 @@ describe('MailComponent', () => { | ||||
|       throwError(() => new Error('error deleting mail rule "rule1"')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
|   | ||||
							
								
								
									
										8
									
								
								src-ui/src/app/utils/navigation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src-ui/src/app/utils/navigation.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| /* istanbul ignore file */ | ||||
| export function setLocationHref(url: string) { | ||||
|   window.location.href = url | ||||
| } | ||||
|  | ||||
| export function locationReload() { | ||||
|   window.location.reload() | ||||
| } | ||||
| @@ -3,7 +3,8 @@ | ||||
|   "compilerOptions": { | ||||
|     "outDir": "./out-tsc/spec", | ||||
|     "types": [ | ||||
|       "jest" | ||||
|       "jest", | ||||
|       "node", | ||||
|     ], | ||||
|     "module": "commonjs", | ||||
|     "emitDecoratorMetadata": true, | ||||
|   | ||||
| @@ -497,6 +497,97 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]: | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def edit_pdf( | ||||
|     doc_ids: list[int], | ||||
|     operations: list[dict], | ||||
|     *, | ||||
|     delete_original: bool = False, | ||||
|     update_document: bool = False, | ||||
|     include_metadata: bool = True, | ||||
|     user: User | None = None, | ||||
| ) -> Literal["OK"]: | ||||
|     """ | ||||
|     Operations is a list of dictionaries describing the final PDF pages. | ||||
|     Each entry must contain the original page number in `page` and may | ||||
|     specify `rotate` in degrees and `doc` indicating the output | ||||
|     document index (for splitting). Pages omitted from the list are | ||||
|     discarded. | ||||
|     """ | ||||
|  | ||||
|     logger.info( | ||||
|         f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations", | ||||
|     ) | ||||
|     doc = Document.objects.get(id=doc_ids[0]) | ||||
|     import pikepdf | ||||
|  | ||||
|     pdf_docs: list[pikepdf.Pdf] = [] | ||||
|  | ||||
|     try: | ||||
|         with pikepdf.open(doc.source_path) as src: | ||||
|             # prepare output documents | ||||
|             max_idx = max(op.get("doc", 0) for op in operations) | ||||
|             pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)] | ||||
|  | ||||
|             if update_document and len(pdf_docs) > 1: | ||||
|                 logger.error( | ||||
|                     "Update requested but multiple output documents specified", | ||||
|                 ) | ||||
|                 return "ERROR" | ||||
|  | ||||
|             for op in operations: | ||||
|                 dst = pdf_docs[op.get("doc", 0)] | ||||
|                 page = src.pages[op["page"] - 1] | ||||
|                 dst.pages.append(page) | ||||
|                 if op.get("rotate"): | ||||
|                     dst.pages[-1].rotate(op["rotate"], relative=True) | ||||
|  | ||||
|         if update_document: | ||||
|             pdf = pdf_docs[0] | ||||
|             pdf.remove_unreferenced_resources() | ||||
|             pdf.save(doc.source_path) | ||||
|             doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest() | ||||
|             doc.page_count = len(pdf.pages) | ||||
|             doc.save() | ||||
|             update_document_content_maybe_archive_file.delay(document_id=doc.id) | ||||
|         else: | ||||
|             consume_tasks = [] | ||||
|             overrides = ( | ||||
|                 DocumentMetadataOverrides().from_document(doc) | ||||
|                 if include_metadata | ||||
|                 else DocumentMetadataOverrides() | ||||
|             ) | ||||
|             if user is not None: | ||||
|                 overrides.owner_id = user.id | ||||
|  | ||||
|             for idx, pdf in enumerate(pdf_docs, start=1): | ||||
|                 filepath: Path = ( | ||||
|                     Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) | ||||
|                     / f"{doc.id}_edit_{idx}.pdf" | ||||
|                 ) | ||||
|                 pdf.remove_unreferenced_resources() | ||||
|                 pdf.save(filepath) | ||||
|                 consume_tasks.append( | ||||
|                     consume_file.s( | ||||
|                         ConsumableDocument( | ||||
|                             source=DocumentSource.ConsumeFolder, | ||||
|                             original_file=filepath, | ||||
|                         ), | ||||
|                         overrides, | ||||
|                     ), | ||||
|                 ) | ||||
|  | ||||
|             if delete_original: | ||||
|                 chord(header=consume_tasks, body=delete.si([doc.id])).delay() | ||||
|             else: | ||||
|                 group(consume_tasks).delay() | ||||
|  | ||||
|     except Exception as e: | ||||
|         logger.exception(f"Error editing document {doc.id}: {e}") | ||||
|         return "ERROR" | ||||
|  | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def reflect_doclinks( | ||||
|     document: Document, | ||||
|     field: CustomField, | ||||
|   | ||||
| @@ -12,6 +12,7 @@ from tqdm import tqdm | ||||
|  | ||||
| from documents.models import Document | ||||
| from documents.models import PaperlessTask | ||||
| from paperless.config import GeneralConfig | ||||
|  | ||||
|  | ||||
| class SanityCheckMessages: | ||||
| @@ -82,8 +83,10 @@ def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages: | ||||
|     if lockfile in present_files: | ||||
|         present_files.remove(lockfile) | ||||
|  | ||||
|     if settings.APP_LOGO: | ||||
|         logo_file = Path(settings.MEDIA_ROOT / settings.APP_LOGO).resolve() | ||||
|     general_config = GeneralConfig() | ||||
|     app_logo = general_config.app_logo or settings.APP_LOGO | ||||
|     if app_logo: | ||||
|         logo_file = Path(settings.MEDIA_ROOT / Path(app_logo.lstrip("/"))).resolve() | ||||
|         if logo_file in present_files: | ||||
|             present_files.remove(logo_file) | ||||
|  | ||||
|   | ||||
| @@ -1293,6 +1293,7 @@ class BulkEditSerializer( | ||||
|             "merge", | ||||
|             "split", | ||||
|             "delete_pages", | ||||
|             "edit_pdf", | ||||
|         ], | ||||
|         label="Method", | ||||
|         write_only=True, | ||||
| @@ -1366,7 +1367,10 @@ class BulkEditSerializer( | ||||
|             return bulk_edit.split | ||||
|         elif method == "delete_pages": | ||||
|             return bulk_edit.delete_pages | ||||
|         else: | ||||
|         elif method == "edit_pdf": | ||||
|             return bulk_edit.edit_pdf | ||||
|         else:  # pragma: no cover | ||||
|             # This will never happen as it is handled by the ChoiceField | ||||
|             raise serializers.ValidationError("Unsupported method.") | ||||
|  | ||||
|     def _validate_parameters_tags(self, parameters): | ||||
| @@ -1520,6 +1524,38 @@ class BulkEditSerializer( | ||||
|         else: | ||||
|             parameters["archive_fallback"] = False | ||||
|  | ||||
|     def _validate_parameters_edit_pdf(self, parameters): | ||||
|         if "operations" not in parameters: | ||||
|             raise serializers.ValidationError("operations not specified") | ||||
|         if not isinstance(parameters["operations"], list): | ||||
|             raise serializers.ValidationError("operations must be a list") | ||||
|         for op in parameters["operations"]: | ||||
|             if not isinstance(op, dict): | ||||
|                 raise serializers.ValidationError("invalid operation entry") | ||||
|             if "page" not in op or not isinstance(op["page"], int): | ||||
|                 raise serializers.ValidationError("page must be an integer") | ||||
|             if "rotate" in op and not isinstance(op["rotate"], int): | ||||
|                 raise serializers.ValidationError("rotate must be an integer") | ||||
|             if "doc" in op and not isinstance(op["doc"], int): | ||||
|                 raise serializers.ValidationError("doc must be an integer") | ||||
|         if "update_document" in parameters: | ||||
|             if not isinstance(parameters["update_document"], bool): | ||||
|                 raise serializers.ValidationError("update_document must be a boolean") | ||||
|         else: | ||||
|             parameters["update_document"] = False | ||||
|         if "include_metadata" in parameters: | ||||
|             if not isinstance(parameters["include_metadata"], bool): | ||||
|                 raise serializers.ValidationError("include_metadata must be a boolean") | ||||
|         else: | ||||
|             parameters["include_metadata"] = True | ||||
|  | ||||
|         if parameters["update_document"]: | ||||
|             max_idx = max(op.get("doc", 0) for op in parameters["operations"]) | ||||
|             if max_idx > 0: | ||||
|                 raise serializers.ValidationError( | ||||
|                     "update_document only allowed with a single output document", | ||||
|                 ) | ||||
|  | ||||
|     def validate(self, attrs): | ||||
|         method = attrs["method"] | ||||
|         parameters = attrs["parameters"] | ||||
| @@ -1554,6 +1590,12 @@ class BulkEditSerializer( | ||||
|             self._validate_parameters_delete_pages(parameters) | ||||
|         elif method == bulk_edit.merge: | ||||
|             self._validate_parameters_merge(parameters) | ||||
|         elif method == bulk_edit.edit_pdf: | ||||
|             if len(attrs["documents"]) > 1: | ||||
|                 raise serializers.ValidationError( | ||||
|                     "Edit PDF method only supports one document", | ||||
|                 ) | ||||
|             self._validate_parameters_edit_pdf(parameters) | ||||
|  | ||||
|         return attrs | ||||
|  | ||||
|   | ||||
| @@ -1369,6 +1369,192 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"pages must be a list of integers", response.content) | ||||
|  | ||||
|     @mock.patch("documents.serialisers.bulk_edit.edit_pdf") | ||||
|     def test_edit_pdf(self, m): | ||||
|         self.setup_mock(m, "edit_pdf") | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": [{"page": 1}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|  | ||||
|         m.assert_called_once() | ||||
|         args, kwargs = m.call_args | ||||
|         self.assertCountEqual(args[0], [self.doc2.id]) | ||||
|         self.assertEqual(kwargs["operations"], [{"page": 1}]) | ||||
|         self.assertEqual(kwargs["user"], self.user) | ||||
|  | ||||
|     def test_edit_pdf_invalid_params(self): | ||||
|         # multiple documents | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id, self.doc3.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": [{"page": 1}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"Edit PDF method only supports one document", response.content) | ||||
|  | ||||
|         # no operations specified | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"operations not specified", response.content) | ||||
|  | ||||
|         # operations not a list | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": "not_a_list"}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"operations must be a list", response.content) | ||||
|  | ||||
|         # invalid operation | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": ["invalid_operation"]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"invalid operation entry", response.content) | ||||
|  | ||||
|         # page not an int | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": [{"page": "not_an_int"}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"page must be an integer", response.content) | ||||
|  | ||||
|         # rotate not an int | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": [{"page": 1, "rotate": "not_an_int"}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"rotate must be an integer", response.content) | ||||
|  | ||||
|         # doc not an int | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": {"operations": [{"page": 1, "doc": "not_an_int"}]}, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"doc must be an integer", response.content) | ||||
|  | ||||
|         # update_document not a boolean | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": { | ||||
|                         "update_document": "not_a_bool", | ||||
|                         "operations": [{"page": 1}], | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"update_document must be a boolean", response.content) | ||||
|  | ||||
|         # include_metadata not a boolean | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": { | ||||
|                         "include_metadata": "not_a_bool", | ||||
|                         "operations": [{"page": 1}], | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn(b"include_metadata must be a boolean", response.content) | ||||
|  | ||||
|         # update_document True but output would be multiple documents | ||||
|         response = self.client.post( | ||||
|             "/api/documents/bulk_edit/", | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "documents": [self.doc2.id], | ||||
|                     "method": "edit_pdf", | ||||
|                     "parameters": { | ||||
|                         "update_document": True, | ||||
|                         "operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}], | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn( | ||||
|             b"update_document only allowed with a single output document", | ||||
|             response.content, | ||||
|         ) | ||||
|  | ||||
|     @override_settings(AUDIT_LOG_ENABLED=True) | ||||
|     def test_bulk_edit_audit_log_enabled_simple_field(self): | ||||
|         """ | ||||
|   | ||||
| @@ -909,3 +909,156 @@ class TestPDFActions(DirectoriesMixin, TestCase): | ||||
|             expected_str = "Error deleting pages from document" | ||||
|             self.assertIn(expected_str, error_str) | ||||
|             mock_update_archive_file.assert_not_called() | ||||
|  | ||||
|     @mock.patch("documents.bulk_edit.group") | ||||
|     @mock.patch("documents.tasks.consume_file.s") | ||||
|     def test_edit_pdf_basic_operations(self, mock_consume_file, mock_group): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Existing document | ||||
|         WHEN: | ||||
|             - edit_pdf is called with two operations to split the doc and rotate pages | ||||
|         THEN: | ||||
|             - A grouped task is generated and delay() is called | ||||
|         """ | ||||
|         mock_group.return_value.delay.return_value = None | ||||
|         doc_ids = [self.doc2.id] | ||||
|         operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1, "rotate": 90}] | ||||
|  | ||||
|         result = bulk_edit.edit_pdf(doc_ids, operations) | ||||
|         self.assertEqual(result, "OK") | ||||
|         mock_group.return_value.delay.assert_called_once() | ||||
|  | ||||
|     @mock.patch("documents.bulk_edit.group") | ||||
|     @mock.patch("documents.tasks.consume_file.s") | ||||
|     def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Existing document | ||||
|         WHEN: | ||||
|             - edit_pdf is called with user override | ||||
|         THEN: | ||||
|             - Task is created with user context | ||||
|         """ | ||||
|         mock_group.return_value.delay.return_value = None | ||||
|         doc_ids = [self.doc2.id] | ||||
|         operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}] | ||||
|         user = User.objects.create(username="editor") | ||||
|  | ||||
|         result = bulk_edit.edit_pdf(doc_ids, operations, user=user) | ||||
|         self.assertEqual(result, "OK") | ||||
|         mock_group.return_value.delay.assert_called_once() | ||||
|  | ||||
|     @mock.patch("documents.bulk_edit.chord") | ||||
|     @mock.patch("documents.tasks.consume_file.s") | ||||
|     def test_edit_pdf_with_delete_original(self, mock_consume_file, mock_chord): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Existing document | ||||
|         WHEN: | ||||
|             - edit_pdf is called with delete_original=True | ||||
|         THEN: | ||||
|             - Task group is triggered | ||||
|         """ | ||||
|         mock_chord.return_value.delay.return_value = None | ||||
|         doc_ids = [self.doc2.id] | ||||
|         operations = [{"page": 1}, {"page": 2}] | ||||
|  | ||||
|         result = bulk_edit.edit_pdf(doc_ids, operations, delete_original=True) | ||||
|         self.assertEqual(result, "OK") | ||||
|         mock_chord.assert_called_once() | ||||
|  | ||||
|     @mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay") | ||||
|     def test_edit_pdf_with_update_document(self, mock_update_document): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A single existing PDF document | ||||
|         WHEN: | ||||
|             - edit_pdf is called with update_document=True and a single output | ||||
|         THEN: | ||||
|             - The original document is updated in-place | ||||
|             - The update_document_content_maybe_archive_file task is triggered | ||||
|         """ | ||||
|         doc_ids = [self.doc2.id] | ||||
|         operations = [{"page": 1}, {"page": 2}] | ||||
|         original_checksum = self.doc2.checksum | ||||
|         original_page_count = self.doc2.page_count | ||||
|  | ||||
|         result = bulk_edit.edit_pdf( | ||||
|             doc_ids, | ||||
|             operations=operations, | ||||
|             update_document=True, | ||||
|             delete_original=False, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(result, "OK") | ||||
|         self.doc2.refresh_from_db() | ||||
|         self.assertNotEqual(self.doc2.checksum, original_checksum) | ||||
|         self.assertNotEqual(self.doc2.page_count, original_page_count) | ||||
|         mock_update_document.assert_called_once_with(document_id=self.doc2.id) | ||||
|  | ||||
|     @mock.patch("documents.bulk_edit.group") | ||||
|     @mock.patch("documents.tasks.consume_file.s") | ||||
|     def test_edit_pdf_without_metadata(self, mock_consume_file, mock_group): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Existing document | ||||
|         WHEN: | ||||
|             - edit_pdf is called with include_metadata=False | ||||
|         THEN: | ||||
|             - Tasks are created with empty metadata | ||||
|         """ | ||||
|         mock_group.return_value.delay.return_value = None | ||||
|         doc_ids = [self.doc2.id] | ||||
|         operations = [{"page": 1}] | ||||
|  | ||||
|         result = bulk_edit.edit_pdf(doc_ids, operations, include_metadata=False) | ||||
|         self.assertEqual(result, "OK") | ||||
|         mock_group.return_value.delay.assert_called_once() | ||||
|  | ||||
|     @mock.patch("documents.bulk_edit.group") | ||||
|     @mock.patch("documents.tasks.consume_file.s") | ||||
|     def test_edit_pdf_open_failure(self, mock_consume_file, mock_group): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Existing document | ||||
|         WHEN: | ||||
|             - edit_pdf fails to open PDF | ||||
|         THEN: | ||||
|             - Task group is not called | ||||
|         """ | ||||
|         doc_ids = [self.doc2.id] | ||||
|         operations = [ | ||||
|             {"page": 9999},  # invalid page, forces error during PDF load | ||||
|         ] | ||||
|         with self.assertLogs("paperless.bulk_edit", level="ERROR"): | ||||
|             result = bulk_edit.edit_pdf(doc_ids, operations) | ||||
|             self.assertEqual(result, "ERROR") | ||||
|         mock_group.assert_not_called() | ||||
|         mock_consume_file.assert_not_called() | ||||
|  | ||||
|     @mock.patch("documents.bulk_edit.group") | ||||
|     @mock.patch("documents.tasks.consume_file.s") | ||||
|     def test_edit_pdf_multiple_outputs_with_update_flag_errors( | ||||
|         self, | ||||
|         mock_consume_file, | ||||
|         mock_group, | ||||
|     ): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Existing document | ||||
|         WHEN: | ||||
|             - edit_pdf is called with multiple outputs and update_document=True | ||||
|         THEN: | ||||
|             - An error is logged and task group is not called | ||||
|         """ | ||||
|         doc_ids = [self.doc2.id] | ||||
|         operations = [ | ||||
|             {"page": 1, "doc": 0}, | ||||
|             {"page": 2, "doc": 1}, | ||||
|         ] | ||||
|         with self.assertLogs("paperless.bulk_edit", level="ERROR"): | ||||
|             result = bulk_edit.edit_pdf(doc_ids, operations, update_document=True) | ||||
|             self.assertEqual(result, "ERROR") | ||||
|         mock_group.assert_not_called() | ||||
|         mock_consume_file.assert_not_called() | ||||
|   | ||||
| @@ -1321,6 +1321,7 @@ class BulkEditView(PassUserMixin): | ||||
|         "delete_pages": "checksum", | ||||
|         "split": None, | ||||
|         "merge": None, | ||||
|         "edit_pdf": "checksum", | ||||
|         "reprocess": "checksum", | ||||
|     } | ||||
|  | ||||
| @@ -1339,6 +1340,7 @@ class BulkEditView(PassUserMixin): | ||||
|         if method in [ | ||||
|             bulk_edit.split, | ||||
|             bulk_edit.merge, | ||||
|             bulk_edit.edit_pdf, | ||||
|         ]: | ||||
|             parameters["user"] = user | ||||
|  | ||||
| @@ -1358,27 +1360,36 @@ class BulkEditView(PassUserMixin): | ||||
|  | ||||
|             # check ownership for methods that change original document | ||||
|             if ( | ||||
|                 has_perms | ||||
|                 and method | ||||
|                 in [ | ||||
|                     bulk_edit.set_permissions, | ||||
|                     bulk_edit.delete, | ||||
|                     bulk_edit.rotate, | ||||
|                     bulk_edit.delete_pages, | ||||
|                 ] | ||||
|             ) or ( | ||||
|                 method in [bulk_edit.merge, bulk_edit.split] | ||||
|                 and parameters["delete_originals"] | ||||
|                 ( | ||||
|                     has_perms | ||||
|                     and method | ||||
|                     in [ | ||||
|                         bulk_edit.set_permissions, | ||||
|                         bulk_edit.delete, | ||||
|                         bulk_edit.rotate, | ||||
|                         bulk_edit.delete_pages, | ||||
|                         bulk_edit.edit_pdf, | ||||
|                     ] | ||||
|                 ) | ||||
|                 or ( | ||||
|                     method in [bulk_edit.merge, bulk_edit.split] | ||||
|                     and parameters["delete_originals"] | ||||
|                 ) | ||||
|                 or (method == bulk_edit.edit_pdf and parameters["update_document"]) | ||||
|             ): | ||||
|                 has_perms = user_is_owner_of_all_documents | ||||
|  | ||||
|             # check global add permissions for methods that create documents | ||||
|             if ( | ||||
|                 has_perms | ||||
|                 and method in [bulk_edit.split, bulk_edit.merge] | ||||
|                 and not user.has_perm( | ||||
|                     "documents.add_document", | ||||
|                 and ( | ||||
|                     method in [bulk_edit.split, bulk_edit.merge] | ||||
|                     or ( | ||||
|                         method == bulk_edit.edit_pdf | ||||
|                         and not parameters["update_document"] | ||||
|                     ) | ||||
|                 ) | ||||
|                 and not user.has_perm("documents.add_document") | ||||
|             ): | ||||
|                 has_perms = False | ||||
|  | ||||
|   | ||||
| @@ -16,7 +16,15 @@ class TextDocumentParser(DocumentParser): | ||||
|     logging_name = "paperless.parsing.text" | ||||
|  | ||||
|     def get_thumbnail(self, document_path: Path, mime_type, file_name=None) -> Path: | ||||
|         text = self.read_file_handle_unicode_errors(document_path) | ||||
|         # Avoid reading entire file into memory | ||||
|         max_chars = 100_000 | ||||
|         file_size_limit = 50 * 1024 * 1024 | ||||
|  | ||||
|         if document_path.stat().st_size > file_size_limit: | ||||
|             text = "[File too large to preview]" | ||||
|         else: | ||||
|             with Path(document_path).open("r", encoding="utf-8", errors="replace") as f: | ||||
|                 text = f.read(max_chars) | ||||
|  | ||||
|         img = Image.new("RGB", (500, 700), color="white") | ||||
|         draw = ImageDraw.Draw(img) | ||||
| @@ -25,7 +33,7 @@ class TextDocumentParser(DocumentParser): | ||||
|             size=20, | ||||
|             layout_engine=ImageFont.Layout.BASIC, | ||||
|         ) | ||||
|         draw.text((5, 5), text, font=font, fill="black") | ||||
|         draw.multiline_text((5, 5), text, font=font, fill="black", spacing=4) | ||||
|  | ||||
|         out_path = self.tempdir / "thumb.webp" | ||||
|         img.save(out_path, format="WEBP") | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import tempfile | ||||
| from pathlib import Path | ||||
|  | ||||
| from paperless_text.parsers import TextDocumentParser | ||||
| @@ -35,3 +36,26 @@ class TestTextParser: | ||||
|  | ||||
|         assert text_parser.get_text() == "Pantothens<EFBFBD>ure\n" | ||||
|         assert text_parser.get_archive_path() is None | ||||
|  | ||||
|     def test_thumbnail_large_file(self, text_parser: TextDocumentParser): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A very large text file (>50MB) | ||||
|         WHEN: | ||||
|             - A thumbnail is requested | ||||
|         THEN: | ||||
|             - A thumbnail is created without reading the entire file into memory | ||||
|         """ | ||||
|         with tempfile.NamedTemporaryFile( | ||||
|             delete=False, | ||||
|             mode="w", | ||||
|             encoding="utf-8", | ||||
|             suffix=".txt", | ||||
|         ) as tmp: | ||||
|             tmp.write("A" * (51 * 1024 * 1024))  # 51 MB of 'A' | ||||
|             large_file = Path(tmp.name) | ||||
|  | ||||
|             thumb = text_parser.get_thumbnail(large_file, "text/plain") | ||||
|             assert thumb.exists() | ||||
|             assert thumb.is_file() | ||||
|             large_file.unlink() | ||||
|   | ||||
							
								
								
									
										131
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										131
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @@ -41,14 +41,14 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "asgiref" | ||||
| version = "3.8.1" | ||||
| version = "3.9.1" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -312,20 +312,20 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "channels" | ||||
| version = "4.2.2" | ||||
| version = "4.3.0" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/16/d6/049f93c3c96a88265a52f85da91d2635279261bbd4a924b45caa43b8822e/channels-4.2.2.tar.gz", hash = "sha256:8d7208e48ab8fdb972aaeae8311ce920637d97656ffc7ae5eca4f93f84bcd9a0", size = 26647, upload-time = "2025-03-30T14:59:20.35Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/72/04/6768c7a887f9c593c4d49f99130c8aec4ea06e750bc17c306b689f6caf3b/channels-4.3.0.tar.gz", hash = "sha256:7db32c61dcd88eada1647e6c6f6ad2eb724b75d4852eeff26ad1c51ccd1a37f7", size = 26816, upload-time = "2025-07-28T13:52:50.334Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/cc/bf/4799809715225d19928147d59fda0d3a4129da055b59a9b3e35aa6223f52/channels-4.2.2-py3-none-any.whl", hash = "sha256:ff36a6e1576cacf40bcdc615fa7aece7a709fc4fdd2dc87f2971f4061ffdaa81", size = 31048, upload-time = "2025-03-30T14:59:18.969Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/7c/59/0866202ee593e1b0dab0b472ebb8169e1b2b7886ad3008d193da2bbe10cb/channels-4.3.0-py3-none-any.whl", hash = "sha256:0497f3affb95e621b37d6bae1b6a5d9e8e1e1221007a2566f280091cf30ffcce", size = 31238, upload-time = "2025-07-28T13:52:49.117Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "channels-redis" | ||||
| version = "4.2.1" | ||||
| version = "4.3.0" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| @@ -333,9 +333,9 @@ dependencies = [ | ||||
|     { name = "msgpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/c7/6d/c379c9feea4522cbdb4eba9b3d23a6270ba8cbd94e847b21834d898109d6/channels_redis-4.2.1.tar.gz", hash = "sha256:8375e81493e684792efe6e6eca60ef3d7782ef76c6664057d2e5c31e80d636dd", size = 31152, upload-time = "2024-11-15T12:58:49.836Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/a6/aa/981d08ae9627c3b9d8dd150f0fe644122a351abc1f47bcf53d2bfff80d91/channels_redis-4.2.1-py3-none-any.whl", hash = "sha256:2ca33105b3a04b5a327a9c47dd762b546f30b76a0cd3f3f593a23d91d346b6f4", size = 20487, upload-time = "2024-11-15T12:58:47.847Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5", size = 20641, upload-time = "2025-07-22T13:48:44.545Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -1662,7 +1662,7 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "mkdocs-material" | ||||
| version = "9.6.15" | ||||
| version = "9.6.16" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| @@ -1677,9 +1677,9 @@ dependencies = [ | ||||
|     { name = "pymdown-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/95/c1/f804ba2db2ddc2183e900befe7dad64339a34fa935034e1ab405289d0a97/mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5", size = 3951836, upload-time = "2025-07-01T10:14:15.671Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/dd/84/aec27a468c5e8c27689c71b516fb5a0d10b8fca45b9ad2dd9d6e43bc4296/mkdocs_material-9.6.16.tar.gz", hash = "sha256:d07011df4a5c02ee0877496d9f1bfc986cfb93d964799b032dd99fe34c0e9d19", size = 4028828, upload-time = "2025-07-26T15:53:47.542Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/1d/30/dda19f0495a9096b64b6b3c07c4bfcff1c76ee0fc521086d53593f18b4c0/mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a", size = 8716840, upload-time = "2025-07-01T10:14:13.18Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/65/f4/90ad67125b4dd66e7884e4dbdfab82e3679eb92b751116f8bb25ccfe2f0c/mkdocs_material-9.6.16-py3-none-any.whl", hash = "sha256:8d1a1282b892fe1fdf77bfeb08c485ba3909dd743c9ba69a19a40f637c6ec18c", size = 9223743, upload-time = "2025-07-26T15:53:44.236Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -2111,10 +2111,10 @@ dev = [ | ||||
|     { name = "imagehash" }, | ||||
|     { name = "mkdocs-glightbox", specifier = "~=0.4.0" }, | ||||
|     { name = "mkdocs-material", specifier = "~=9.6.4" }, | ||||
|     { name = "pre-commit", specifier = "~=4.1.0" }, | ||||
|     { name = "pre-commit", specifier = "~=4.2.0" }, | ||||
|     { name = "pre-commit-uv", specifier = "~=4.1.3" }, | ||||
|     { name = "pytest", specifier = "~=8.3.3" }, | ||||
|     { name = "pytest-cov", specifier = "~=6.0.0" }, | ||||
|     { name = "pytest", specifier = "~=8.4.1" }, | ||||
|     { name = "pytest-cov", specifier = "~=6.2.1" }, | ||||
|     { name = "pytest-django", specifier = "~=4.10.0" }, | ||||
|     { name = "pytest-env" }, | ||||
|     { name = "pytest-httpx" }, | ||||
| @@ -2129,7 +2129,7 @@ docs = [ | ||||
|     { name = "mkdocs-material", specifier = "~=9.6.4" }, | ||||
| ] | ||||
| lint = [ | ||||
|     { name = "pre-commit", specifier = "~=4.1.0" }, | ||||
|     { name = "pre-commit", specifier = "~=4.2.0" }, | ||||
|     { name = "pre-commit-uv", specifier = "~=4.1.3" }, | ||||
|     { name = "ruff", specifier = "~=0.12.2" }, | ||||
| ] | ||||
| @@ -2137,8 +2137,8 @@ testing = [ | ||||
|     { name = "daphne" }, | ||||
|     { name = "factory-boy", specifier = "~=3.3.1" }, | ||||
|     { name = "imagehash" }, | ||||
|     { name = "pytest", specifier = "~=8.3.3" }, | ||||
|     { name = "pytest-cov", specifier = "~=6.0.0" }, | ||||
|     { name = "pytest", specifier = "~=8.4.1" }, | ||||
|     { name = "pytest-cov", specifier = "~=6.2.1" }, | ||||
|     { name = "pytest-django", specifier = "~=4.10.0" }, | ||||
|     { name = "pytest-env" }, | ||||
|     { name = "pytest-httpx" }, | ||||
| @@ -2367,7 +2367,7 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "pre-commit" | ||||
| version = "4.1.0" | ||||
| version = "4.2.0" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "cfgv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| @@ -2376,9 +2376,9 @@ dependencies = [ | ||||
|     { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "virtualenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330, upload-time = "2025-01-20T18:31:48.681Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560, upload-time = "2025-01-20T18:31:47.319Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -2547,31 +2547,33 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "pytest" | ||||
| version = "8.3.5" | ||||
| version = "8.4.1" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, | ||||
|     { name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "pytest-cov" | ||||
| version = "6.0.0" | ||||
| version = "6.2.1" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "coverage", extra = ["toml"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -3100,29 +3102,29 @@ wheels = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "ruff" | ||||
| version = "0.12.2" | ||||
| version = "0.12.7" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814, upload-time = "2025-07-29T22:32:35.877Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189, upload-time = "2025-07-29T22:31:41.281Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389, upload-time = "2025-07-29T22:31:54.265Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384, upload-time = "2025-07-29T22:31:59.575Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759, upload-time = "2025-07-29T22:32:01.95Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028, upload-time = "2025-07-29T22:32:04.367Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209, upload-time = "2025-07-29T22:32:06.952Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353, upload-time = "2025-07-29T22:32:10.053Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555, upload-time = "2025-07-29T22:32:12.644Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556, upload-time = "2025-07-29T22:32:15.312Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784, upload-time = "2025-07-29T22:32:17.69Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356, upload-time = "2025-07-29T22:32:20.134Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124, upload-time = "2025-07-29T22:32:22.645Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945, upload-time = "2025-07-29T22:32:24.765Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677, upload-time = "2025-07-29T22:32:27.022Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "scikit-learn" | ||||
| version = "1.7.0" | ||||
| version = "1.7.1" | ||||
| source = { registry = "https://pypi.org/simple" } | ||||
| dependencies = [ | ||||
|     { name = "joblib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| @@ -3130,27 +3132,28 @@ dependencies = [ | ||||
|     { name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
|     { name = "threadpoolctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||
| ] | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/df/3b/29fa87e76b1d7b3b77cc1fcbe82e6e6b8cd704410705b008822de530277c/scikit_learn-1.7.0.tar.gz", hash = "sha256:c01e869b15aec88e2cdb73d27f15bdbe03bce8e2fb43afbe77c45d399e73a5a3", size = 7178217, upload-time = "2025-06-05T22:02:46.703Z" } | ||||
| sdist = { url = "https://files.pythonhosted.org/packages/41/84/5f4af978fff619706b8961accac84780a6d298d82a8873446f72edb4ead0/scikit_learn-1.7.1.tar.gz", hash = "sha256:24b3f1e976a4665aa74ee0fcaac2b8fccc6ae77c8e07ab25da3ba6d3292b9802", size = 7190445, upload-time = "2025-07-18T08:01:54.5Z" } | ||||
| wheels = [ | ||||
|     { url = "https://files.pythonhosted.org/packages/a4/70/e725b1da11e7e833f558eb4d3ea8b7ed7100edda26101df074f1ae778235/scikit_learn-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9fe7f51435f49d97bd41d724bb3e11eeb939882af9c29c931a8002c357e8cdd5", size = 11728006, upload-time = "2025-06-05T22:01:43.007Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/32/aa/43874d372e9dc51eb361f5c2f0a4462915c9454563b3abb0d9457c66b7e9/scikit_learn-1.7.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d0c93294e1e1acbee2d029b1f2a064f26bd928b284938d51d412c22e0c977eb3", size = 10726255, upload-time = "2025-06-05T22:01:46.082Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/f5/1a/da73cc18e00f0b9ae89f7e4463a02fb6e0569778120aeab138d9554ecef0/scikit_learn-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3755f25f145186ad8c403312f74fb90df82a4dfa1af19dc96ef35f57237a94", size = 12205657, upload-time = "2025-06-05T22:01:48.729Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/fb/f6/800cb3243dd0137ca6d98df8c9d539eb567ba0a0a39ecd245c33fab93510/scikit_learn-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2726c8787933add436fb66fb63ad18e8ef342dfb39bbbd19dc1e83e8f828a85a", size = 12877290, upload-time = "2025-06-05T22:01:51.073Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/5a/42/c6b41711c2bee01c4800ad8da2862c0b6d2956a399d23ce4d77f2ca7f0c7/scikit_learn-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ef09b1615e1ad04dc0d0054ad50634514818a8eb3ee3dee99af3bffc0ef5007", size = 11719657, upload-time = "2025-06-05T22:01:56.345Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/a3/24/44acca76449e391b6b2522e67a63c0454b7c1f060531bdc6d0118fb40851/scikit_learn-1.7.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7d7240c7b19edf6ed93403f43b0fcb0fe95b53bc0b17821f8fb88edab97085ef", size = 10712636, upload-time = "2025-06-05T22:01:59.093Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/9f/1b/fcad1ccb29bdc9b96bcaa2ed8345d56afb77b16c0c47bafe392cc5d1d213/scikit_learn-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80bd3bd4e95381efc47073a720d4cbab485fc483966f1709f1fd559afac57ab8", size = 12242817, upload-time = "2025-06-05T22:02:01.43Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/c6/38/48b75c3d8d268a3f19837cb8a89155ead6e97c6892bb64837183ea41db2b/scikit_learn-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dbe48d69aa38ecfc5a6cda6c5df5abef0c0ebdb2468e92437e2053f84abb8bc", size = 12873961, upload-time = "2025-06-05T22:02:03.951Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/70/3a/bffab14e974a665a3ee2d79766e7389572ffcaad941a246931c824afcdb2/scikit_learn-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2c7243d34aaede0efca7a5a96d67fddaebb4ad7e14a70991b9abee9dc5c0379", size = 11646758, upload-time = "2025-06-05T22:02:09.51Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/58/d8/f3249232fa79a70cb40595282813e61453c1e76da3e1a44b77a63dd8d0cb/scikit_learn-1.7.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f39f6a811bf3f15177b66c82cbe0d7b1ebad9f190737dcdef77cfca1ea3c19c", size = 10673971, upload-time = "2025-06-05T22:02:12.217Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/67/93/eb14c50533bea2f77758abe7d60a10057e5f2e2cdcf0a75a14c6bc19c734/scikit_learn-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63017a5f9a74963d24aac7590287149a8d0f1a0799bbe7173c0d8ba1523293c0", size = 11818428, upload-time = "2025-06-05T22:02:14.947Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/08/17/804cc13b22a8663564bb0b55fb89e661a577e4e88a61a39740d58b909efe/scikit_learn-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f8a0b1e73e9a08b7cc498bb2aeab36cdc1f571f8ab2b35c6e5d1c7115d97d", size = 12505887, upload-time = "2025-06-05T22:02:17.824Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/9a/c3/a85dcccdaf1e807e6f067fa95788a6485b0491d9ea44fd4c812050d04f45/scikit_learn-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5b7974f1f32bc586c90145df51130e02267e4b7e77cab76165c76cf43faca0d9", size = 11559841, upload-time = "2025-06-05T22:02:23.308Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/d8/57/eea0de1562cc52d3196eae51a68c5736a31949a465f0b6bb3579b2d80282/scikit_learn-1.7.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:014e07a23fe02e65f9392898143c542a50b6001dbe89cb867e19688e468d049b", size = 10616463, upload-time = "2025-06-05T22:02:26.068Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/10/a4/39717ca669296dfc3a62928393168da88ac9d8cbec88b6321ffa62c6776f/scikit_learn-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7e7ced20582d3a5516fb6f405fd1d254e1f5ce712bfef2589f51326af6346e8", size = 11766512, upload-time = "2025-06-05T22:02:28.689Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/d5/cd/a19722241d5f7b51e08351e1e82453e0057aeb7621b17805f31fcb57bb6c/scikit_learn-1.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1babf2511e6ffd695da7a983b4e4d6de45dce39577b26b721610711081850906", size = 12461075, upload-time = "2025-06-05T22:02:31.233Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/ea/78/7357d12b2e4c6674175f9a09a3ba10498cde8340e622715bcc71e532981d/scikit_learn-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e39d95a929b112047c25b775035c8c234c5ca67e681ce60d12413afb501129f7", size = 12111822, upload-time = "2025-06-05T22:02:36.904Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/d0/0c/9c3715393343f04232f9d81fe540eb3831d0b4ec351135a145855295110f/scikit_learn-1.7.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:0521cb460426c56fee7e07f9365b0f45ec8ca7b2d696534ac98bfb85e7ae4775", size = 11325286, upload-time = "2025-06-05T22:02:39.739Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/64/e0/42282ad3dd70b7c1a5f65c412ac3841f6543502a8d6263cae7b466612dc9/scikit_learn-1.7.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:317ca9f83acbde2883bd6bb27116a741bfcb371369706b4f9973cf30e9a03b0d", size = 12380865, upload-time = "2025-06-05T22:02:42.137Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/74/88/0dd5be14ef19f2d80a77780be35a33aa94e8a3b3223d80bee8892a7832b4/scikit_learn-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:406204dd4004f0517f0b23cf4b28c6245cbd51ab1b6b78153bc784def214946d", size = 9338868, upload-time = "2025-07-18T08:01:00.25Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/fd/52/3056b6adb1ac58a0bc335fc2ed2fcf599974d908855e8cb0ca55f797593c/scikit_learn-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:16af2e44164f05d04337fd1fc3ae7c4ea61fd9b0d527e22665346336920fe0e1", size = 8655943, upload-time = "2025-07-18T08:01:02.974Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/fb/a4/e488acdece6d413f370a9589a7193dac79cd486b2e418d3276d6ea0b9305/scikit_learn-1.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2f2e78e56a40c7587dea9a28dc4a49500fa2ead366869418c66f0fd75b80885c", size = 9652056, upload-time = "2025-07-18T08:01:04.978Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/18/41/bceacec1285b94eb9e4659b24db46c23346d7e22cf258d63419eb5dec6f7/scikit_learn-1.7.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62b76ad408a821475b43b7bb90a9b1c9a4d8d125d505c2df0539f06d6e631b1", size = 9473691, upload-time = "2025-07-18T08:01:07.006Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/b4/bd/a23177930abd81b96daffa30ef9c54ddbf544d3226b8788ce4c3ef1067b4/scikit_learn-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90c8494ea23e24c0fb371afc474618c1019dc152ce4a10e4607e62196113851b", size = 9334838, upload-time = "2025-07-18T08:01:11.239Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/8d/a1/d3a7628630a711e2ac0d1a482910da174b629f44e7dd8cfcd6924a4ef81a/scikit_learn-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:bb870c0daf3bf3be145ec51df8ac84720d9972170786601039f024bf6d61a518", size = 8651241, upload-time = "2025-07-18T08:01:13.234Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/26/92/85ec172418f39474c1cd0221d611345d4f433fc4ee2fc68e01f524ccc4e4/scikit_learn-1.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40daccd1b5623f39e8943ab39735cadf0bdce80e67cdca2adcb5426e987320a8", size = 9718677, upload-time = "2025-07-18T08:01:15.649Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/df/ce/abdb1dcbb1d2b66168ec43b23ee0cee356b4cc4100ddee3943934ebf1480/scikit_learn-1.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30d1f413cfc0aa5a99132a554f1d80517563c34a9d3e7c118fde2d273c6fe0f7", size = 9511189, upload-time = "2025-07-18T08:01:18.013Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/cb/16/57f176585b35ed865f51b04117947fe20f130f78940c6477b6d66279c9c2/scikit_learn-1.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3cee419b49b5bbae8796ecd690f97aa412ef1674410c23fc3257c6b8b85b8087", size = 9260431, upload-time = "2025-07-18T08:01:22.77Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/67/4e/899317092f5efcab0e9bc929e3391341cec8fb0e816c4789686770024580/scikit_learn-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2fd8b8d35817b0d9ebf0b576f7d5ffbbabdb55536b0655a8aaae629d7ffd2e1f", size = 8637191, upload-time = "2025-07-18T08:01:24.731Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/f3/1b/998312db6d361ded1dd56b457ada371a8d8d77ca2195a7d18fd8a1736f21/scikit_learn-1.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:588410fa19a96a69763202f1d6b7b91d5d7a5d73be36e189bc6396bfb355bd87", size = 9486346, upload-time = "2025-07-18T08:01:26.713Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/ad/09/a2aa0b4e644e5c4ede7006748f24e72863ba2ae71897fecfd832afea01b4/scikit_learn-1.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3142f0abe1ad1d1c31a2ae987621e41f6b578144a911ff4ac94781a583adad7", size = 9290988, upload-time = "2025-07-18T08:01:28.938Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/52/f8/e0533303f318a0f37b88300d21f79b6ac067188d4824f1047a37214ab718/scikit_learn-1.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7839687fa46d02e01035ad775982f2470be2668e13ddd151f0f55a5bf123bae", size = 9213143, upload-time = "2025-07-18T08:01:32.942Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/71/f3/f1df377d1bdfc3e3e2adc9c119c238b182293e6740df4cbeac6de2cc3e23/scikit_learn-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a10f276639195a96c86aa572ee0698ad64ee939a7b042060b98bd1930c261d10", size = 8591977, upload-time = "2025-07-18T08:01:34.967Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/99/72/c86a4cd867816350fe8dee13f30222340b9cd6b96173955819a5561810c5/scikit_learn-1.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13679981fdaebc10cc4c13c43344416a86fcbc61449cb3e6517e1df9d12c8309", size = 9436142, upload-time = "2025-07-18T08:01:37.397Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/e8/66/277967b29bd297538dc7a6ecfb1a7dce751beabd0d7f7a2233be7a4f7832/scikit_learn-1.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f1262883c6a63f067a980a8cdd2d2e7f2513dddcef6a9eaada6416a7a7cbe43", size = 9282996, upload-time = "2025-07-18T08:01:39.721Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/61/95/45726819beccdaa34d3362ea9b2ff9f2b5d3b8bf721bd632675870308ceb/scikit_learn-1.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:781674d096303cfe3d351ae6963ff7c958db61cde3421cd490e3a5a58f2a94ae", size = 9561466, upload-time = "2025-07-18T08:01:44.195Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/ee/1c/6f4b3344805de783d20a51eb24d4c9ad4b11a7f75c1801e6ec6d777361fd/scikit_learn-1.7.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:10679f7f125fe7ecd5fad37dd1aa2daae7e3ad8df7f3eefa08901b8254b3e12c", size = 9040467, upload-time = "2025-07-18T08:01:46.671Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/6f/80/abe18fe471af9f1d181904203d62697998b27d9b62124cd281d740ded2f9/scikit_learn-1.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f812729e38c8cb37f760dce71a9b83ccfb04f59b3dca7c6079dcdc60544fa9e", size = 9532052, upload-time = "2025-07-18T08:01:48.676Z" }, | ||||
|     { url = "https://files.pythonhosted.org/packages/14/82/b21aa1e0c4cee7e74864d3a5a721ab8fcae5ca55033cb6263dca297ed35b/scikit_learn-1.7.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88e1a20131cf741b84b89567e1717f27a2ced228e0f29103426102bc2e3b8ef7", size = 9361575, upload-time = "2025-07-18T08:01:50.639Z" }, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
|   | ||||
		Reference in New Issue
	
	Block a user