Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as chokidar from 'chokidar';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { BarcodeScannerService } from '../barcode/barcode-scanner.service';
|
||||
import { PageCacheService } from '../barcode/page-cache.service';
|
||||
import {
|
||||
InboxDocument,
|
||||
type InboxSource,
|
||||
} from '../database/entities/inbox-document.entity';
|
||||
|
||||
const STABILITY_MS = 5000;
|
||||
|
||||
@Injectable()
|
||||
export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ScannerWatcherService.name);
|
||||
private watcher: chokidar.FSWatcher | null = null;
|
||||
private readonly sourceRoot: string;
|
||||
private readonly processing = new Set<string>();
|
||||
private isPeriodicScanning = false;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly barcodeScanner: BarcodeScannerService,
|
||||
private readonly pageCache: PageCacheService,
|
||||
@InjectRepository(InboxDocument)
|
||||
private readonly documentRepo: Repository<InboxDocument>,
|
||||
) {
|
||||
this.sourceRoot = this.configService.get<string>('SCANNER_WATCH_DIR', '/mnt/scans');
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.startWatching();
|
||||
// Sequenziell, sonst greifen sich initialScan (Watcher) und Backfill
|
||||
// dieselbe frisch angelegte Row und scannen doppelt. Fire-and-forget,
|
||||
// damit der Modulstart nicht blockiert.
|
||||
void this.bootstrap();
|
||||
}
|
||||
|
||||
private async bootstrap(): Promise<void> {
|
||||
await this.initialScan();
|
||||
await this.backfillMissingScans();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.stopWatching();
|
||||
}
|
||||
|
||||
private startWatching(): void {
|
||||
this.logger.log(`Starte Überwachung: ${this.sourceRoot}`);
|
||||
|
||||
this.watcher = chokidar.watch(this.sourceRoot, {
|
||||
ignored: /(^|[\/\\])\../,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: STABILITY_MS,
|
||||
pollInterval: 500,
|
||||
},
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
this.watcher
|
||||
.on('add', (filePath: string) => this.handleNewFile(filePath))
|
||||
.on('error', (error: Error) => this.logger.error(`Watcher Fehler: ${error.message}`));
|
||||
|
||||
this.logger.log('Scanner-Watcher aktiv');
|
||||
}
|
||||
|
||||
private stopWatching(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
this.logger.log('Scanner-Watcher gestoppt');
|
||||
}
|
||||
}
|
||||
|
||||
private async initialScan(silent = false): Promise<void> {
|
||||
let subdirs: string[];
|
||||
try {
|
||||
const entries = await fs.readdir(this.sourceRoot, { withFileTypes: true });
|
||||
subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch (err: any) {
|
||||
if (!silent) {
|
||||
this.logger.warn(`Scanner-Check: Quellverzeichnis nicht lesbar (${this.sourceRoot}): ${err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let seen = 0;
|
||||
for (const subdir of subdirs) {
|
||||
const dir = path.join(this.sourceRoot, subdir);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(dir);
|
||||
} catch (err: any) {
|
||||
if (!silent) {
|
||||
this.logger.warn(`Scanner-Check: ${dir} nicht lesbar: ${err.message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of files) {
|
||||
if (path.extname(name).toLowerCase() !== '.pdf') continue;
|
||||
const full = path.join(dir, name);
|
||||
|
||||
if (!(await this.isStable(full))) {
|
||||
if (!silent) {
|
||||
this.logger.debug(`Scanner-Check: ${full} noch nicht stabil – Watcher übernimmt`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
seen += 1;
|
||||
await this.handleNewFile(full);
|
||||
}
|
||||
}
|
||||
|
||||
if (seen > 0) {
|
||||
this.logger.log(`Scanner-Check: ${seen} Datei(en) verarbeitet`);
|
||||
} else if (!silent) {
|
||||
this.logger.log('Scanner-Check: keine neuen Dateien gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
@Cron('*/15 * * * * *')
|
||||
async periodicScan(): Promise<void> {
|
||||
if (this.isPeriodicScanning) return;
|
||||
this.isPeriodicScanning = true;
|
||||
try {
|
||||
await this.initialScan(true);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Periodic Scan Fehler: ${err.message}`);
|
||||
} finally {
|
||||
this.isPeriodicScanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async isStable(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return Date.now() - stat.mtimeMs >= STABILITY_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNewFile(filePath: string): Promise<void> {
|
||||
if (path.extname(filePath).toLowerCase() !== '.pdf') return;
|
||||
|
||||
const relative = path.relative(this.sourceRoot, filePath);
|
||||
const parts = relative.split(path.sep);
|
||||
|
||||
if (parts.length !== 2) {
|
||||
this.logger.debug(`Überspringe (falsche Tiefe): ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const subdir = parts[0];
|
||||
const fileName = parts[1];
|
||||
|
||||
if (this.processing.has(filePath)) return;
|
||||
this.processing.add(filePath);
|
||||
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const source: InboxSource = subdir === 'all' ? 'all' : 'user';
|
||||
const owner = source === 'all' ? null : subdir;
|
||||
|
||||
const targetDir = this.pageCache.documentDir(id);
|
||||
const targetPdf = this.pageCache.documentPdfPath(id);
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
await this.move(filePath, targetPdf);
|
||||
|
||||
const doc = this.documentRepo.create({
|
||||
Id: id,
|
||||
OriginalName: fileName,
|
||||
Source: source,
|
||||
OwnerUsername: owner,
|
||||
PageCount: 0,
|
||||
QrCodes: [],
|
||||
});
|
||||
await this.documentRepo.save(doc);
|
||||
|
||||
this.logger.log(`Übernommen: ${relative} → ${id}/document.pdf`);
|
||||
|
||||
try {
|
||||
await this.barcodeScanner.scanAndMatch(doc);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Barcode-Scan nach Move fehlgeschlagen (${id}): ${err.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Übernahme fehlgeschlagen für ${filePath}: ${err.message}`);
|
||||
} finally {
|
||||
this.processing.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async backfillMissingScans(): Promise<void> {
|
||||
let pending: InboxDocument[];
|
||||
try {
|
||||
pending = await this.documentRepo.find({
|
||||
where: [{ PageCount: 0 }, { QrCodes: IsNull() }],
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Backfill: DB-Query fehlgeschlagen: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let scanned = 0;
|
||||
for (const doc of pending) {
|
||||
try {
|
||||
const didScan = await this.barcodeScanner.ensureScanned(doc);
|
||||
if (didScan) scanned += 1;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Backfill fehlgeschlagen (${doc.Id}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scanned > 0) {
|
||||
this.logger.log(`Backfill: ${scanned} Datei(en) nachträglich gescannt`);
|
||||
} else {
|
||||
this.logger.log('Backfill: alle Dateien bereits gescannt');
|
||||
}
|
||||
}
|
||||
|
||||
private async move(src: string, dest: string): Promise<void> {
|
||||
try {
|
||||
await fs.rename(src, dest);
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'EXDEV') throw err;
|
||||
}
|
||||
// Cross-device: copy + unlink. Wenn unlink scheitert, Kopie zurückrollen,
|
||||
// damit ein kaputter Mount nicht bei jedem Neustart Duplikate produziert.
|
||||
await fs.copyFile(src, dest);
|
||||
try {
|
||||
await fs.unlink(src);
|
||||
} catch (err) {
|
||||
await fs.unlink(dest).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user