Files
paperlessmanager/paperless-backend/src/inbox/inbox.service.ts
T
bjoernpoettker 66aeab282c
Build and Push Multi-Platform Images / build-and-push (push) Successful in 19s
Revert "fix: resolve all ESLint errors in backend and frontend"
This reverts commit 07dfd7e840.
2026-06-16 16:19:11 +02:00

346 lines
10 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs/promises';
import {
BarcodeScannerService,
type MatchedBarcode,
} from '../barcode/barcode-scanner.service';
import { PageCacheService } from '../barcode/page-cache.service';
import {
InboxDocument,
type InboxSource,
} from '../database/entities/inbox-document.entity';
import { MailService } from '../postprocessing/mail.service';
import { buildSegmentBuffer } from '../inbox-postprocessor/edit-applier';
export interface InboxFile {
id: string;
name: string;
source: InboxSource;
pageCount: number;
deletedPages: number[];
manualSplitPages: number[];
rotations: Record<string, number>;
barcodes: MatchedBarcode[];
createdAt: string;
}
export interface ResolvedDocument {
doc: InboxDocument;
pdfPath: string;
}
@Injectable()
export class InboxService {
private readonly logger = new Logger(InboxService.name);
constructor(
private readonly barcodeScanner: BarcodeScannerService,
private readonly pageCache: PageCacheService,
@InjectRepository(InboxDocument)
private readonly documentRepo: Repository<InboxDocument>,
private readonly mailService: MailService,
) {}
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
const where = preferredUsername
? [
{ Source: 'all' as InboxSource, IsScanned: true },
{
Source: 'user' as InboxSource,
OwnerUsername: preferredUsername,
IsScanned: true,
},
]
: [{ Source: 'all' as InboxSource, IsScanned: true }];
const docs = await this.documentRepo.find({
where,
order: { CreatedAt: 'DESC' },
});
const files: InboxFile[] = [];
for (const doc of docs) {
files.push({
id: doc.Id,
name: doc.OriginalName,
source: doc.Source,
pageCount: doc.PageCount,
deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b),
manualSplitPages: [...(doc.ManualSplitPages ?? [])].sort(
(a, b) => a - b,
),
rotations: { ...(doc.Rotations ?? {}) },
barcodes: await this.barcodeScanner.getMatched(doc),
createdAt: doc.CreatedAt.toISOString(),
});
}
return files;
}
async resolveDocument(
id: string,
preferredUsername: string | null,
): Promise<ResolvedDocument> {
const doc = await this.documentRepo.findOne({ where: { Id: id } });
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
throw new NotFoundException('Dokument nicht gefunden');
}
const pdfPath = this.pageCache.documentPdfPath(doc.Id);
try {
const stat = await fs.stat(pdfPath);
if (!stat.isFile()) throw new Error('not a file');
} catch (err: any) {
this.logger.warn(
`Datei fehlt trotz DB-Eintrag (${doc.Id}): ${err.message}`,
);
throw new NotFoundException('Dokument nicht gefunden');
}
return { doc, pdfPath };
}
/**
* Markiert eine Seite virtuell zum Löschen. Die PDF und der Page-Cache
* bleiben unverändert; die eigentliche Anwendung passiert später bei
* der Weiterverarbeitung.
*/
async deletePage(
id: string,
page: number,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
throw new NotFoundException('Seite nicht gefunden');
}
const deleted = new Set<number>(doc.DeletedPages ?? []);
if (deleted.has(page)) return; // schon markiert
const remaining = doc.PageCount - deleted.size;
if (remaining <= 1) {
throw new ConflictException('Mindestens eine Seite muss übrig bleiben');
}
deleted.add(page);
doc.DeletedPages = Array.from(deleted).sort((a, b) => a - b);
await this.documentRepo.save(doc);
}
/**
* Setzt eine Seitenrotation virtuell. Wert wird auf 0/90/180/270
* normalisiert; 0 entfernt den Eintrag.
*/
async setPageRotation(
id: string,
page: number,
rotation: number,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
throw new NotFoundException('Seite nicht gefunden');
}
const normalized = (((Math.round(rotation / 90) * 90) % 360) + 360) % 360;
const next: Record<string, number> = { ...(doc.Rotations ?? {}) };
if (normalized === 0) {
delete next[String(page)];
} else {
next[String(page)] = normalized;
}
doc.Rotations = next;
await this.documentRepo.save(doc);
}
/**
* Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück.
*/
async resetEdits(
id: string,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
let changed = false;
if (doc.DeletedPages && doc.DeletedPages.length > 0) {
doc.DeletedPages = [];
changed = true;
}
if (doc.Rotations && Object.keys(doc.Rotations).length > 0) {
doc.Rotations = {};
changed = true;
}
if (doc.ManualSplitPages && doc.ManualSplitPages.length > 0) {
doc.ManualSplitPages = [];
changed = true;
}
if (changed) await this.documentRepo.save(doc);
}
/**
* Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite.
*/
async toggleManualSplit(
id: string,
page: number,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) {
throw new BadRequestException('Ungültige Seitennummer für Trennung');
}
const splits = new Set<number>(doc.ManualSplitPages ?? []);
if (splits.has(page)) {
splits.delete(page);
} else {
splits.add(page);
}
doc.ManualSplitPages = Array.from(splits).sort((a, b) => a - b);
await this.documentRepo.save(doc);
}
async deleteDocument(
id: string,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
const dir = this.pageCache.documentDir(doc.Id);
await this.documentRepo.delete(doc.Id);
try {
await fs.rm(dir, { recursive: true, force: true });
} catch (err: any) {
this.logger.warn(
`Dokument-Ordner konnte nicht gelöscht werden (${dir}): ${err.message}`,
);
}
}
async resolvePageImage(
id: string,
page: number,
variant: 'preview' | 'thumbnail',
preferredUsername: string | null,
): Promise<string> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
throw new NotFoundException('Seite nicht gefunden');
}
const filePath =
variant === 'preview'
? this.pageCache.previewPath(doc.Id, page)
: this.pageCache.thumbnailPath(doc.Id, page);
try {
await fs.access(filePath);
} catch {
throw new NotFoundException('Seite nicht gefunden');
}
return filePath;
}
async updateSource(
id: string,
source: InboxSource,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (source === 'all') {
doc.Source = 'all';
doc.OwnerUsername = null;
} else {
if (!preferredUsername) {
throw new BadRequestException(
'Benutzername erforderlich für persönlichen Scan',
);
}
doc.Source = 'user';
doc.OwnerUsername = preferredUsername;
}
await this.documentRepo.save(doc);
}
async scanRegion(
id: string,
page: number,
x: number,
y: number,
w: number,
h: number,
preferredUsername: string | null,
): Promise<{ found: string[] }> {
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
return this.barcodeScanner.scanRegion(doc, pdfPath, page, x, y, w, h);
}
async getSegmentPdfBuffer(
id: string,
preferredUsername: string | null,
pages: number[],
): Promise<{ buffer: Buffer; filename: string }> {
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
const deleted = new Set(doc.DeletedPages ?? []);
const safePages = pages.filter(
(p) => p >= 1 && p <= doc.PageCount && !deleted.has(p),
);
const buffer = await buildSegmentBuffer(doc, pdfPath, safePages);
return { buffer, filename: doc.OriginalName };
}
async sendAsEmail(
id: string,
preferredUsername: string | null,
opts: {
to: string;
subject: string;
body: string;
html?: string;
segments: { pages: number[]; filename: string }[];
smtpOverride?: {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
};
},
): Promise<void> {
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
const deleted = new Set(doc.DeletedPages ?? []);
const attachments = await Promise.all(
opts.segments.map(async (seg) => {
const safePages = seg.pages.filter(
(p) => p >= 1 && p <= doc.PageCount && !deleted.has(p),
);
const content = await buildSegmentBuffer(doc, pdfPath, safePages);
const filename = seg.filename.endsWith('.pdf')
? seg.filename
: `${seg.filename}.pdf`;
return { filename, content };
}),
);
await this.mailService.sendMail({
to: opts.to,
subject: opts.subject,
body: opts.body,
html: opts.html,
attachments,
smtpOverride: opts.smtpOverride,
});
}
}