feat: Duplikate im Import als neue Version behandeln #6
No files matched your search
@@ -14,6 +14,11 @@ import {
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { EmailImportService } from './email-import.service';
|
import { EmailImportService } from './email-import.service';
|
||||||
import { EmailPageCacheService } from './email-page-cache.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 { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ export class EmailImportController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly importService: EmailImportService,
|
private readonly importService: EmailImportService,
|
||||||
private readonly pageCache: EmailPageCacheService,
|
private readonly pageCache: EmailPageCacheService,
|
||||||
|
private readonly paperlessService: PaperlessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// --- Korrespondenten Mapping ---
|
// --- Korrespondenten Mapping ---
|
||||||
@@ -118,6 +124,51 @@ export class EmailImportController {
|
|||||||
return { isDuplicate };
|
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<VergleichKennzahlen> {
|
||||||
|
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 ---
|
// --- Print Preview ---
|
||||||
@Post('attachments/:attachmentId/print-preview')
|
@Post('attachments/:attachmentId/print-preview')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@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 };
|
||||||
|
}
|
||||||
@@ -348,9 +348,13 @@ export class EmailImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Print Preview ---
|
// --- Print Preview ---
|
||||||
async generatePrintPdf(
|
/**
|
||||||
|
* PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999`
|
||||||
|
* = bis zum Ende) – ohne Stempel.
|
||||||
|
*/
|
||||||
|
async getAttachmentPdf(
|
||||||
attachmentId: number,
|
attachmentId: number,
|
||||||
barcodeData: any,
|
pages?: { start: number; end: number },
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const content = await this.contentRepo.findOne({
|
const content = await this.contentRepo.findOne({
|
||||||
where: { AttachmentEntityId: attachmentId },
|
where: { AttachmentEntityId: attachmentId },
|
||||||
@@ -358,12 +362,9 @@ export class EmailImportService {
|
|||||||
if (!content)
|
if (!content)
|
||||||
throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND);
|
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 =
|
const pdfDoc = await PDFDocument.load(content.Content1, {
|
||||||
barcodeData._pages;
|
|
||||||
if (pages) {
|
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
|
||||||
ignoreEncryption: true,
|
ignoreEncryption: true,
|
||||||
});
|
});
|
||||||
const total = pdfDoc.getPageCount();
|
const total = pdfDoc.getPageCount();
|
||||||
@@ -376,10 +377,18 @@ export class EmailImportService {
|
|||||||
);
|
);
|
||||||
const copied = await sliced.copyPages(pdfDoc, indices);
|
const copied = await sliced.copyPages(pdfDoc, indices);
|
||||||
copied.forEach((p) => sliced.addPage(p));
|
copied.forEach((p) => sliced.addPage(p));
|
||||||
pdfBytes = Buffer.from(await sliced.save());
|
return Buffer.from(await sliced.save());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generatePrintPdf(
|
||||||
|
attachmentId: number,
|
||||||
|
barcodeData: any,
|
||||||
|
): Promise<Buffer> {
|
||||||
const { _pages, ...barcode } = barcodeData;
|
const { _pages, ...barcode } = barcodeData;
|
||||||
|
const pdfBytes = await this.getAttachmentPdf(
|
||||||
|
attachmentId,
|
||||||
|
_pages as { start: number; end: number } | undefined,
|
||||||
|
);
|
||||||
return this.applyBarcodeToPdf(pdfBytes, barcode);
|
return this.applyBarcodeToPdf(pdfBytes, barcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import { BarcodeScannerService } from '../barcode/barcode-scanner.service';
|
|||||||
import { UserSettingsService } from '../user-settings/user-settings.service';
|
import { UserSettingsService } from '../user-settings/user-settings.service';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
|
import {
|
||||||
|
kennzahlenAusPdf,
|
||||||
|
type VergleichKennzahlen,
|
||||||
|
} from '../paperless/vergleich-kennzahlen.util';
|
||||||
|
|
||||||
@Controller('api/inbox')
|
@Controller('api/inbox')
|
||||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
@RequirePermissions(Permission.VIEW_SCANNER)
|
||||||
@@ -30,6 +35,7 @@ export class InboxController {
|
|||||||
private readonly postprocessor: InboxPostprocessorService,
|
private readonly postprocessor: InboxPostprocessorService,
|
||||||
private readonly barcodeScanner: BarcodeScannerService,
|
private readonly barcodeScanner: BarcodeScannerService,
|
||||||
private readonly userSettingsService: UserSettingsService,
|
private readonly userSettingsService: UserSettingsService,
|
||||||
|
private readonly paperlessService: PaperlessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -250,6 +256,29 @@ export class InboxController {
|
|||||||
return new StreamableFile(Readable.from(buffer));
|
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<VergleichKennzahlen> {
|
||||||
|
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')
|
@Post(':id/send-email')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async sendEmail(
|
async sendEmail(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BarcodeModule } from '../barcode/barcode.module';
|
|||||||
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
|
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
|
||||||
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
||||||
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||||
|
import { PaperlessModule } from '../paperless/paperless.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,6 +20,7 @@ import { UserSettingsModule } from '../user-settings/user-settings.module';
|
|||||||
InboxPostprocessorModule,
|
InboxPostprocessorModule,
|
||||||
PostprocessingModule,
|
PostprocessingModule,
|
||||||
UserSettingsModule,
|
UserSettingsModule,
|
||||||
|
PaperlessModule,
|
||||||
],
|
],
|
||||||
controllers: [InboxController, ClientsController],
|
controllers: [InboxController, ClientsController],
|
||||||
providers: [InboxService, InboxMigrationService],
|
providers: [InboxService, InboxMigrationService],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import axios, { type AxiosInstance } from 'axios';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import FormData = require('form-data');
|
import FormData = require('form-data');
|
||||||
|
import { type Kennzahlen, zaehleWoerter } from './vergleich-kennzahlen.util';
|
||||||
import {
|
import {
|
||||||
type BelegnummerPruefung,
|
type BelegnummerPruefung,
|
||||||
belegnummerZuAsn,
|
belegnummerZuAsn,
|
||||||
@@ -160,6 +161,18 @@ export class PaperlessService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seiten- und Wortzahl eines Paperless-Dokuments (aktuelle Fassung). */
|
||||||
|
async getDocumentKennzahlen(id: number): Promise<Kennzahlen> {
|
||||||
|
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<any[]> {
|
async getInboxDocuments(): Promise<any[]> {
|
||||||
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
|
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
|
||||||
const response = await this.client.get('/documents/', {
|
const response = await this.client.get('/documents/', {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number | null> {
|
||||||
|
try {
|
||||||
|
const doc = await PDFDocument.load(pdf, { ignoreEncryption: true });
|
||||||
|
return doc.getPageCount();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wortzahl(pdf: Buffer): Promise<number | null> {
|
||||||
|
// 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<Kennzahlen> {
|
||||||
|
const [seiten, woerter] = await Promise.all([seitenzahl(pdf), wortzahl(pdf)]);
|
||||||
|
return { seiten, woerter };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import api from './client';
|
import api from './client';
|
||||||
|
import type { VergleichKennzahlen } from './paperless';
|
||||||
|
|
||||||
export interface CorrespondentMapping {
|
export interface CorrespondentMapping {
|
||||||
Id: number;
|
Id: number;
|
||||||
@@ -50,6 +51,23 @@ export const emailImportApi = {
|
|||||||
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number });
|
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
attachmentPdfBlob: async (attachmentId: number, pages?: { start: number; end: number }): Promise<Blob> => {
|
||||||
|
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<VergleichKennzahlen> => {
|
||||||
|
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<Blob> => {
|
printPreview: async (attachmentId: number, barcodeData: any, pages?: { start: number; end: number }): Promise<Blob> => {
|
||||||
const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, {
|
const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, { ...barcodeData, _pages: pages }, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import api from './client';
|
import api from './client';
|
||||||
import type { BarcodeActionType } from './barcode-templates';
|
import type { BarcodeActionType } from './barcode-templates';
|
||||||
|
import type { VergleichKennzahlen } from './paperless';
|
||||||
|
|
||||||
export type InboxSource = 'all' | 'user';
|
export type InboxSource = 'all' | 'user';
|
||||||
|
|
||||||
@@ -107,6 +108,11 @@ export const inboxApi = {
|
|||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
vergleich: (id: string, paperlessDocumentId: number, pages: number[]) =>
|
||||||
|
api
|
||||||
|
.post<VergleichKennzahlen>(`/api/inbox/${encodeURIComponent(id)}/vergleich`, { paperlessDocumentId, pages })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
downloadSegmentBlob: (id: string, pages: number[]) =>
|
downloadSegmentBlob: (id: string, pages: number[]) =>
|
||||||
api
|
api
|
||||||
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
|
.post<Blob>(`/api/inbox/${encodeURIComponent(id)}/download-segment`, { pages }, { responseType: 'blob' })
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ export interface BelegnummerPruefung {
|
|||||||
kandidaten: number[];
|
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 {
|
export interface PaperlessTag {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
Reference in new issue
Block a user