Build and Push Multi-Platform Images / build-and-push (push) Successful in 32s
check-attachments fragte nur die Checksumme bei Paperless ab. Neu erzeugte PDFs mit gleichem Inhalt wie ein bereits importierter Anhang blieben dadurch auf „Neu“. Findet Paperless die Checksumme nicht, wird jetzt per Text-Fingerprint nach einem importierten Anhang gesucht; die Mail wird dann als verarbeitet markiert und die Paperless-ID übernommen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Brackets, Repository } from 'typeorm';
|
||
import { Attachment } from '../database/entities/attachment.entity';
|
||
import { Content } from '../database/entities/content.entity';
|
||
import { computeContentFingerprint } from './pdf-fingerprint.util';
|
||
|
||
/**
|
||
* Verwaltet den inhaltsbasierten Fingerprint der Mail-Anhänge: berechnet ihn
|
||
* bei Bedarf nach, füllt ihn für Altbestände im Hintergrund auf und findet
|
||
* bereits importierte Anhänge mit gleichem Inhalt.
|
||
*/
|
||
@Injectable()
|
||
export class AttachmentFingerprintService implements OnApplicationBootstrap {
|
||
private readonly logger = new Logger(AttachmentFingerprintService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(Attachment)
|
||
private readonly attachmentRepo: Repository<Attachment>,
|
||
@InjectRepository(Content)
|
||
private readonly contentRepo: Repository<Content>,
|
||
) {}
|
||
|
||
onApplicationBootstrap(): void {
|
||
// Nicht auf den Backfill warten – der Start soll nicht blockieren
|
||
this.backfill().catch((err: Error) =>
|
||
this.logger.error(`Fingerprint-Backfill fehlgeschlagen: ${err.message}`),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Liefert den Fingerprint eines Anhangs und berechnet ihn nach, falls er
|
||
* noch fehlt (z.B. Mail vor dem Backfill eingegangen). `null`, wenn kein
|
||
* vergleichbarer Text vorliegt.
|
||
*/
|
||
async ensure(attachment: Attachment): Promise<string | null> {
|
||
if (attachment.ContentFingerprint != null) {
|
||
return attachment.ContentFingerprint || null;
|
||
}
|
||
if (attachment.ContentType !== 'application/pdf') return null;
|
||
|
||
const content = await this.contentRepo.findOne({
|
||
where: { AttachmentEntityId: attachment.Id },
|
||
});
|
||
if (!content) return null;
|
||
|
||
const fingerprint = await computeContentFingerprint(content.Content1);
|
||
if (fingerprint === null) return null;
|
||
await this.attachmentRepo.update(attachment.Id, {
|
||
ContentFingerprint: fingerprint,
|
||
});
|
||
attachment.ContentFingerprint = fingerprint;
|
||
return fingerprint || null;
|
||
}
|
||
|
||
/**
|
||
* Gibt es einen anderen Anhang mit gleichem Inhalt, dessen Seitenbereich
|
||
* bereits importiert wurde oder gerade importiert wird?
|
||
*/
|
||
async hasImportedTwin(
|
||
attachmentId: number,
|
||
fingerprint: string,
|
||
range: string,
|
||
): Promise<boolean> {
|
||
const count = await this.attachmentRepo
|
||
.createQueryBuilder('a')
|
||
.where('a.ContentFingerprint = :fingerprint', { fingerprint })
|
||
.andWhere('a.Id != :attachmentId', { attachmentId })
|
||
.andWhere(
|
||
new Brackets((qb) => {
|
||
// Abgeschlossener Import mit ermittelter Paperless-ID
|
||
qb.where(
|
||
"JSON_CONTAINS_PATH(a.PaperlessDocumentIds, 'one', :jsonPath) = 1",
|
||
{ jsonPath: `$."${range}"` },
|
||
)
|
||
// Laufender oder abgeschlossener Upload (fehlgeschlagene Tasks
|
||
// löscht der Task-Processor)
|
||
.orWhere(
|
||
'EXISTS (SELECT 1 FROM tasks t WHERE t.SourceAttachmentID = a.Id AND t.SourceAttachmentRange = :range)',
|
||
{ range },
|
||
);
|
||
// Ganzer Anhang: jeder Import zählt, auch wenn die ID beim Upload
|
||
// nicht ermittelt werden konnte
|
||
if (range === 'full') qb.orWhere('a.ImportStatus = 1');
|
||
}),
|
||
)
|
||
.getCount();
|
||
return count > 0;
|
||
}
|
||
|
||
/**
|
||
* Sucht einen bereits importierten Anhang mit gleichem Inhalt und liefert
|
||
* dessen Paperless-Dokument-ID (sofern beim Import ermittelt).
|
||
*/
|
||
async findImportedTwin(
|
||
attachment: Attachment,
|
||
): Promise<{ attachmentId: number; documentId: number | null } | null> {
|
||
const fingerprint = await this.ensure(attachment);
|
||
if (!fingerprint) return null;
|
||
|
||
const twin = await this.attachmentRepo
|
||
.createQueryBuilder('a')
|
||
.where('a.ContentFingerprint = :fingerprint', { fingerprint })
|
||
.andWhere('a.Id != :attachmentId', { attachmentId: attachment.Id })
|
||
.andWhere('a.ImportStatus = 1')
|
||
.orderBy('a.Id', 'ASC')
|
||
.getOne();
|
||
if (!twin) return null;
|
||
|
||
const ids = twin.PaperlessDocumentIds as Record<string, number> | null;
|
||
return { attachmentId: twin.Id, documentId: ids?.full ?? null };
|
||
}
|
||
|
||
/** Berechnet fehlende Fingerprints aller PDF-Anhänge in kleinen Portionen. */
|
||
async backfill(batchSize = 25): Promise<number> {
|
||
let lastId = 0;
|
||
let updated = 0;
|
||
for (;;) {
|
||
const batch = await this.attachmentRepo
|
||
.createQueryBuilder('a')
|
||
.select(['a.Id', 'a.ContentType', 'a.ContentFingerprint'])
|
||
.where('a.ContentFingerprint IS NULL')
|
||
.andWhere("a.ContentType = 'application/pdf'")
|
||
.andWhere('a.Id > :lastId', { lastId })
|
||
.orderBy('a.Id', 'ASC')
|
||
.take(batchSize)
|
||
.getMany();
|
||
if (batch.length === 0) break;
|
||
|
||
for (const attachment of batch) {
|
||
// lastId weiterschieben, damit unlesbare PDFs nicht endlos drankommen
|
||
lastId = attachment.Id;
|
||
try {
|
||
if ((await this.ensure(attachment)) !== null) updated++;
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Fingerprint für Anhang ${attachment.Id} nicht berechenbar: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (updated > 0) {
|
||
this.logger.log(`Fingerprint-Backfill: ${updated} Anhänge ergänzt.`);
|
||
}
|
||
return updated;
|
||
}
|
||
}
|