From 1cdd8d9ba8900dcd54bc7b6595f1ee8f25f12579 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:32:21 -0700 Subject: [PATCH 01/14] Clarify repo maintenance rules --- .github/workflows/repo-maintenance.yml | 1 + CONTRIBUTING.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repo-maintenance.yml b/.github/workflows/repo-maintenance.yml index 0cfc57938..ae473da37 100644 --- a/.github/workflows/repo-maintenance.yml +++ b/.github/workflows/repo-maintenance.yml @@ -241,6 +241,7 @@ jobs: ) { nodes { id, + createdAt, number, updatedAt, upvoteCount, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24f563c7a..20d7bfd36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. - Discussions with a marked answer will be automatically closed. - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. -- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years. +- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years. In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. From 19a54b3b23d3e7a8e00f322562a0b50f6b99d056 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:17:42 -0700 Subject: [PATCH 02/14] Feature: processed mail UI (#10866) --- docs/usage.md | 4 + .../manage/mail/mail.component.html | 22 +- .../manage/mail/mail.component.spec.ts | 9 + .../components/manage/mail/mail.component.ts | 9 + .../processed-mail-dialog.component.html | 107 +++++++ .../processed-mail-dialog.component.scss | 8 + .../processed-mail-dialog.component.spec.ts | 150 +++++++++ .../processed-mail-dialog.component.ts | 96 ++++++ src-ui/src/app/data/processed-mail.ts | 12 + .../src/app/services/permissions.service.ts | 1 + .../rest/processed-mail.service.spec.ts | 39 +++ .../services/rest/processed-mail.service.ts | 19 ++ src-ui/src/main.ts | 4 + src/paperless/urls.py | 2 + src/paperless_mail/filters.py | 12 + src/paperless_mail/serialisers.py | 18 ++ src/paperless_mail/tests/test_api.py | 284 ++++++++++++++++++ src/paperless_mail/views.py | 36 +++ 18 files changed, 824 insertions(+), 8 deletions(-) create mode 100644 src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html create mode 100644 src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss create mode 100644 src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts create mode 100644 src-ui/src/app/data/processed-mail.ts create mode 100644 src-ui/src/app/services/rest/processed-mail.service.spec.ts create mode 100644 src-ui/src/app/services/rest/processed-mail.service.ts create mode 100644 src/paperless_mail/filters.py diff --git a/docs/usage.md b/docs/usage.md index 94ef5ae1b..32441862d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -261,6 +261,10 @@ different means. These are as follows: Paperless is set up to check your mails every 10 minutes. This can be configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON) +#### Processed Mail + +Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs. + #### OAuth Email Setup Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly. diff --git a/src-ui/src/app/components/manage/mail/mail.component.html b/src-ui/src/app/components/manage/mail/mail.component.html index 16e8e88fb..97b2bf507 100644 --- a/src-ui/src/app/components/manage/mail/mail.component.html +++ b/src-ui/src/app/components/manage/mail/mail.component.html @@ -109,10 +109,11 @@
  • Name
    -
    Sort Order
    -
    Account
    -
    Status
    -
    Actions
    +
    Sort Order
    +
    Account
    +
    Status
    +
    Processed Mail
    +
    Actions
  • @@ -127,9 +128,9 @@
  • -
    {{rule.order}}
    -
    {{(mailAccountService.getCached(rule.account) | async)?.name}}
    -
    +
    {{rule.order}}
    +
    {{(mailAccountService.getCached(rule.account) | async)?.name}}
    +
    -
    +
    + +
    +
    + + Read more + + + +
    + diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss new file mode 100644 index 000000000..6aadd8330 --- /dev/null +++ b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss @@ -0,0 +1,8 @@ +::ng-deep .popover { + max-width: 350px; + + pre { + white-space: pre-wrap; + word-break: break-word; + } +} diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts new file mode 100644 index 000000000..c34c97ef2 --- /dev/null +++ b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts @@ -0,0 +1,150 @@ +import { DatePipe } from '@angular/common' +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { FormsModule } from '@angular/forms' +import { By } from '@angular/platform-browser' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ProcessedMailDialogComponent } from './processed-mail-dialog.component' + +describe('ProcessedMailDialogComponent', () => { + let component: ProcessedMailDialogComponent + let fixture: ComponentFixture + let httpTestingController: HttpTestingController + let toastService: ToastService + + const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests + const mails = [ + { + id: 1, + rule: rule.id, + folder: 'INBOX', + uid: 111, + subject: 'A', + received: new Date().toISOString(), + processed: new Date().toISOString(), + status: 'SUCCESS', + error: null, + }, + { + id: 2, + rule: rule.id, + folder: 'INBOX', + uid: 222, + subject: 'B', + received: new Date().toISOString(), + processed: new Date().toISOString(), + status: 'FAILED', + error: 'Oops', + }, + ] + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + ProcessedMailDialogComponent, + FormsModule, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + DatePipe, + NgbActiveModal, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }).compileComponents() + + httpTestingController = TestBed.inject(HttpTestingController) + toastService = TestBed.inject(ToastService) + fixture = TestBed.createComponent(ProcessedMailDialogComponent) + component = fixture.componentInstance + component.rule = rule + }) + + afterEach(() => { + httpTestingController.verify() + }) + + function expectListRequest(ruleId: number) { + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}` + ) + expect(req.request.method).toEqual('GET') + return req + } + + it('should load processed mails on init', () => { + fixture.detectChanges() + const req = expectListRequest(rule.id) + req.flush({ count: 2, results: mails }) + expect(component.loading).toBeFalsy() + expect(component.processedMails).toEqual(mails) + }) + + it('should delete selected mails and reload', () => { + fixture.detectChanges() + // initial load + const initialReq = expectListRequest(rule.id) + initialReq.flush({ count: 0, results: [] }) + + // select a couple of mails and delete + component.selectedMailIds.add(5) + component.selectedMailIds.add(6) + const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + component.deleteSelected() + + const delReq = httpTestingController.expectOne( + `${environment.apiBaseUrl}processed_mail/bulk_delete/` + ) + expect(delReq.request.method).toEqual('POST') + expect(delReq.request.body).toEqual({ mail_ids: [5, 6] }) + delReq.flush({}) + + // reload after delete + const reloadReq = expectListRequest(rule.id) + reloadReq.flush({ count: 0, results: [] }) + expect(toastInfoSpy).toHaveBeenCalled() + }) + + it('should toggle all, toggle selected, and clear selection', () => { + fixture.detectChanges() + // initial load with two mails + const req = expectListRequest(rule.id) + req.flush({ count: 2, results: mails }) + fixture.detectChanges() + + // toggle all via header checkbox + const inputs = fixture.debugElement.queryAll( + By.css('input.form-check-input') + ) + const header = inputs[0].nativeElement as HTMLInputElement + header.dispatchEvent(new Event('click')) + header.checked = true + header.dispatchEvent(new Event('click')) + expect(component.selectedMailIds.size).toEqual(mails.length) + + // toggle a single mail + component.toggleSelected(mails[0] as any) + expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy() + component.toggleSelected(mails[0] as any) + expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy() + + // clear selection + component.clearSelection() + expect(component.selectedMailIds.size).toEqual(0) + expect(component.toggleAllEnabled).toBeFalsy() + }) + + it('should close the dialog', () => { + const activeModal = TestBed.inject(NgbActiveModal) + const closeSpy = jest.spyOn(activeModal, 'close') + component.close() + expect(closeSpy).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts new file mode 100644 index 000000000..ed51ad0ed --- /dev/null +++ b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts @@ -0,0 +1,96 @@ +import { SlicePipe } from '@angular/common' +import { Component, inject, Input, OnInit } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { + NgbActiveModal, + NgbPagination, + NgbPopoverModule, + NgbTooltipModule, +} from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component' +import { MailRule } from 'src/app/data/mail-rule' +import { ProcessedMail } from 'src/app/data/processed-mail' +import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' +import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service' +import { ToastService } from 'src/app/services/toast.service' + +@Component({ + selector: 'pngx-processed-mail-dialog', + imports: [ + ConfirmButtonComponent, + CustomDatePipe, + NgbPagination, + NgbPopoverModule, + NgbTooltipModule, + NgxBootstrapIconsModule, + FormsModule, + ReactiveFormsModule, + SlicePipe, + ], + templateUrl: './processed-mail-dialog.component.html', + styleUrl: './processed-mail-dialog.component.scss', +}) +export class ProcessedMailDialogComponent implements OnInit { + private readonly activeModal = inject(NgbActiveModal) + private readonly processedMailService = inject(ProcessedMailService) + private readonly toastService = inject(ToastService) + + public processedMails: ProcessedMail[] = [] + + public loading: boolean = true + public toggleAllEnabled: boolean = false + public readonly selectedMailIds: Set = new Set() + + public page: number = 1 + + @Input() rule: MailRule + + ngOnInit(): void { + this.loadProcessedMails() + } + + public close() { + this.activeModal.close() + } + + private loadProcessedMails(): void { + this.loading = true + this.clearSelection() + this.processedMailService + .list(this.page, 50, 'processed_at', true, { rule: this.rule.id }) + .subscribe((result) => { + this.processedMails = result.results + this.loading = false + }) + } + + public deleteSelected(): void { + this.processedMailService + .bulk_delete(Array.from(this.selectedMailIds)) + .subscribe(() => { + this.toastService.showInfo($localize`Processed mail(s) deleted`) + this.loadProcessedMails() + }) + } + + public toggleAll(event: PointerEvent) { + if ((event.target as HTMLInputElement).checked) { + this.selectedMailIds.clear() + this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id)) + } else { + this.clearSelection() + } + } + + public clearSelection() { + this.toggleAllEnabled = false + this.selectedMailIds.clear() + } + + public toggleSelected(mail: ProcessedMail) { + this.selectedMailIds.has(mail.id) + ? this.selectedMailIds.delete(mail.id) + : this.selectedMailIds.add(mail.id) + } +} diff --git a/src-ui/src/app/data/processed-mail.ts b/src-ui/src/app/data/processed-mail.ts new file mode 100644 index 000000000..7eacf2415 --- /dev/null +++ b/src-ui/src/app/data/processed-mail.ts @@ -0,0 +1,12 @@ +import { ObjectWithId } from './object-with-id' + +export interface ProcessedMail extends ObjectWithId { + rule: number // MailRule.id + folder: string + uid: number + subject: string + received: Date + processed: Date + status: string + error: string +} diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts index 3d88b10cc..0c36b646f 100644 --- a/src-ui/src/app/services/permissions.service.ts +++ b/src-ui/src/app/services/permissions.service.ts @@ -28,6 +28,7 @@ export enum PermissionType { ShareLink = '%s_sharelink', CustomField = '%s_customfield', Workflow = '%s_workflow', + ProcessedMail = '%s_processedmail', } @Injectable({ diff --git a/src-ui/src/app/services/rest/processed-mail.service.spec.ts b/src-ui/src/app/services/rest/processed-mail.service.spec.ts new file mode 100644 index 000000000..a424c2cbb --- /dev/null +++ b/src-ui/src/app/services/rest/processed-mail.service.spec.ts @@ -0,0 +1,39 @@ +import { HttpTestingController } from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { Subscription } from 'rxjs' +import { environment } from 'src/environments/environment' +import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' +import { ProcessedMailService } from './processed-mail.service' + +let httpTestingController: HttpTestingController +let service: ProcessedMailService +let subscription: Subscription +const endpoint = 'processed_mail' + +// run common tests +commonAbstractPaperlessServiceTests(endpoint, ProcessedMailService) + +describe('Additional service tests for ProcessedMailService', () => { + beforeEach(() => { + // Dont need to setup again + + httpTestingController = TestBed.inject(HttpTestingController) + service = TestBed.inject(ProcessedMailService) + }) + + afterEach(() => { + subscription?.unsubscribe() + httpTestingController.verify() + }) + + it('should call appropriate api endpoint for bulk delete', () => { + const ids = [1, 2, 3] + subscription = service.bulk_delete(ids).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/bulk_delete/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ mail_ids: ids }) + req.flush({}) + }) +}) diff --git a/src-ui/src/app/services/rest/processed-mail.service.ts b/src-ui/src/app/services/rest/processed-mail.service.ts new file mode 100644 index 000000000..e1ea327da --- /dev/null +++ b/src-ui/src/app/services/rest/processed-mail.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core' +import { ProcessedMail } from 'src/app/data/processed-mail' +import { AbstractPaperlessService } from './abstract-paperless-service' + +@Injectable({ + providedIn: 'root', +}) +export class ProcessedMailService extends AbstractPaperlessService { + constructor() { + super() + this.resourceName = 'processed_mail' + } + + public bulk_delete(mailIds: number[]) { + return this.http.post(`${this.getResourceUrl()}bulk_delete/`, { + mail_ids: mailIds, + }) + } +} diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index cd1f4ef59..7e57edcea 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -51,6 +51,7 @@ import { check, check2All, checkAll, + checkCircle, checkCircleFill, checkLg, chevronDoubleLeft, @@ -60,6 +61,7 @@ import { clipboardCheck, clipboardCheckFill, clipboardFill, + clockHistory, dash, dashCircle, diagram3, @@ -263,6 +265,7 @@ const icons = { check, check2All, checkAll, + checkCircle, checkCircleFill, checkLg, chevronDoubleLeft, @@ -272,6 +275,7 @@ const icons = { clipboardCheck, clipboardCheckFill, clipboardFill, + clockHistory, dash, dashCircle, diagram3, diff --git a/src/paperless/urls.py b/src/paperless/urls.py index c37331ce2..e24d1a459 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -57,6 +57,7 @@ from paperless.views import UserViewSet from paperless_mail.views import MailAccountViewSet from paperless_mail.views import MailRuleViewSet from paperless_mail.views import OauthCallbackView +from paperless_mail.views import ProcessedMailViewSet api_router = DefaultRouter() api_router.register(r"correspondents", CorrespondentViewSet) @@ -77,6 +78,7 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet) api_router.register(r"workflows", WorkflowViewSet) api_router.register(r"custom_fields", CustomFieldViewSet) api_router.register(r"config", ApplicationConfigurationViewSet) +api_router.register(r"processed_mail", ProcessedMailViewSet) urlpatterns = [ diff --git a/src/paperless_mail/filters.py b/src/paperless_mail/filters.py new file mode 100644 index 000000000..57b8dec77 --- /dev/null +++ b/src/paperless_mail/filters.py @@ -0,0 +1,12 @@ +from django_filters import FilterSet + +from paperless_mail.models import ProcessedMail + + +class ProcessedMailFilterSet(FilterSet): + class Meta: + model = ProcessedMail + fields = { + "rule": ["exact"], + "status": ["exact"], + } diff --git a/src/paperless_mail/serialisers.py b/src/paperless_mail/serialisers.py index fa025fcbe..b38c8e78c 100644 --- a/src/paperless_mail/serialisers.py +++ b/src/paperless_mail/serialisers.py @@ -6,6 +6,7 @@ from documents.serialisers import OwnedObjectSerializer from documents.serialisers import TagsField from paperless_mail.models import MailAccount from paperless_mail.models import MailRule +from paperless_mail.models import ProcessedMail class ObfuscatedPasswordField(serializers.CharField): @@ -130,3 +131,20 @@ class MailRuleSerializer(OwnedObjectSerializer): if value > 36500: # ~100 years raise serializers.ValidationError("Maximum mail age is unreasonably large.") return value + + +class ProcessedMailSerializer(OwnedObjectSerializer): + class Meta: + model = ProcessedMail + fields = [ + "id", + "owner", + "rule", + "folder", + "uid", + "subject", + "received", + "processed", + "status", + "error", + ] diff --git a/src/paperless_mail/tests/test_api.py b/src/paperless_mail/tests/test_api.py index 3ba06a746..dd63c67ab 100644 --- a/src/paperless_mail/tests/test_api.py +++ b/src/paperless_mail/tests/test_api.py @@ -3,6 +3,7 @@ from unittest import mock from django.contrib.auth.models import Permission from django.contrib.auth.models import User +from django.utils import timezone from guardian.shortcuts import assign_perm from rest_framework import status from rest_framework.test import APITestCase @@ -13,6 +14,7 @@ from documents.models import Tag from documents.tests.utils import DirectoriesMixin from paperless_mail.models import MailAccount from paperless_mail.models import MailRule +from paperless_mail.models import ProcessedMail from paperless_mail.tests.test_mail import BogusMailBox @@ -721,3 +723,285 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("maximum_age", response.data) + + +class TestAPIProcessedMails(DirectoriesMixin, APITestCase): + ENDPOINT = "/api/processed_mail/" + + def setUp(self): + super().setUp() + + self.user = User.objects.create_user(username="temp_admin") + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.client.force_authenticate(user=self.user) + + def test_get_processed_mails_owner_aware(self): + """ + GIVEN: + - Configured processed mails with different users + WHEN: + - API call is made to get processed mails + THEN: + - Only unowned, owned by user or granted processed mails are provided + """ + user2 = User.objects.create_user(username="temp_admin2") + + account = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + + rule = MailRule.objects.create( + name="Rule1", + account=account, + folder="INBOX", + filter_from="from@example.com", + order=0, + ) + + pm1 = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="1", + subject="Subj1", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + + pm2 = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="2", + subject="Subj2", + received=timezone.now(), + processed=timezone.now(), + status="FAILED", + error="err", + owner=self.user, + ) + + ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="3", + subject="Subj3", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=user2, + ) + + pm4 = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="4", + subject="Subj4", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + pm4.owner = user2 + pm4.save() + assign_perm("view_processedmail", self.user, pm4) + + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + returned_ids = {r["id"] for r in response.data["results"]} + self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id}) + + def test_get_processed_mails_filter_by_rule(self): + """ + GIVEN: + - Processed mails belonging to two different rules + WHEN: + - API call is made with rule filter + THEN: + - Only processed mails for that rule are returned + """ + account = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + + rule1 = MailRule.objects.create( + name="Rule1", + account=account, + folder="INBOX", + filter_from="from1@example.com", + order=0, + ) + rule2 = MailRule.objects.create( + name="Rule2", + account=account, + folder="INBOX", + filter_from="from2@example.com", + order=1, + ) + + pm1 = ProcessedMail.objects.create( + rule=rule1, + folder="INBOX", + uid="r1-1", + subject="R1-A", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=self.user, + ) + pm2 = ProcessedMail.objects.create( + rule=rule1, + folder="INBOX", + uid="r1-2", + subject="R1-B", + received=timezone.now(), + processed=timezone.now(), + status="FAILED", + error="e", + ) + ProcessedMail.objects.create( + rule=rule2, + folder="INBOX", + uid="r2-1", + subject="R2-A", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + + response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + returned_ids = {r["id"] for r in response.data["results"]} + self.assertSetEqual(returned_ids, {pm1.id, pm2.id}) + + def test_bulk_delete_processed_mails(self): + """ + GIVEN: + - Processed mails belonging to two different rules and different users + WHEN: + - API call is made to bulk delete some of the processed mails + THEN: + - Only the specified processed mails are deleted, respecting ownership and permissions + """ + user2 = User.objects.create_user(username="temp_admin2") + + account = MailAccount.objects.create( + name="Email1", + username="username1", + password="password1", + imap_server="server.example.com", + imap_port=443, + imap_security=MailAccount.ImapSecurity.SSL, + character_set="UTF-8", + ) + + rule = MailRule.objects.create( + name="Rule1", + account=account, + folder="INBOX", + filter_from="from@example.com", + order=0, + ) + + # unowned and owned by self, and one with explicit object perm + pm_unowned = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u1", + subject="Unowned", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + ) + pm_owned = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u2", + subject="Owned", + received=timezone.now(), + processed=timezone.now(), + status="FAILED", + error="e", + owner=self.user, + ) + pm_granted = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u3", + subject="Granted", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=user2, + ) + assign_perm("delete_processedmail", self.user, pm_granted) + pm_forbidden = ProcessedMail.objects.create( + rule=rule, + folder="INBOX", + uid="u4", + subject="Forbidden", + received=timezone.now(), + processed=timezone.now(), + status="SUCCESS", + error=None, + owner=user2, + ) + + # Success for allowed items + response = self.client.post( + f"{self.ENDPOINT}bulk_delete/", + data={ + "mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["result"], "OK") + self.assertSetEqual( + set(response.data["deleted_mail_ids"]), + {pm_unowned.id, pm_owned.id, pm_granted.id}, + ) + self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists()) + self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists()) + self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists()) + self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) + + # 403 and not deleted + response = self.client.post( + f"{self.ENDPOINT}bulk_delete/", + data={ + "mail_ids": [pm_forbidden.id], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) + + # missing mail_ids + response = self.client.post( + f"{self.ENDPOINT}bulk_delete/", + data={"mail_ids": "not-a-list"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index e48049a36..b54bcb5f7 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -3,8 +3,10 @@ import logging from datetime import timedelta from django.http import HttpResponseBadRequest +from django.http import HttpResponseForbidden from django.http import HttpResponseRedirect from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema_view @@ -12,23 +14,29 @@ from drf_spectacular.utils import inline_serializer from httpx_oauth.oauth2 import GetAccessTokenError from rest_framework import serializers from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ReadOnlyModelViewSet from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.permissions import PaperlessObjectPermissions +from documents.permissions import has_perms_owner_aware from documents.views import PassUserMixin from paperless.views import StandardPagination +from paperless_mail.filters import ProcessedMailFilterSet from paperless_mail.mail import MailError from paperless_mail.mail import get_mailbox from paperless_mail.mail import mailbox_login from paperless_mail.models import MailAccount from paperless_mail.models import MailRule +from paperless_mail.models import ProcessedMail from paperless_mail.oauth import PaperlessMailOAuth2Manager from paperless_mail.serialisers import MailAccountSerializer from paperless_mail.serialisers import MailRuleSerializer +from paperless_mail.serialisers import ProcessedMailSerializer from paperless_mail.tasks import process_mail_accounts @@ -126,6 +134,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): return Response({"result": "OK"}) +class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin): + permission_classes = (IsAuthenticated, PaperlessObjectPermissions) + serializer_class = ProcessedMailSerializer + pagination_class = StandardPagination + filter_backends = ( + DjangoFilterBackend, + OrderingFilter, + ObjectOwnedOrGrantedPermissionsFilter, + ) + filterset_class = ProcessedMailFilterSet + + queryset = ProcessedMail.objects.all().order_by("-processed") + + @action(methods=["post"], detail=False) + def bulk_delete(self, request): + mail_ids = request.data.get("mail_ids", []) + if not isinstance(mail_ids, list) or not all( + isinstance(i, int) for i in mail_ids + ): + return HttpResponseBadRequest("mail_ids must be a list of integers") + mails = ProcessedMail.objects.filter(id__in=mail_ids) + for mail in mails: + if not has_perms_owner_aware(request.user, "delete_processedmail", mail): + return HttpResponseForbidden("Insufficient permissions") + mail.delete() + return Response({"result": "OK", "deleted_mail_ids": mail_ids}) + + class MailRuleViewSet(ModelViewSet, PassUserMixin): model = MailRule From c8850fa7527d010a071ff90dfd740f68e5726775 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 18:21:26 +0000 Subject: [PATCH 03/14] Auto translate strings --- src-ui/messages.xlf | 160 ++++++++++++++++++------- src/locale/en_US/LC_MESSAGES/django.po | 4 +- 2 files changed, 121 insertions(+), 43 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index a8cf235a6..17f1bedf5 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -755,11 +755,15 @@ src/app/components/manage/mail/mail.component.html - 122 + 123 src/app/components/manage/mail/mail.component.html - 186 + 192 + + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 16 src/app/components/manage/management-list/management-list.component.html @@ -972,6 +976,10 @@ src/app/components/common/permissions-select/permissions-select.component.html 4 + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 3 + Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. @@ -1217,11 +1225,11 @@ src/app/components/manage/mail/mail.component.html - 148 + 154 src/app/components/manage/mail/mail.component.html - 160 + 166 src/app/components/manage/management-list/management-list.component.html @@ -1812,7 +1820,7 @@ src/app/components/manage/mail/mail.component.html - 115 + 116 src/app/components/manage/management-list/management-list.component.html @@ -2004,6 +2012,14 @@ src/app/components/admin/trash/trash.component.html 14 + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 87 + + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 89 + Empty trash @@ -2113,11 +2129,11 @@ src/app/components/manage/mail/mail.component.html - 149 + 155 src/app/components/manage/mail/mail.component.html - 163 + 169 src/app/components/manage/management-list/management-list.component.html @@ -2241,11 +2257,11 @@ src/app/components/manage/mail/mail.component.ts - 191 + 192 src/app/components/manage/mail/mail.component.ts - 292 + 293 src/app/components/manage/management-list/management-list.component.ts @@ -2432,11 +2448,11 @@ src/app/components/manage/mail/mail.component.html - 147 + 153 src/app/components/manage/mail/mail.component.html - 157 + 163 src/app/components/manage/management-list/management-list.component.html @@ -2568,11 +2584,11 @@ src/app/components/manage/mail/mail.component.ts - 193 + 194 src/app/components/manage/mail/mail.component.ts - 294 + 295 src/app/components/manage/management-list/management-list.component.ts @@ -3129,6 +3145,10 @@ src/app/components/common/clearable-badge/clearable-badge.component.html 2 + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 85 + Are you sure? @@ -3896,7 +3916,7 @@ src/app/components/manage/mail/mail.component.html - 136 + 137 src/app/components/manage/workflows/workflows.component.html @@ -4106,6 +4126,10 @@ src/app/components/common/toast/toast.component.html 30 + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 36 + Only process attachments @@ -5109,6 +5133,10 @@ src/app/components/common/email-document-dialog/email-document-dialog.component.html 11 + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 32 + Message @@ -5478,6 +5506,10 @@ src/app/components/common/permissions-select/permissions-select.component.html 9 + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 7 + Select all pages @@ -5745,11 +5777,11 @@ src/app/components/manage/mail/mail.component.html - 150 + 156 src/app/components/manage/mail/mail.component.html - 168 + 174 src/app/components/manage/workflows/workflows.component.html @@ -6127,6 +6159,10 @@ src/app/components/manage/mail/mail.component.html 114 + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 35 + src/app/components/manage/workflows/workflows.component.html 19 @@ -8517,185 +8553,227 @@ Disabled src/app/components/manage/mail/mail.component.html - 136 + 137 src/app/components/manage/workflows/workflows.component.html 41 + + View Processed Mail + + src/app/components/manage/mail/mail.component.html + 143 + + No mail rules defined. src/app/components/manage/mail/mail.component.html - 177 + 183 Error retrieving mail accounts src/app/components/manage/mail/mail.component.ts - 104 + 105 Error retrieving mail rules src/app/components/manage/mail/mail.component.ts - 126 + 127 OAuth2 authentication success src/app/components/manage/mail/mail.component.ts - 134 + 135 OAuth2 authentication failed, see logs for details src/app/components/manage/mail/mail.component.ts - 145 + 146 Saved account "". src/app/components/manage/mail/mail.component.ts - 169 + 170 Error saving account. src/app/components/manage/mail/mail.component.ts - 181 + 182 Confirm delete mail account src/app/components/manage/mail/mail.component.ts - 189 + 190 This operation will permanently delete this mail account. src/app/components/manage/mail/mail.component.ts - 190 + 191 Deleted mail account "" src/app/components/manage/mail/mail.component.ts - 200 + 201 Error deleting mail account "". src/app/components/manage/mail/mail.component.ts - 211 + 212 Processing mail account "" src/app/components/manage/mail/mail.component.ts - 223 + 224 Error processing mail account "" src/app/components/manage/mail/mail.component.ts - 228 + 229 Saved rule "". src/app/components/manage/mail/mail.component.ts - 246 + 247 Error saving rule. src/app/components/manage/mail/mail.component.ts - 257 + 258 Rule "" enabled. src/app/components/manage/mail/mail.component.ts - 273 + 274 Rule "" disabled. src/app/components/manage/mail/mail.component.ts - 274 + 275 Error toggling rule "". src/app/components/manage/mail/mail.component.ts - 279 + 280 Confirm delete mail rule src/app/components/manage/mail/mail.component.ts - 290 + 291 This operation will permanently delete this mail rule. src/app/components/manage/mail/mail.component.ts - 291 + 292 Deleted mail rule "" src/app/components/manage/mail/mail.component.ts - 301 + 302 Error deleting mail rule "". src/app/components/manage/mail/mail.component.ts - 312 + 313 Permissions updated src/app/components/manage/mail/mail.component.ts - 336 + 337 Error updating permissions src/app/components/manage/mail/mail.component.ts - 341 + 342 src/app/components/manage/management-list/management-list.component.ts 339 + + Processed Mail for + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 2 + + + + No processed email messages found. + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 20 + + + + Received + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 33 + + + + Processed + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 34 + + + + Processed mail(s) deleted + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts + 72 + + Filter by: diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 1b3bc6d06..ebefeb420 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-17 22:44+0000\n" +"POT-Creation-Date: 2025-09-22 18:20+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1827,7 +1827,7 @@ msgstr "" msgid "Chinese Traditional" msgstr "" -#: paperless/urls.py:368 +#: paperless/urls.py:370 msgid "Paperless-ngx administration" msgstr "" From 8d1f23e9d61066b95e2e4a11d806e0651ddcf8a8 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:53:32 -0700 Subject: [PATCH 04/14] Chore: Enable SonarQube scanning (#10904) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .github/workflows/ci.yml | 91 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + sonar-project.properties | 24 +++++++++++ 3 files changed, 116 insertions(+) create mode 100644 sonar-project.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e28e537d7..edb6a5641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,18 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} flags: backend-python-${{ matrix.python-version }} files: coverage.xml + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: backend-coverage-${{ matrix.python-version }} + path: | + .coverage + coverage.xml + junit.xml + retention-days: 1 + include-hidden-files: true + if-no-files-found: error - name: Stop containers if: always() run: | @@ -233,6 +245,17 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} flags: frontend-node-${{ matrix.node-version }} directory: src-ui/coverage/ + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: frontend-coverage-${{ matrix.shard-index }} + path: | + src-ui/coverage/lcov.info + src-ui/coverage/coverage-final.json + src-ui/junit.xml + retention-days: 1 + if-no-files-found: error tests-frontend-e2e: name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" runs-on: ubuntu-24.04 @@ -313,6 +336,74 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: cd src-ui && pnpm run build --configuration=production + sonarqube-analysis: + name: "SonarQube Analysis" + runs-on: ubuntu-24.04 + needs: + - tests-backend + - tests-frontend + if: github.repository_owner == 'paperless-ngx' + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Download all backend coverage + uses: actions/download-artifact@v5.0.0 + with: + pattern: backend-coverage-* + path: ./coverage/ + - name: Download all frontend coverage + uses: actions/download-artifact@v5.0.0 + with: + pattern: frontend-coverage-* + path: ./coverage/ + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + - name: Install coverage tools + run: | + pip install coverage + npm install -g nyc + # Merge backend coverage from all Python versions + - name: Merge backend coverage + run: | + coverage combine coverage/backend-coverage-*/.coverage + coverage xml -o merged-backend-coverage.xml + # Merge frontend coverage from all shards + - name: Merge frontend coverage + run: | + # Find all coverage-final.json files from the shards, exit with error if none found + shopt -s nullglob + files=(coverage/frontend-coverage-*/coverage/coverage-final.json) + if [ ${#files[@]} -eq 0 ]; then + echo "No frontend coverage JSON found under coverage/" >&2 + exit 1 + fi + # Create .nyc_output directory and copy each shard's coverage JSON into it with a unique name + mkdir -p .nyc_output + for coverage_json in "${files[@]}"; do + shard=$(basename "$(dirname "$(dirname "$coverage_json")")") + cp "$coverage_json" ".nyc_output/${shard}.json" + done + npx nyc merge .nyc_output .nyc_output/out.json + npx nyc report --reporter=lcovonly --report-dir coverage + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4.6.2 + with: + name: merged-coverage + path: | + merged-backend-coverage.xml + .nyc_output/* + coverage/lcov.info + retention-days: 7 + if-no-files-found: error + include-hidden-files: true + - name: SonarQube Analysis + uses: SonarSource/sonarqube-scan-action@v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} build-docker-image: name: Build Docker image for ${{ github.ref_name }} runs-on: ubuntu-24.04 diff --git a/pyproject.toml b/pyproject.toml index a49e94f38..f3b270c77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -255,6 +255,7 @@ PAPERLESS_DISABLE_DBHANDLER = "true" PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" [tool.coverage.run] +relative_files = true source = [ "src/", ] diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..d9d341e87 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,24 @@ +sonar.projectKey=paperless-ngx_paperless-ngx +sonar.organization=paperless-ngx +sonar.projectName=Paperless-ngx +sonar.projectVersion=1.0 + +# Source and test directories +sonar.sources=src/,src-ui/ +sonar.test.inclusions=**/test_*.py,**/tests.py,**/*.spec.ts,**/*.test.ts + +# Language specific settings +sonar.python.version=3.10,3.11,3.12,3.13 + +# Coverage reports +sonar.python.coverage.reportPaths=merged-backend-coverage.xml +sonar.javascript.lcov.reportPaths=coverage/lcov.info + +# Test execution reports +sonar.junit.reportPaths=**/junit.xml,**/test-results.xml + +# Encoding +sonar.sourceEncoding=UTF-8 + +# Exclusions +sonar.exclusions=**/migrations/**,**/node_modules/**,**/static/**,**/venv/**,**/.venv/**,**/dist/** From 6119c215e7d8782f4a8a4a1a889462ddd2297b63 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:30:24 -0700 Subject: [PATCH 05/14] Fix: skip fuzzy matching for empty document content (#10914) --- .../commands/document_fuzzy_match.py | 3 +++ src/documents/tests/test_management_fuzzy.py | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/documents/management/commands/document_fuzzy_match.py b/src/documents/management/commands/document_fuzzy_match.py index 5eebeb172..4ecdf6d01 100644 --- a/src/documents/management/commands/document_fuzzy_match.py +++ b/src/documents/management/commands/document_fuzzy_match.py @@ -92,6 +92,9 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand): # doc to doc is obviously not useful if first_doc.pk == second_doc.pk: continue + # Skip empty documents (e.g. password-protected) + if first_doc.content.strip() == "" or second_doc.content.strip() == "": + continue # Skip matching which have already been matched together # doc 1 to doc 2 is the same as doc 2 to doc 1 doc_1_to_doc_2 = (first_doc.pk, second_doc.pk) diff --git a/src/documents/tests/test_management_fuzzy.py b/src/documents/tests/test_management_fuzzy.py index 2d7d3735a..453a86082 100644 --- a/src/documents/tests/test_management_fuzzy.py +++ b/src/documents/tests/test_management_fuzzy.py @@ -206,3 +206,29 @@ class TestFuzzyMatchCommand(TestCase): self.assertEqual(Document.objects.count(), 2) self.assertIsNotNone(Document.objects.get(pk=1)) self.assertIsNotNone(Document.objects.get(pk=2)) + + def test_empty_content(self): + """ + GIVEN: + - 2 documents exist, content is empty (pw-protected) + WHEN: + - Command is called + THEN: + - No matches are found + """ + Document.objects.create( + checksum="BEEFCAFE", + title="A", + content="", + mime_type="application/pdf", + filename="test.pdf", + ) + Document.objects.create( + checksum="DEADBEAF", + title="A", + content="", + mime_type="application/pdf", + filename="other_test.pdf", + ) + stdout, _ = self.call_command() + self.assertIn("No matches found", stdout) From 53b393dab556d61a07aea0df281c69a0b178174b Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:43:09 -0700 Subject: [PATCH 06/14] Chore: remove conditional from pre-commit job in CI (#10916) --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edb6a5641..44596b4a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,52 @@ env: DEFAULT_PYTHON_VERSION: "3.11" NLTK_DATA: "/usr/share/nltk_data" jobs: + detect-duplicate: + name: Detect Duplicate Run + runs-on: ubuntu-24.04 + outputs: + should_run: ${{ steps.check.outputs.should_run }} + steps: + - name: Check if workflow should run + id: check + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + if (context.eventName !== 'push') { + core.info('Not a push event; running workflow.'); + core.setOutput('should_run', 'true'); + return; + } + + const ref = context.ref || ''; + if (!ref.startsWith('refs/heads/')) { + core.info('Push is not to a branch; running workflow.'); + core.setOutput('should_run', 'true'); + return; + } + + const branch = ref.substring('refs/heads/'.length); + const { owner, repo } = context.repo; + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + head: `${owner}:${branch}`, + per_page: 100, + }); + + if (prs.length === 0) { + core.info(`No open PR found for ${branch}; running workflow.`); + core.setOutput('should_run', 'true'); + } else { + core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`); + core.setOutput('should_run', 'false'); + } pre-commit: - # We want to run on external PRs, but not on our own internal PRs as they'll be run - # by the push to the branch. Without this if check, checks are duplicated since - # internal PRs match both the push and pull_request events. - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository + needs: + - detect-duplicate + if: needs.detect-duplicate.outputs.should_run == 'true' name: Linting Checks runs-on: ubuntu-24.04 steps: From 4ff09c4cf45f6649bc626d23b94c954895c41f7c Mon Sep 17 00:00:00 2001 From: DerRockWolf <50499906+DerRockWolf@users.noreply.github.com> Date: Wed, 24 Sep 2025 23:03:03 +0200 Subject: [PATCH 07/14] Enhancement: support workflow path matching of barcode-split documents (#10723) --- src/documents/barcodes.py | 3 +++ src/documents/data_models.py | 1 + src/documents/matching.py | 10 +++++++++- src/documents/tests/test_barcodes.py | 4 +++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/documents/barcodes.py b/src/documents/barcodes.py index 6742e6704..9054475f4 100644 --- a/src/documents/barcodes.py +++ b/src/documents/barcodes.py @@ -164,6 +164,9 @@ class BarcodePlugin(ConsumeTaskPlugin): mailrule_id=self.input_doc.mailrule_id, # Can't use same folder or the consume might grab it again original_file=(tmp_dir / new_document.name).resolve(), + # Adding optional original_path for later uses in + # workflow matching + original_path=self.input_doc.original_file, ), # All the same metadata self.metadata, diff --git a/src/documents/data_models.py b/src/documents/data_models.py index fbba36dcc..7f98a1f05 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -156,6 +156,7 @@ class ConsumableDocument: source: DocumentSource original_file: Path + original_path: Path | None = None mailrule_id: int | None = None mime_type: str = dataclasses.field(init=False, default=None) diff --git a/src/documents/matching.py b/src/documents/matching.py index 2088a6042..72f1af5cf 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -314,11 +314,19 @@ def consumable_document_matches_workflow( trigger_matched = False # Document path vs trigger path + + # Use the original_path if set, else us the original_file + match_against = ( + document.original_path + if document.original_path is not None + else document.original_file + ) + if ( trigger.filter_path is not None and len(trigger.filter_path) > 0 and not fnmatch( - document.original_file, + match_against, trigger.filter_path, ) ): diff --git a/src/documents/tests/test_barcodes.py b/src/documents/tests/test_barcodes.py index b2c28a82b..0ad98344d 100644 --- a/src/documents/tests/test_barcodes.py +++ b/src/documents/tests/test_barcodes.py @@ -614,14 +614,16 @@ class TestBarcodeNewConsume( self.assertIsNotFile(temp_copy) # Check the split files exist + # Check the original_path is set # Check the source is unchanged # Check the overrides are unchanged for ( new_input_doc, new_doc_overrides, ) in self.get_all_consume_delay_call_args(): - self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) self.assertIsFile(new_input_doc.original_file) + self.assertEqual(new_input_doc.original_path, temp_copy) + self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) self.assertEqual(overrides, new_doc_overrides) From 5e4706993471e0d2a4b2d801586d6538c2e1d296 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:42:43 -0700 Subject: [PATCH 08/14] Fix select option removal and pagination update (#10933) --- .../custom-field-edit-dialog.component.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts index 617d825b2..8e8bddfab 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts @@ -177,10 +177,16 @@ export class CustomFieldEditDialogComponent } public removeSelectOption(index: number) { - this.selectOptions.removeAt(index) - this._allSelectOptions.splice( - index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE, - 1 + const globalIndex = + index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE + this._allSelectOptions.splice(globalIndex, 1) + + const totalPages = Math.max( + 1, + Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE) ) + const targetPage = Math.min(this.selectOptionsPage, totalPages) + + this.selectOptionsPage = targetPage } } From 764ad059d16aaeea59caa96736f7831fc6800008 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:45:36 -0700 Subject: [PATCH 09/14] Revert "Chore: Enable SonarQube scanning (#10904)" (#10934) This reverts commit 8d1f23e9d61066b95e2e4a11d806e0651ddcf8a8. --- .github/workflows/ci.yml | 91 ---------------------------------------- pyproject.toml | 1 - sonar-project.properties | 24 ----------- 3 files changed, 116 deletions(-) delete mode 100644 sonar-project.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44596b4a8..363b2a512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,18 +192,6 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} flags: backend-python-${{ matrix.python-version }} files: coverage.xml - - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: backend-coverage-${{ matrix.python-version }} - path: | - .coverage - coverage.xml - junit.xml - retention-days: 1 - include-hidden-files: true - if-no-files-found: error - name: Stop containers if: always() run: | @@ -286,17 +274,6 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} flags: frontend-node-${{ matrix.node-version }} directory: src-ui/coverage/ - - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: frontend-coverage-${{ matrix.shard-index }} - path: | - src-ui/coverage/lcov.info - src-ui/coverage/coverage-final.json - src-ui/junit.xml - retention-days: 1 - if-no-files-found: error tests-frontend-e2e: name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" runs-on: ubuntu-24.04 @@ -377,74 +354,6 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: cd src-ui && pnpm run build --configuration=production - sonarqube-analysis: - name: "SonarQube Analysis" - runs-on: ubuntu-24.04 - needs: - - tests-backend - - tests-frontend - if: github.repository_owner == 'paperless-ngx' - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Download all backend coverage - uses: actions/download-artifact@v5.0.0 - with: - pattern: backend-coverage-* - path: ./coverage/ - - name: Download all frontend coverage - uses: actions/download-artifact@v5.0.0 - with: - pattern: frontend-coverage-* - path: ./coverage/ - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.DEFAULT_PYTHON_VERSION }} - - name: Install coverage tools - run: | - pip install coverage - npm install -g nyc - # Merge backend coverage from all Python versions - - name: Merge backend coverage - run: | - coverage combine coverage/backend-coverage-*/.coverage - coverage xml -o merged-backend-coverage.xml - # Merge frontend coverage from all shards - - name: Merge frontend coverage - run: | - # Find all coverage-final.json files from the shards, exit with error if none found - shopt -s nullglob - files=(coverage/frontend-coverage-*/coverage/coverage-final.json) - if [ ${#files[@]} -eq 0 ]; then - echo "No frontend coverage JSON found under coverage/" >&2 - exit 1 - fi - # Create .nyc_output directory and copy each shard's coverage JSON into it with a unique name - mkdir -p .nyc_output - for coverage_json in "${files[@]}"; do - shard=$(basename "$(dirname "$(dirname "$coverage_json")")") - cp "$coverage_json" ".nyc_output/${shard}.json" - done - npx nyc merge .nyc_output .nyc_output/out.json - npx nyc report --reporter=lcovonly --report-dir coverage - - name: Upload coverage artifacts - uses: actions/upload-artifact@v4.6.2 - with: - name: merged-coverage - path: | - merged-backend-coverage.xml - .nyc_output/* - coverage/lcov.info - retention-days: 7 - if-no-files-found: error - include-hidden-files: true - - name: SonarQube Analysis - uses: SonarSource/sonarqube-scan-action@v5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} build-docker-image: name: Build Docker image for ${{ github.ref_name }} runs-on: ubuntu-24.04 diff --git a/pyproject.toml b/pyproject.toml index f3b270c77..a49e94f38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -255,7 +255,6 @@ PAPERLESS_DISABLE_DBHANDLER = "true" PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" [tool.coverage.run] -relative_files = true source = [ "src/", ] diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index d9d341e87..000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,24 +0,0 @@ -sonar.projectKey=paperless-ngx_paperless-ngx -sonar.organization=paperless-ngx -sonar.projectName=Paperless-ngx -sonar.projectVersion=1.0 - -# Source and test directories -sonar.sources=src/,src-ui/ -sonar.test.inclusions=**/test_*.py,**/tests.py,**/*.spec.ts,**/*.test.ts - -# Language specific settings -sonar.python.version=3.10,3.11,3.12,3.13 - -# Coverage reports -sonar.python.coverage.reportPaths=merged-backend-coverage.xml -sonar.javascript.lcov.reportPaths=coverage/lcov.info - -# Test execution reports -sonar.junit.reportPaths=**/junit.xml,**/test-results.xml - -# Encoding -sonar.sourceEncoding=UTF-8 - -# Exclusions -sonar.exclusions=**/migrations/**,**/node_modules/**,**/static/**,**/venv/**,**/.venv/**,**/dist/** From e9850518909b864101d9f9b6ca7fd995f3da1746 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:01:31 -0700 Subject: [PATCH 10/14] Chore: remove Codecov token from CI workflow (#10941) --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 363b2a512..9ca0d2167 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,13 +183,11 @@ jobs: if: always() uses: codecov/test-results-action@v1 with: - token: ${{ secrets.CODECOV_TOKEN }} flags: backend-python-${{ matrix.python-version }} files: junit.xml - name: Upload backend coverage to Codecov uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} flags: backend-python-${{ matrix.python-version }} files: coverage.xml - name: Stop containers @@ -265,13 +263,11 @@ jobs: uses: codecov/test-results-action@v1 if: always() with: - token: ${{ secrets.CODECOV_TOKEN }} flags: frontend-node-${{ matrix.node-version }} directory: src-ui/ - name: Upload frontend coverage to Codecov uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} flags: frontend-node-${{ matrix.node-version }} directory: src-ui/coverage/ tests-frontend-e2e: From 766af6a48a3a83219f3bebc33cd6af9e7305f2f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:42:28 +0000 Subject: [PATCH 11/14] docker(deps): bump astral-sh/uv (#10906) Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.8.17-python3.12-bookworm-slim to 0.8.19-python3.12-bookworm-slim. - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.8.17...0.8.19) --- updated-dependencies: - dependency-name: astral-sh/uv dependency-version: 0.8.19-python3.12-bookworm-slim dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 93fd32ee3..8dbfc7119 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.8.17-python3.12-bookworm-slim AS s6-overlay-base +FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base WORKDIR /usr/src/s6 From af544177d46941d1c1b8fd595248ef2d3cbcfe57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:21:18 -0700 Subject: [PATCH 12/14] Chore(deps): Bump django-cors-headers from 4.8.0 to 4.9.0 (#10907) Bumps [django-cors-headers](https://github.com/adamchainz/django-cors-headers) from 4.8.0 to 4.9.0. - [Changelog](https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst) - [Commits](https://github.com/adamchainz/django-cors-headers/compare/4.8.0...4.9.0) --- updated-dependencies: - dependency-name: django-cors-headers dependency-version: 4.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a49e94f38..c1d5b5023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "django-cachalot~=2.8.0", "django-celery-results~=2.6.0", "django-compression-middleware~=0.5.0", - "django-cors-headers~=4.8.0", + "django-cors-headers~=4.9.0", "django-extensions~=4.1", "django-filter~=25.1", "django-guardian~=3.1.2", diff --git a/uv.lock b/uv.lock index 5c5a0a41b..b6184b1db 100644 --- a/uv.lock +++ b/uv.lock @@ -730,15 +730,15 @@ wheels = [ [[package]] name = "django-cors-headers" -version = "4.8.0" +version = "4.9.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/89/8e/6225441edcfe179bf4861e9e67489e33375e0b66316c8d7b9edaae863d37/django_cors_headers-4.8.0.tar.gz", hash = "sha256:0a12a2efcd59a3cea741e44db8ab589e929949de5bc4cdf35a29c6ae77297686", size = 21425, upload-time = "2025-09-08T15:58:05.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/b3/29ef49d6ff7800f323f3d98cde7777b3cfdda133de8feea84cffafea4578/django_cors_headers-4.8.0-py3-none-any.whl", hash = "sha256:3b883f4c6d07848673218456a5e070d8ab51f97341c1f27d0242ca167e7272ab", size = 12804, upload-time = "2025-09-08T15:58:03.882Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] [[package]] @@ -2182,7 +2182,7 @@ requires-dist = [ { name = "django-cachalot", specifier = "~=2.8.0" }, { name = "django-celery-results", specifier = "~=2.6.0" }, { name = "django-compression-middleware", specifier = "~=0.5.0" }, - { name = "django-cors-headers", specifier = "~=4.8.0" }, + { name = "django-cors-headers", specifier = "~=4.9.0" }, { name = "django-extensions", specifier = "~=4.1" }, { name = "django-filter", specifier = "~=25.1" }, { name = "django-guardian", specifier = "~=3.1.2" }, From 1717517e70f5de571a18d59bde69ed2db9958fea Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:47:24 -0700 Subject: [PATCH 13/14] Tweakhancement: reorganize some list & bulk editing buttons (#10944) --- .../e2e/document-list/document-list.spec.ts | 2 +- .../bulk-editor/bulk-editor.component.html | 291 +++++++++--------- .../bulk-editor/bulk-editor.component.scss | 4 + .../document-list.component.html | 37 ++- .../document-list/document-list.component.ts | 2 + 5 files changed, 175 insertions(+), 161 deletions(-) diff --git a/src-ui/e2e/document-list/document-list.spec.ts b/src-ui/e2e/document-list/document-list.spec.ts index 45857bb09..0a7b54fcb 100644 --- a/src-ui/e2e/document-list/document-list.spec.ts +++ b/src-ui/e2e/document-list/document-list.spec.ts @@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => { await expect(page.locator('pngx-document-list')).toHaveText( /Selected 61 of 61 documents/i ) - await page.getByRole('button', { name: 'Cancel' }).click() + await page.getByRole('button', { name: 'None' }).click() await page.locator('pngx-document-card-small').nth(1).click() await page.locator('pngx-document-card-small').nth(2).click() diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 0eb655a21..7e499dfd0 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -1,161 +1,144 @@
    -
    -
    -
    - -
    -
    +
    +
    +
    + +
    + + + - -
    -
    - - @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { - - - } - @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { - - - } - @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { - - - } - @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { - - - } - @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { - - - } +
    +
    +
    + +
    + +
    +
    +

    Include:

    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    -
    -
    +
    +
    - - -
    - -
    - - - -
    -
    -
    - -
    - -
    - -
    -
    -

    Include:

    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    -
    - -
    - -
    -
    -
    +
    + +
    +
    +
    diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.scss b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.scss index 939f2c790..a5ea35ce4 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.scss +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.scss @@ -5,3 +5,7 @@ .dropdown-menu{ --bs-dropdown-min-width: 12rem; } + +.btn-group .btn { + white-space: nowrap; +} diff --git a/src-ui/src/app/components/document-list/document-list.component.html b/src-ui/src/app/components/document-list/document-list.component.html index c58d1ede1..a6d23f2a5 100644 --- a/src-ui/src/app/components/document-list/document-list.component.html +++ b/src-ui/src/app/components/document-list/document-list.component.html @@ -1,16 +1,36 @@ - -
    - -
    +
    +
    +
    + Select: +
    +
    + @if (list.selected.size > 0) { + + } + + +
    +
    - } + + } + @if (!list.isReloading && list.selected.size > 0) { + + }
    @if (list.collectionSize) { Date: Fri, 26 Sep 2025 20:49:13 +0000 Subject: [PATCH 14/14] Auto translate strings --- src-ui/messages.xlf | 209 ++++++++++++++++++++++---------------------- 1 file changed, 103 insertions(+), 106 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 17f1bedf5..376594cb7 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -324,7 +324,7 @@ src/app/components/document-list/document-list.component.ts - 190 + 192 src/app/components/manage/custom-fields/custom-fields.component.html @@ -743,7 +743,7 @@ src/app/components/document-list/document-list.component.html - 114 + 134 src/app/components/manage/custom-fields/custom-fields.component.html @@ -1167,7 +1167,7 @@ src/app/components/document-list/document-list.component.html - 217 + 242 src/app/data/document.ts @@ -1209,7 +1209,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 97 + 78 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -1494,10 +1494,6 @@ src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html 182 - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 4 - src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html 81 @@ -1604,6 +1600,10 @@ src/app/components/admin/trash/trash.component.html 8 + + src/app/components/document-list/document-list.component.html + 153 + src/app/components/manage/management-list/management-list.component.html 4 @@ -1755,7 +1755,7 @@ src/app/components/document-list/document-list.component.html - 244 + 269 src/app/data/document.ts @@ -1808,7 +1808,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 103 + 87 src/app/components/manage/custom-fields/custom-fields.component.html @@ -2109,7 +2109,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 157 + 140 src/app/components/manage/custom-fields/custom-fields.component.html @@ -2769,11 +2769,11 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 21 + 5 src/app/components/document-list/document-list.component.html - 199 + 224 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -3001,7 +3001,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 129 + 112 src/app/components/document-list/document-card-large/document-card-large.component.html @@ -3448,8 +3448,8 @@ 27 - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 14 + src/app/components/document-list/document-list.component.html + 30 @@ -3529,7 +3529,7 @@ src/app/components/document-list/document-list.component.html - 253 + 278 src/app/data/document.ts @@ -6356,7 +6356,7 @@ src/app/components/document-list/document-list.component.html - 298 + 323 @@ -6371,7 +6371,7 @@ src/app/components/document-list/document-list.component.html - 338 + 363 @@ -6386,7 +6386,7 @@ src/app/components/document-list/document-list.component.html - 345 + 370 @@ -6404,7 +6404,7 @@ src/app/components/document-list/document-list.component.html - 366 + 391 @@ -6415,7 +6415,7 @@ src/app/components/document-list/document-list.component.html - 366 + 391 @@ -6585,8 +6585,8 @@ 5 - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 11 + src/app/components/document-list/document-list.component.html + 27 @@ -6625,7 +6625,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 107 + 91 @@ -6686,7 +6686,7 @@ src/app/components/document-list/document-list.component.html - 196 + 221 src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -6723,11 +6723,11 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 35 + 19 src/app/components/document-list/document-list.component.html - 186 + 211 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -6750,11 +6750,11 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 49 + 33 src/app/components/document-list/document-list.component.html - 226 + 251 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -6777,11 +6777,11 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 63 + 47 src/app/components/document-list/document-list.component.html - 235 + 260 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -7188,25 +7188,18 @@ 10 - - Select: - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 8 - - Edit: src/app/components/document-list/bulk-editor/bulk-editor.component.html - 19 + 3 Filter tags src/app/components/document-list/bulk-editor/bulk-editor.component.html - 22 + 6 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -7217,7 +7210,7 @@ Filter correspondents src/app/components/document-list/bulk-editor/bulk-editor.component.html - 36 + 20 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -7228,7 +7221,7 @@ Filter document types src/app/components/document-list/bulk-editor/bulk-editor.component.html - 50 + 34 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -7239,7 +7232,7 @@ Filter storage paths src/app/components/document-list/bulk-editor/bulk-editor.component.html - 64 + 48 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -7250,7 +7243,7 @@ Custom fields src/app/components/document-list/bulk-editor/bulk-editor.component.html - 77 + 61 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -7265,56 +7258,56 @@ Filter custom fields src/app/components/document-list/bulk-editor/bulk-editor.component.html - 78 + 62 Set values src/app/components/document-list/bulk-editor/bulk-editor.component.html - 86 + 70 Rotate src/app/components/document-list/bulk-editor/bulk-editor.component.html - 110 + 94 Merge src/app/components/document-list/bulk-editor/bulk-editor.component.html - 113 + 97 Include: src/app/components/document-list/bulk-editor/bulk-editor.component.html - 135 + 118 Archived files src/app/components/document-list/bulk-editor/bulk-editor.component.html - 139 + 122 Original files src/app/components/document-list/bulk-editor/bulk-editor.component.html - 143 + 126 Use formatted filename src/app/components/document-list/bulk-editor/bulk-editor.component.html - 148 + 131 @@ -7614,7 +7607,7 @@ src/app/components/document-list/document-list.component.html - 314 + 339 @@ -7738,7 +7731,7 @@ Select src/app/components/document-list/document-list.component.html - 6 + 5 src/app/data/custom-field.ts @@ -7749,36 +7742,51 @@ Select none src/app/components/document-list/document-list.component.html - 9 + 11 Select page src/app/components/document-list/document-list.component.html - 10 + 12 src/app/components/document-list/document-list.component.ts - 313 + 315 Select all src/app/components/document-list/document-list.component.html - 11 + 13 src/app/components/document-list/document-list.component.ts - 306 + 308 + + + + None + + src/app/components/document-list/document-list.component.html + 23 + + + src/app/components/manage/management-list/management-list.component.ts + 120 + + + src/app/data/matching-model.ts + 45 Show src/app/components/document-list/document-list.component.html - 17 + 37 src/app/components/manage/saved-views/saved-views.component.html @@ -7789,63 +7797,63 @@ Sort src/app/components/document-list/document-list.component.html - 48 + 68 Views src/app/components/document-list/document-list.component.html - 74 + 94 Save "" src/app/components/document-list/document-list.component.html - 93 + 113 Save as... src/app/components/document-list/document-list.component.html - 96 + 116 All saved views src/app/components/document-list/document-list.component.html - 97 + 117 {VAR_PLURAL, plural, =1 {Selected of one document} other {Selected of documents}} src/app/components/document-list/document-list.component.html - 117 + 137 {VAR_PLURAL, plural, =1 {One document} other { documents}} src/app/components/document-list/document-list.component.html - 121 + 141 (filtered) src/app/components/document-list/document-list.component.html - 123 + 143 Reset filters src/app/components/document-list/document-list.component.html - 128 + 148 src/app/components/document-list/filter-editor/filter-editor.component.html @@ -7856,21 +7864,21 @@ Error while loading documents src/app/components/document-list/document-list.component.html - 144 + 169 Sort by ASN src/app/components/document-list/document-list.component.html - 173 + 198 ASN src/app/components/document-list/document-list.component.html - 177 + 202 src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -7889,28 +7897,28 @@ Sort by correspondent src/app/components/document-list/document-list.component.html - 182 + 207 Sort by title src/app/components/document-list/document-list.component.html - 191 + 216 Sort by owner src/app/components/document-list/document-list.component.html - 204 + 229 Owner src/app/components/document-list/document-list.component.html - 208 + 233 src/app/data/document.ts @@ -7925,49 +7933,49 @@ Sort by notes src/app/components/document-list/document-list.component.html - 213 + 238 Sort by document type src/app/components/document-list/document-list.component.html - 222 + 247 Sort by storage path src/app/components/document-list/document-list.component.html - 231 + 256 Sort by created date src/app/components/document-list/document-list.component.html - 240 + 265 Sort by added date src/app/components/document-list/document-list.component.html - 249 + 274 Sort by number of pages src/app/components/document-list/document-list.component.html - 258 + 283 Pages src/app/components/document-list/document-list.component.html - 262 + 287 src/app/data/document.ts @@ -7986,77 +7994,77 @@ Shared src/app/components/document-list/document-list.component.html - 265,267 + 290,292 Sort by src/app/components/document-list/document-list.component.html - 272,273 + 297,298 Edit document src/app/components/document-list/document-list.component.html - 306 + 331 Preview document src/app/components/document-list/document-list.component.html - 307 + 332 Reset filters / selection src/app/components/document-list/document-list.component.ts - 294 + 296 Open first [selected] document src/app/components/document-list/document-list.component.ts - 322 + 324 Previous page src/app/components/document-list/document-list.component.ts - 338 + 340 Next page src/app/components/document-list/document-list.component.ts - 350 + 352 View "" saved successfully. src/app/components/document-list/document-list.component.ts - 383 + 385 Failed to save view "". src/app/components/document-list/document-list.component.ts - 389 + 391 View "" created successfully. src/app/components/document-list/document-list.component.ts - 435 + 437 @@ -8861,17 +8869,6 @@ 15 - - None - - src/app/components/manage/management-list/management-list.component.ts - 120 - - - src/app/data/matching-model.ts - 45 - - Successfully created .