66aeab282c
Build and Push Multi-Platform Images / build-and-push (push) Successful in 19s
This reverts commit 07dfd7e840.
346 lines
10 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|