Compare commits

..

3 Commits

Author SHA1 Message Date
shamoon
72cb733812 Add test 2025-11-03 17:34:12 -08:00
shamoon
3c49cdfc51 Add update_document flag to bulkEdit remove_password 2025-11-03 17:34:12 -08:00
shamoon
d4ac1fe9f5 Basic remove password bulk edit action 2025-11-03 17:34:09 -08:00
16 changed files with 154 additions and 48 deletions

View File

@@ -294,6 +294,9 @@ The following methods are supported:
- `"delete_original": true` to delete the original documents after editing. - `"delete_original": true` to delete the original documents after editing.
- `"update_document": true` to update the existing document with the edited PDF. - `"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. - `"include_metadata": true` to copy metadata from the original document to the edited document.
- `remove_password`
- Requires `parameters`:
- `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
- `merge` - `merge`
- No additional `parameters` required. - No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs. - The ordering of the merged document is determined by the list of IDs.

View File

@@ -1,41 +1,5 @@
# Changelog # Changelog
## paperless-ngx 2.19.4
### Bug Fixes
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
### Performance
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
### All App Changes
<details>
<summary>11 changes</summary>
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
- Chore: cache Github version check for 15 minutes [@shamoon](https://github.com/shamoon) ([#11235](https://github.com/paperless-ngx/paperless-ngx/pull/11235))
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
</details>
## paperless-ngx 2.19.3 ## paperless-ngx 2.19.3
### Bug Fixes ### Bug Fixes

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.19.4" version = "2.19.3"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.19.4", "version": "2.19.3",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",

View File

@@ -210,7 +210,6 @@
<pngx-input-tags <pngx-input-tags
[allowCreate]="false" [allowCreate]="false"
[title]="null" [title]="null"
[autoHeirarchy]="false"
formControlName="values" formControlName="values"
></pngx-input-tags> ></pngx-input-tags>
} @else if ( } @else if (

View File

@@ -103,9 +103,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input() @Input()
multiple: boolean = true multiple: boolean = true
@Input()
autoHeirarchy: boolean = true
@Output() @Output()
filterDocuments = new EventEmitter<Tag[]>() filterDocuments = new EventEmitter<Tag[]>()
@@ -137,7 +134,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1) oldValue.splice(index, 1)
// remove children // remove children
if (this.autoHeirarchy) oldValue = this.removeChildren(oldValue, tag) oldValue = this.removeChildren(oldValue, tag)
this.value = [...oldValue] this.value = [...oldValue]
this.onChange(this.value) this.onChange(this.value)
@@ -156,7 +153,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
public onAdd(tag: Tag) { public onAdd(tag: Tag) {
if (this.autoHeirarchy && tag.parent) { if (tag.parent) {
// add all parents recursively // add all parents recursively
const parent = this.getTag(tag.parent) const parent = this.getTag(tag.parent)
this.value = [...this.value, parent.id] this.value = [...this.value, parent.id]

View File

@@ -65,6 +65,12 @@
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF"> <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="pencil"></i-bs>&nbsp;<ng-container i18n>PDF Editor</ng-container> <i-bs name="pencil"></i-bs>&nbsp;<ng-container i18n>PDF Editor</ng-container>
</button> </button>
@if (requiresPassword || password) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!userIsOwner || !password">
<i-bs name="unlock"></i-bs>&nbsp;<ng-container i18n>Remove Password</ng-container>
</button>
}
</div> </div>
</div> </div>

View File

@@ -1209,6 +1209,24 @@ describe('DocumentDetailComponent', () => {
expect(closeSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled()
}) })
it('should support removing password protection from pdfs', () => {
initNormally()
component.password = 'secret'
component.removePassword()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'remove_password',
parameters: {
password: 'secret',
update_document: true,
},
})
req.flush(true)
})
it('should support keyboard shortcuts', () => { it('should support keyboard shortcuts', () => {
initNormally() initNormally()

View File

@@ -1428,6 +1428,38 @@ export class DocumentDetailComponent
}) })
} }
removePassword() {
if (this.requiresPassword || !this.password) {
this.toastService.showError(
$localize`Please enter the current password before attempting to remove it.`
)
return
}
this.networkActive = true
this.documentsService
.bulkEdit([this.document.id], 'remove_password', {
password: this.password,
update_document: true,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Password removal operation for "${this.document.title}" will begin in the background.`
)
this.networkActive = false
this.openDocumentService.refreshDocument(this.documentId)
},
error: (error) => {
this.networkActive = false
this.toastService.showError(
$localize`Error executing password removal operation`,
error
)
},
})
}
printDocument() { printDocument() {
const printUrl = this.documentsService.getDownloadUrl( const printUrl = this.documentsService.getDownloadUrl(
this.document.id, this.document.id,

View File

@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'prod', tag: 'prod',
version: '2.19.4', version: '2.19.3',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -132,6 +132,7 @@ import {
threeDotsVertical, threeDotsVertical,
trash, trash,
uiRadios, uiRadios,
unlock,
upcScan, upcScan,
windowStack, windowStack,
x, x,
@@ -346,6 +347,7 @@ const icons = {
threeDotsVertical, threeDotsVertical,
trash, trash,
uiRadios, uiRadios,
unlock,
upcScan, upcScan,
windowStack, windowStack,
x, x,

View File

@@ -644,6 +644,77 @@ def edit_pdf(
return "OK" return "OK"
def remove_password(
doc_ids: list[int],
password: str,
*,
delete_original: bool = False,
update_document: bool = False,
include_metadata: bool = True,
user: User | None = None,
) -> Literal["OK"]:
"""
Remove password protection from PDF documents.
"""
import pikepdf
for doc_id in doc_ids:
doc = Document.objects.get(id=doc_id)
try:
logger.info(
f"Attempting password removal from document {doc_ids[0]}",
)
with pikepdf.open(doc.source_path, password=password) as pdf:
temp_path = doc.source_path.with_suffix(".tmp.pdf")
pdf.remove_unreferenced_resources()
pdf.save(temp_path)
if update_document:
# replace the original document with the unprotected one
temp_path.replace(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
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{doc.id}_unprotected.pdf"
)
temp_path.replace(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 removing password from document {doc.id}: {e}")
raise ValueError(
f"An error occurred while removing the password: {e}",
) from e
return "OK"
def reflect_doclinks( def reflect_doclinks(
document: Document, document: Document,
field: CustomField, field: CustomField,

View File

@@ -1400,6 +1400,7 @@ class BulkEditSerializer(
"split", "split",
"delete_pages", "delete_pages",
"edit_pdf", "edit_pdf",
"remove_password",
], ],
label="Method", label="Method",
write_only=True, write_only=True,
@@ -1475,6 +1476,8 @@ class BulkEditSerializer(
return bulk_edit.delete_pages return bulk_edit.delete_pages
elif method == "edit_pdf": elif method == "edit_pdf":
return bulk_edit.edit_pdf return bulk_edit.edit_pdf
elif method == "remove_password":
return bulk_edit.remove_password
else: # pragma: no cover else: # pragma: no cover
# This will never happen as it is handled by the ChoiceField # This will never happen as it is handled by the ChoiceField
raise serializers.ValidationError("Unsupported method.") raise serializers.ValidationError("Unsupported method.")
@@ -1671,6 +1674,12 @@ class BulkEditSerializer(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
) )
def validate_parameters_remove_password(self, parameters):
if "password" not in parameters:
raise serializers.ValidationError("password not specified")
if not isinstance(parameters["password"], str):
raise serializers.ValidationError("password must be a string")
def validate(self, attrs): def validate(self, attrs):
method = attrs["method"] method = attrs["method"]
parameters = attrs["parameters"] parameters = attrs["parameters"]
@@ -1711,6 +1720,8 @@ class BulkEditSerializer(
"Edit PDF method only supports one document", "Edit PDF method only supports one document",
) )
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0]) self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
elif method == bulk_edit.remove_password:
self.validate_parameters_remove_password(parameters)
return attrs return attrs

View File

@@ -1486,6 +1486,7 @@ class BulkEditView(PassUserMixin):
"merge": None, "merge": None,
"edit_pdf": "checksum", "edit_pdf": "checksum",
"reprocess": "checksum", "reprocess": "checksum",
"remove_password": "checksum",
} }
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
@@ -1504,6 +1505,7 @@ class BulkEditView(PassUserMixin):
bulk_edit.split, bulk_edit.split,
bulk_edit.merge, bulk_edit.merge,
bulk_edit.edit_pdf, bulk_edit.edit_pdf,
bulk_edit.remove_password,
]: ]:
parameters["user"] = user parameters["user"] = user
@@ -1532,6 +1534,7 @@ class BulkEditView(PassUserMixin):
bulk_edit.rotate, bulk_edit.rotate,
bulk_edit.delete_pages, bulk_edit.delete_pages,
bulk_edit.edit_pdf, bulk_edit.edit_pdf,
bulk_edit.remove_password,
] ]
) )
or ( or (
@@ -1548,7 +1551,7 @@ class BulkEditView(PassUserMixin):
and ( and (
method in [bulk_edit.split, bulk_edit.merge] method in [bulk_edit.split, bulk_edit.merge]
or ( or (
method == bulk_edit.edit_pdf method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and not parameters["update_document"] and not parameters["update_document"]
) )
) )

View File

@@ -1,6 +1,6 @@
from typing import Final from typing import Final
__version__: Final[tuple[int, int, int]] = (2, 19, 4) __version__: Final[tuple[int, int, int]] = (2, 19, 3)
# Version string like X.Y.Z # Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__)) __full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y # Version string like X.Y

2
uv.lock generated
View File

@@ -2115,7 +2115,7 @@ wheels = [
[[package]] [[package]]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.19.4" version = "2.19.3"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },