From 8e0c609802d160790d70703b0b326653a107af3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 2 Nov 2026 13:55:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Seiten-=20und=20Wortzahl=20f=C3=BCr=20d?= =?UTF-8?q?en=20Duplikatvergleich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../src/email/email-import.controller.ts | 60 +++++++++++++++++++ .../src/email/email-import.service.ts | 51 +++++++++------- .../src/inbox/inbox.controller.ts | 29 +++++++++ paperless-backend/src/inbox/inbox.module.ts | 2 + .../src/paperless/paperless.service.ts | 13 ++++ .../vergleich-kennzahlen.util.spec.ts | 48 +++++++++++++++ .../paperless/vergleich-kennzahlen.util.ts | 56 +++++++++++++++++ paperless-frontend/src/api/email-import.ts | 18 ++++++ paperless-frontend/src/api/inbox.ts | 6 ++ paperless-frontend/src/api/paperless.ts | 11 ++++ 10 files changed, 273 insertions(+), 21 deletions(-) create mode 100644 paperless-backend/src/paperless/vergleich-kennzahlen.util.spec.ts create mode 100644 paperless-backend/src/paperless/vergleich-kennzahlen.util.ts diff --git a/paperless-backend/src/email/email-import.controller.ts b/paperless-backend/src/email/email-import.controller.ts index 9da16ae..9802c63 100644 --- a/paperless-backend/src/email/email-import.controller.ts +++ b/paperless-backend/src/email/email-import.controller.ts @@ -14,6 +14,11 @@ import { import type { Response } from 'express'; import { EmailImportService } from './email-import.service'; import { EmailPageCacheService } from './email-page-cache.service'; +import { PaperlessService } from '../paperless/paperless.service'; +import { + kennzahlenAusPdf, + type VergleichKennzahlen, +} from '../paperless/vergleich-kennzahlen.util'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -24,6 +29,7 @@ export class EmailImportController { constructor( private readonly importService: EmailImportService, private readonly pageCache: EmailPageCacheService, + private readonly paperlessService: PaperlessService, ) {} // --- Korrespondenten Mapping --- @@ -118,6 +124,51 @@ export class EmailImportController { return { isDuplicate }; } + // --- Duplikatvergleich --- + /** PDF eines Anhangs bzw. Seitenbereichs ohne Stempel (rechte Seite im Vergleich). */ + @Get('attachments/:attachmentId/pdf') + @RequirePermissions(Permission.VIEW_MAIL) + async getAttachmentPdf( + @Param('attachmentId') attachmentId: string, + @Query('start') start: string | undefined, + @Query('end') end: string | undefined, + @Res() res: Response, + ) { + const pdf = await this.importService.getAttachmentPdf( + parseInt(attachmentId, 10), + seitenbereich(start, end), + ); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `inline; filename="anhang-${attachmentId}.pdf"`, + ); + res.send(pdf); + } + + @Post('attachments/:attachmentId/vergleich') + @RequirePermissions(Permission.VIEW_MAIL) + async vergleich( + @Param('attachmentId') attachmentId: string, + @Body() + body: { + paperlessDocumentId: number; + pages?: { start: number; end: number }; + }, + ): Promise { + const pdf = await this.importService.getAttachmentPdf( + parseInt(attachmentId, 10), + body.pages, + ); + const [vorhanden, neu] = await Promise.all([ + this.paperlessService.getDocumentKennzahlen( + Number(body.paperlessDocumentId), + ), + kennzahlenAusPdf(pdf), + ]); + return { vorhanden, neu }; + } + // --- Print Preview --- @Post('attachments/:attachmentId/print-preview') @RequirePermissions(Permission.VIEW_MAIL) @@ -202,3 +253,12 @@ export class EmailImportController { } } } + +function seitenbereich( + start: string | undefined, + end: string | undefined, +): { start: number; end: number } | undefined { + const s = parseInt(start ?? '', 10); + const e = parseInt(end ?? '', 10); + return Number.isNaN(s) || Number.isNaN(e) ? undefined : { start: s, end: e }; +} diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index 2b6c9db..2d617a2 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -348,9 +348,13 @@ export class EmailImportService { } // --- Print Preview --- - async generatePrintPdf( + /** + * PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999` + * = bis zum Ende) – ohne Stempel. + */ + async getAttachmentPdf( attachmentId: number, - barcodeData: any, + pages?: { start: number; end: number }, ): Promise { const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId }, @@ -358,28 +362,33 @@ export class EmailImportService { if (!content) throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND); - let pdfBytes: Buffer = content.Content1; + if (!pages) return content.Content1; - const pages: { start: number; end: number } | undefined = - barcodeData._pages; - if (pages) { - const pdfDoc = await PDFDocument.load(pdfBytes, { - ignoreEncryption: true, - }); - const total = pdfDoc.getPageCount(); - const startIdx = Math.max(1, pages.start) - 1; - const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; - const sliced = await PDFDocument.create(); - const indices = Array.from( - { length: endIdx - startIdx + 1 }, - (_, i) => startIdx + i, - ); - const copied = await sliced.copyPages(pdfDoc, indices); - copied.forEach((p) => sliced.addPage(p)); - pdfBytes = Buffer.from(await sliced.save()); - } + const pdfDoc = await PDFDocument.load(content.Content1, { + ignoreEncryption: true, + }); + const total = pdfDoc.getPageCount(); + const startIdx = Math.max(1, pages.start) - 1; + const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; + const sliced = await PDFDocument.create(); + const indices = Array.from( + { length: endIdx - startIdx + 1 }, + (_, i) => startIdx + i, + ); + const copied = await sliced.copyPages(pdfDoc, indices); + copied.forEach((p) => sliced.addPage(p)); + return Buffer.from(await sliced.save()); + } + async generatePrintPdf( + attachmentId: number, + barcodeData: any, + ): Promise { const { _pages, ...barcode } = barcodeData; + const pdfBytes = await this.getAttachmentPdf( + attachmentId, + _pages as { start: number; end: number } | undefined, + ); return this.applyBarcodeToPdf(pdfBytes, barcode); } diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index 34a97d4..f3db1e1 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -21,6 +21,11 @@ import { BarcodeScannerService } from '../barcode/barcode-scanner.service'; import { UserSettingsService } from '../user-settings/user-settings.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; +import { PaperlessService } from '../paperless/paperless.service'; +import { + kennzahlenAusPdf, + type VergleichKennzahlen, +} from '../paperless/vergleich-kennzahlen.util'; @Controller('api/inbox') @RequirePermissions(Permission.VIEW_SCANNER) @@ -30,6 +35,7 @@ export class InboxController { private readonly postprocessor: InboxPostprocessorService, private readonly barcodeScanner: BarcodeScannerService, private readonly userSettingsService: UserSettingsService, + private readonly paperlessService: PaperlessService, ) {} @Get() @@ -250,6 +256,29 @@ export class InboxController { return new StreamableFile(Readable.from(buffer)); } + /** Seiten-/Wortzahl des Abschnitts und des vorhandenen Paperless-Dokuments. */ + @Post(':id/vergleich') + async vergleich( + @Param('id') id: string, + @Body() body: { paperlessDocumentId: number; pages: number[] }, + @Request() req: any, + ): Promise { + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + const { buffer } = await this.inboxService.getSegmentPdfBuffer( + id, + preferredUsername, + body.pages ?? [], + ); + const [vorhanden, neu] = await Promise.all([ + this.paperlessService.getDocumentKennzahlen( + Number(body.paperlessDocumentId), + ), + kennzahlenAusPdf(buffer), + ]); + return { vorhanden, neu }; + } + @Post(':id/send-email') @HttpCode(204) async sendEmail( diff --git a/paperless-backend/src/inbox/inbox.module.ts b/paperless-backend/src/inbox/inbox.module.ts index 24a9516..2d07f23 100644 --- a/paperless-backend/src/inbox/inbox.module.ts +++ b/paperless-backend/src/inbox/inbox.module.ts @@ -11,6 +11,7 @@ import { BarcodeModule } from '../barcode/barcode.module'; import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module'; import { PostprocessingModule } from '../postprocessing/postprocessing.module'; import { UserSettingsModule } from '../user-settings/user-settings.module'; +import { PaperlessModule } from '../paperless/paperless.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module'; InboxPostprocessorModule, PostprocessingModule, UserSettingsModule, + PaperlessModule, ], controllers: [InboxController, ClientsController], providers: [InboxService, InboxMigrationService], diff --git a/paperless-backend/src/paperless/paperless.service.ts b/paperless-backend/src/paperless/paperless.service.ts index bb484e8..58521a1 100644 --- a/paperless-backend/src/paperless/paperless.service.ts +++ b/paperless-backend/src/paperless/paperless.service.ts @@ -4,6 +4,7 @@ import axios, { type AxiosInstance } from 'axios'; import * as fs from 'fs'; import * as path from 'path'; import FormData = require('form-data'); +import { type Kennzahlen, zaehleWoerter } from './vergleich-kennzahlen.util'; import { type BelegnummerPruefung, belegnummerZuAsn, @@ -160,6 +161,18 @@ export class PaperlessService { return response.data; } + /** Seiten- und Wortzahl eines Paperless-Dokuments (aktuelle Fassung). */ + async getDocumentKennzahlen(id: number): Promise { + const response = await this.client.get<{ + page_count?: number | null; + content?: string | null; + }>(`/documents/${id}/`); + return { + seiten: response.data.page_count ?? null, + woerter: zaehleWoerter(response.data.content), + }; + } + async getInboxDocuments(): Promise { // API pagination to get large amount of inbox documents (assuming max 9999 like C# app) const response = await this.client.get('/documents/', { diff --git a/paperless-backend/src/paperless/vergleich-kennzahlen.util.spec.ts b/paperless-backend/src/paperless/vergleich-kennzahlen.util.spec.ts new file mode 100644 index 0000000..53340be --- /dev/null +++ b/paperless-backend/src/paperless/vergleich-kennzahlen.util.spec.ts @@ -0,0 +1,48 @@ +import { PDFDocument, StandardFonts } from 'pdf-lib'; +import { kennzahlenAusPdf, zaehleWoerter } from './vergleich-kennzahlen.util'; + +describe('zaehleWoerter', () => { + it('zählt nur Wörter mit Buchstaben oder Ziffern', () => { + // „–“ und „€“ zählen nicht + expect(zaehleWoerter('Rechnung Nr. 123 – Betrag 4,50 €')).toBe(5); + }); + + it('ignoriert Seitenmarker von pdf-parse', () => { + expect(zaehleWoerter('-- 1 of 2 --')).toBeNull(); + }); + + it('liefert null für leeren oder fehlenden Text', () => { + expect(zaehleWoerter('')).toBeNull(); + expect(zaehleWoerter(null)).toBeNull(); + }); +}); + +describe('kennzahlenAusPdf', () => { + it('ermittelt Seiten und Wörter eines PDFs mit Textebene', async () => { + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + pdf.addPage().drawText('Hallo Welt', { x: 50, y: 700, font, size: 14 }); + pdf.addPage(); + const buffer = Buffer.from(await pdf.save()); + + expect(await kennzahlenAusPdf(buffer)).toEqual({ seiten: 2, woerter: 2 }); + }); + + it('meldet bei PDFs ohne Text die Wortzahl als nicht ermittelbar', async () => { + const pdf = await PDFDocument.create(); + pdf.addPage(); + const buffer = Buffer.from(await pdf.save()); + + expect(await kennzahlenAusPdf(buffer)).toEqual({ + seiten: 1, + woerter: null, + }); + }); + + it('liefert für unlesbare Dateien keine Kennzahlen', async () => { + expect(await kennzahlenAusPdf(Buffer.from('kaputt'))).toEqual({ + seiten: null, + woerter: null, + }); + }); +}); diff --git a/paperless-backend/src/paperless/vergleich-kennzahlen.util.ts b/paperless-backend/src/paperless/vergleich-kennzahlen.util.ts new file mode 100644 index 0000000..4753f0e --- /dev/null +++ b/paperless-backend/src/paperless/vergleich-kennzahlen.util.ts @@ -0,0 +1,56 @@ +import { PDFDocument } from 'pdf-lib'; +import { PDFParse } from 'pdf-parse'; + +/** Kennzahlen eines Belegs; `null` = nicht ermittelbar. */ +export interface Kennzahlen { + seiten: number | null; + woerter: number | null; +} + +/** Kennzahlen des vorhandenen (Paperless) und des neuen Belegs. */ +export interface VergleichKennzahlen { + vorhanden: Kennzahlen; + neu: Kennzahlen; +} + +/** + * Zählt Wörter: Tokens zwischen Leerraum, die mindestens einen Buchstaben oder + * eine Ziffer enthalten. Seitenmarker von pdf-parse („-- 1 of 2 --“) zählen + * nicht. Kein Wort → `null` (z. B. Scan ohne Textebene). + */ +export function zaehleWoerter(text: string | null | undefined): number | null { + if (!text) return null; + const ohneMarker = text.replace(/-- \d+ of \d+ --/g, ' '); + const anzahl = ohneMarker + .split(/\s+/) + .filter((token) => /[\p{L}\p{N}]/u.test(token)).length; + return anzahl > 0 ? anzahl : null; +} + +async function seitenzahl(pdf: Buffer): Promise { + try { + const doc = await PDFDocument.load(pdf, { ignoreEncryption: true }); + return doc.getPageCount(); + } catch { + return null; + } +} + +async function wortzahl(pdf: Buffer): Promise { + // pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben + const parser = new PDFParse({ data: new Uint8Array(pdf) }); + try { + const { text } = await parser.getText(); + return zaehleWoerter(text); + } catch { + return null; + } finally { + await parser.destroy().catch(() => undefined); + } +} + +/** Seiten- und Wortzahl eines PDFs; Lesefehler ergeben `null`. */ +export async function kennzahlenAusPdf(pdf: Buffer): Promise { + const [seiten, woerter] = await Promise.all([seitenzahl(pdf), wortzahl(pdf)]); + return { seiten, woerter }; +} diff --git a/paperless-frontend/src/api/email-import.ts b/paperless-frontend/src/api/email-import.ts index 6659688..a12ce91 100644 --- a/paperless-frontend/src/api/email-import.ts +++ b/paperless-frontend/src/api/email-import.ts @@ -1,4 +1,5 @@ import api from './client'; +import type { VergleichKennzahlen } from './paperless'; export interface CorrespondentMapping { Id: number; @@ -50,6 +51,23 @@ export const emailImportApi = { await api.post('/api/email-import/belegnummer/release', { date: dateStr, number }); }, + attachmentPdfBlob: async (attachmentId: number, pages?: { start: number; end: number }): Promise => { + const res = await api.get(`/api/email-import/attachments/${attachmentId}/pdf`, { + params: pages ? { start: pages.start, end: pages.end } : undefined, + responseType: 'blob', + }); + return res.data; + }, + + vergleich: async ( + attachmentId: number, + paperlessDocumentId: number, + pages?: { start: number; end: number }, + ): Promise => { + const res = await api.post(`/api/email-import/attachments/${attachmentId}/vergleich`, { paperlessDocumentId, pages }); + return res.data; + }, + printPreview: async (attachmentId: number, barcodeData: any, pages?: { start: number; end: number }): Promise => { const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, { responseType: 'blob', diff --git a/paperless-frontend/src/api/inbox.ts b/paperless-frontend/src/api/inbox.ts index 3e7f885..0d265e7 100644 --- a/paperless-frontend/src/api/inbox.ts +++ b/paperless-frontend/src/api/inbox.ts @@ -1,5 +1,6 @@ import api from './client'; import type { BarcodeActionType } from './barcode-templates'; +import type { VergleichKennzahlen } from './paperless'; export type InboxSource = 'all' | 'user'; @@ -107,6 +108,11 @@ export const inboxApi = { ) .then((r) => r.data), + vergleich: (id: string, paperlessDocumentId: number, pages: number[]) => + api + .post(`/api/inbox/${encodeURIComponent(id)}/vergleich`, { paperlessDocumentId, pages }) + .then((r) => r.data), + downloadSegmentBlob: (id: string, pages: number[]) => api .post(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' }) diff --git a/paperless-frontend/src/api/paperless.ts b/paperless-frontend/src/api/paperless.ts index 7e239d5..e4c8f3f 100644 --- a/paperless-frontend/src/api/paperless.ts +++ b/paperless-frontend/src/api/paperless.ts @@ -9,6 +9,17 @@ export interface BelegnummerPruefung { kandidaten: number[]; } +/** Kennzahlen eines Belegs; `null` = nicht ermittelbar. */ +export interface Kennzahlen { + seiten: number | null; + woerter: number | null; +} + +export interface VergleichKennzahlen { + vorhanden: Kennzahlen; + neu: Kennzahlen; +} + export interface PaperlessTag { id: number; slug: string;