From d79e71d6d7f59cd3e7b95f25a142045a18385ad2 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 8 Nov 2025 10:15:25 +0100 Subject: [PATCH] Update PDF parsing and document upload handling in team management Enhanced the PDFParserService to support layout-based text extraction from PDFs using pdfjs-dist, improving parsing accuracy. Updated the team management view to streamline document uploads and parsing processes, removing unnecessary UI elements and consolidating upload logic. Improved error handling and user feedback during document processing, ensuring better user experience and clarity in case of issues. --- backend/controllers/teamDocumentController.js | 6 + backend/node_modules/.package-lock.json | 111 +++++ backend/package-lock.json | 106 +++++ backend/package.json | 1 + backend/services/pdfParserService.js | 385 +++++++++++++++--- .../2_code_list_1762593197315.pdf | Bin 0 -> 28743 bytes frontend/src/views/MembersView.vue | 42 +- frontend/src/views/TeamManagementView.vue | 275 +++++-------- 8 files changed, 688 insertions(+), 238 deletions(-) create mode 100644 backend/uploads/team-documents/2_code_list_1762593197315.pdf diff --git a/backend/controllers/teamDocumentController.js b/backend/controllers/teamDocumentController.js index 2ab238e..1d2a1e9 100644 --- a/backend/controllers/teamDocumentController.js +++ b/backend/controllers/teamDocumentController.js @@ -1,5 +1,6 @@ import multer from 'multer'; import path from 'path'; +import fs from 'fs'; import TeamDocumentService from '../services/teamDocumentService.js'; import PDFParserService from '../services/pdfParserService.js'; import { getUserByToken } from '../utils/userUtils.js'; @@ -8,6 +9,11 @@ import { devLog } from '../utils/logger.js'; // Multer-Konfiguration für Datei-Uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { + try { + fs.mkdirSync('uploads/temp', { recursive: true }); + } catch (mkdirError) { + console.error('[multer] - Failed to ensure temp upload directory exists:', mkdirError); + } cb(null, 'uploads/temp/'); }, filename: (req, file, cb) => { diff --git a/backend/node_modules/.package-lock.json b/backend/node_modules/.package-lock.json index fee7619..84b5d9c 100644 --- a/backend/node_modules/.package-lock.json +++ b/backend/node_modules/.package-lock.json @@ -1004,6 +1004,23 @@ "node": ">=6" } }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "ideallyInert": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1302,6 +1319,20 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "ideallyInert": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2642,6 +2673,20 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "ideallyInert": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2796,6 +2841,14 @@ "node": ">=12" } }, + "node_modules/nan": { + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", + "ideallyInert": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3089,6 +3142,16 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path2d-polyfill": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", + "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/pdf-parse": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", @@ -3117,6 +3180,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "3.11.174", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", + "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d-polyfill": "^2.0.1" + } + }, "node_modules/pg-connection-string": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", @@ -3639,6 +3715,41 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "ideallyInert": true, + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "ideallyInert": true, + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", diff --git a/backend/package-lock.json b/backend/package-lock.json index 9263645..310629b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "node-cron": "^4.2.1", "nodemailer": "^7.0.9", "pdf-parse": "^1.1.1", + "pdfjs-dist": "^3.11.174", "sequelize": "^6.37.3", "sharp": "^0.33.5" }, @@ -1017,6 +1018,22 @@ "node": ">=6" } }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1315,6 +1332,19 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2654,6 +2684,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2808,6 +2851,13 @@ "node": ">=12" } }, + "node_modules/nan": { + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3101,6 +3151,16 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path2d-polyfill": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", + "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/pdf-parse": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", @@ -3129,6 +3189,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "3.11.174", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", + "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d-polyfill": "^2.0.1" + } + }, "node_modules/pg-connection-string": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", @@ -3651,6 +3724,39 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", diff --git a/backend/package.json b/backend/package.json index 2ea54d8..d154c2c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "mysql2": "^3.10.3", + "pdfjs-dist": "^3.11.174", "node-cron": "^4.2.1", "nodemailer": "^7.0.9", "pdf-parse": "^1.1.1", diff --git a/backend/services/pdfParserService.js b/backend/services/pdfParserService.js index 44fdeb2..2b55b11 100644 --- a/backend/services/pdfParserService.js +++ b/backend/services/pdfParserService.js @@ -32,19 +32,28 @@ class PDFParserService { // Bestimme Dateityp basierend auf Dateiendung const fileExtension = path.extname(filePath).toLowerCase(); let fileContent; + let extractedLines = null; + let lineEntries = null; if (fileExtension === '.pdf') { - // Echte PDF-Parsing - const pdfBuffer = fs.readFileSync(filePath); - const pdfData = await pdfParse(pdfBuffer); - fileContent = pdfData.text; + try { + const { text, lines, entries } = await this.extractPdfTextWithLayout(filePath); + fileContent = text; + extractedLines = lines; + lineEntries = entries; + } catch (layoutError) { + console.error('[PDFParserService.parsePDF] - Layout extraction failed, falling back to pdf-parse:', layoutError); + const pdfBuffer = fs.readFileSync(filePath); + const pdfData = await pdfParse(pdfBuffer); + fileContent = pdfData.text; + } } else { // Fallback für TXT-Dateien (für Tests) fileContent = fs.readFileSync(filePath, 'utf8'); } // Parse den Text nach Spiel-Daten - const parsedData = this.extractMatchData(fileContent, clubId); + const parsedData = this.extractMatchData(fileContent, clubId, extractedLines, lineEntries); return parsedData; @@ -60,7 +69,7 @@ class PDFParserService { * @param {number} clubId - ID des Vereins * @returns {Object} Geparste Daten mit Matches und Metadaten */ - static extractMatchData(text, clubId) { + static extractMatchData(text, clubId, providedLines = null, providedLineEntries = null) { const matches = []; const errors = []; const metadata = { @@ -71,21 +80,33 @@ class PDFParserService { try { // Teile Text in Zeilen auf - const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0); + const linesSource = providedLines && Array.isArray(providedLines) ? providedLines : text.split('\n'); + const lines = []; + const filteredLineEntries = []; + + linesSource.forEach((line, idx) => { + const trimmed = typeof line === 'string' ? line.trim() : ''; + if (trimmed.length > 0) { + lines.push(trimmed); + if (providedLineEntries && Array.isArray(providedLineEntries) && providedLineEntries[idx]) { + filteredLineEntries.push(providedLineEntries[idx]); + } + } + }); metadata.totalLines = lines.length; // Verschiedene Parsing-Strategien je nach PDF-Format const strategies = [ - { name: 'Standard Format', fn: this.parseStandardFormat }, - { name: 'Table Format', fn: this.parseTableFormat }, - { name: 'List Format', fn: this.parseListFormat } + { name: 'Standard Format', fn: (lns, club, entries) => PDFParserService.parseStandardFormat(lns, club, entries) }, + { name: 'Table Format', fn: (lns, club, entries) => PDFParserService.parseTableFormat(lns, club, entries) }, + { name: 'List Format', fn: (lns, club, entries) => PDFParserService.parseListFormat(lns, club, entries) } ]; for (const strategy of strategies) { try { - const result = strategy.fn(lines, clubId); + const result = strategy.fn(lines, clubId, filteredLineEntries.length === lines.length ? filteredLineEntries : null); if (result.matches.length > 0) { console.log(`[PDF Parser] Using strategy: ${strategy.name}, found ${result.matches.length} matches`); @@ -134,12 +155,29 @@ class PDFParserService { * @param {number} clubId - ID des Vereins * @returns {Object} Geparste Matches */ - static parseStandardFormat(lines, clubId) { + static parseStandardFormat(lines, clubId, lineEntries = null) { const matches = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; + const lineDetail = Array.isArray(lineEntries) ? lineEntries[i] : null; + const columnSegments = lineDetail ? this.segmentLineByPositions(lineDetail) : null; + let homeFromColumns = null; + let guestFromColumns = null; + let codeFromColumns = null; + + if (columnSegments && columnSegments.length >= 3) { + homeFromColumns = columnSegments[1]?.trim() || null; + guestFromColumns = columnSegments[2]?.trim() || null; + const lastSegment = columnSegments[columnSegments.length - 1]; + if (lastSegment) { + const candidateCode = lastSegment.replace(/\s+/g, '').trim(); + if (/^[A-Z0-9]{12}$/.test(candidateCode)) { + codeFromColumns = candidateCode; + } + } + } // Suche nach Datum-Pattern (dd.mm.yyyy oder dd/mm/yyyy) const dateMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/); @@ -181,7 +219,7 @@ class PDFParserService { const cleanLine3 = cleanLine2.replace(/\([^)]*\)/g, ''); // Suche nach Code (12 Zeichen) oder PIN (4 Ziffern) am Ende - const codeMatch = cleanLine3.match(/([A-Z0-9]{12})$/); + let codeMatch = cleanLine3.match(/([A-Z0-9]{12})$/); const pinMatch = cleanLine3.match(/(\d{4})$/); let code = null; @@ -222,6 +260,11 @@ class PDFParserService { } } + if (!code && codeFromColumns) { + code = codeFromColumns; + teamsPart = teamsPart.replace(new RegExp(`${code}$`), '').trim(); + } + if (code || pinMatch) { @@ -275,39 +318,89 @@ class PDFParserService { // Strategie 1: Suche nach "Harheimer TC" als Heimteam oder Gastteam if (teamsPart.includes('Harheimer TC')) { const harheimerIndex = teamsPart.indexOf('Harheimer TC'); - - // Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim(); let afterHarheimer = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim(); - // Entferne Spielnummern aus beiden Teilen - beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim(); - afterHarheimer = afterHarheimer.replace(/^\d+/, '').trim(); + beforeHarheimer = beforeHarheimer + .replace(/^\(\d+\)/, '') + .replace(/^\d+/, '') + .trim(); + afterHarheimer = afterHarheimer + .replace(/^\(\d+\)/, '') + .replace(/^\d+/, '') + .trim(); - if (beforeHarheimer && !afterHarheimer) { - // "Harheimer TC" ist am Ende → Harheimer ist Gastteam + const romanNumeralCandidates = ['XII', 'XI', 'X', 'IX', 'VIII', 'VII', 'VI', 'V', 'IV', 'III', 'II', 'I']; + + const matchLeadingRoman = (token) => { + if (!token) { + return null; + } + const normalizedToken = token.trim(); + for (const candidate of romanNumeralCandidates) { + if (normalizedToken.startsWith(candidate)) { + const nextChar = normalizedToken.charAt(candidate.length); + if (!nextChar || /\s|[A-ZÄÖÜẞ]/.test(nextChar)) { + const remainder = normalizedToken.slice(candidate.length).trimStart(); + return { roman: candidate, remainder }; + } + } + } + return null; + }; + + const extractLeadingRomanFromTokens = (tokenList) => { + const tokensCopy = Array.isArray(tokenList) ? [...tokenList] : []; + if (tokensCopy.length === 0) { + return { roman: null, tokens: tokensCopy }; + } + + const firstToken = tokensCopy[0]; + const match = matchLeadingRoman(firstToken); + + if (match) { + const { roman, remainder } = match; + if (remainder) { + tokensCopy[0] = remainder; + } else { + tokensCopy.shift(); + } + return { roman, tokens: tokensCopy }; + } + + return { roman: null, tokens: tokensCopy }; + }; + + if (!beforeHarheimer && afterHarheimer) { + const tokens = afterHarheimer.split(/\s+/).filter(Boolean); + const { roman: homeRoman, tokens: guestTokens } = extractLeadingRomanFromTokens(tokens); + const homeSuffix = homeRoman ? ` ${homeRoman}` : ''; + homeTeamName = `Harheimer TC${homeSuffix}`; + guestTeamName = guestTokens.join(' ').trim(); + } else if (beforeHarheimer && !afterHarheimer) { + // "Harheimer TC" ist Gastteam ohne weitere Tokens + homeTeamName = beforeHarheimer.replace(/\([^)]*\)/g, '').trim(); guestTeamName = 'Harheimer TC'; - homeTeamName = beforeHarheimer - .replace(/\([^)]*\)/g, '') // Entferne Klammern - .trim(); - } else if (!beforeHarheimer && afterHarheimer) { - // "Harheimer TC" ist am Anfang → Harheimer ist Heimteam - homeTeamName = 'Harheimer TC'; - guestTeamName = afterHarheimer - .replace(/\([^)]*\)/g, '') // Entferne Klammern - .trim(); } else if (beforeHarheimer && afterHarheimer) { - // "Harheimer TC" ist in der Mitte → verwende Position als Hinweis - // Normalerweise: Heimteam zuerst, dann Gastteam - homeTeamName = beforeHarheimer - .replace(/\([^)]*\)/g, '') // Entferne Klammern - .trim(); - guestTeamName = 'Harheimer TC'; + // "Harheimer TC" steht in der Mitte → Harheimer ist Gast, Tokens nach Harheimer gehören zu ihm + homeTeamName = beforeHarheimer.replace(/\([^)]*\)/g, '').trim(); + const tokens = afterHarheimer.split(/\s+/).filter(Boolean); + const { roman: guestRoman, tokens: remainingTokens } = extractLeadingRomanFromTokens(tokens); + const guestSuffix = guestRoman ? ` ${guestRoman}` : ''; + guestTeamName = `Harheimer TC${guestSuffix}`; + if (remainingTokens.length > 0) { + const trailingText = remainingTokens.join(' ').trim(); + if (trailingText) { + guestTeamName = `${guestTeamName} ${trailingText}`.trim(); + } + } } else { - // Nur "Harheimer TC" ohne andere Teams → ungültig + // Nur "Harheimer TC" ohne weitere Kontexte → überspringen continue; } - + + homeTeamName = homeTeamName.replace(/\([^)]*\)/g, '').trim(); + guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim(); } else { // Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams const teamSplitMatch = teamsPart.match(/^([A-Za-z0-9\s\-\.]+?)\s+([A-Z][A-Za-z0-9\s\-\.]+)$/); @@ -322,6 +415,13 @@ class PDFParserService { } } + if (homeFromColumns) { + homeTeamName = homeFromColumns; + } + if (guestFromColumns) { + guestTeamName = guestFromColumns; + } + if (homeTeamName && guestTeamName) { let debugInfo; if (code) { @@ -358,13 +458,59 @@ class PDFParserService { return { matches }; } + static segmentLineByPositions(lineDetail) { + if (!lineDetail || !Array.isArray(lineDetail.items)) { + return null; + } + + const intraWordGapThreshold = 1.5; + const columnGapThreshold = 12; + const segments = []; + + let currentSegment = ''; + let previousItem = null; + + lineDetail.items.forEach((item) => { + if (!item || typeof item.text !== 'string') { + return; + } + const text = item.text; + if (!text || text.trim().length === 0) { + return; + } + + if (previousItem) { + const previousEnd = previousItem.x + previousItem.width; + const gap = item.x - previousEnd; + + if (gap > columnGapThreshold) { + if (currentSegment.trim().length > 0) { + segments.push(currentSegment.trim()); + } + currentSegment = ''; + } else if (gap > intraWordGapThreshold) { + currentSegment += ' '; + } + } + + currentSegment += text; + previousItem = item; + }); + + if (currentSegment.trim().length > 0) { + segments.push(currentSegment.trim()); + } + + return segments.length > 0 ? segments : null; + } + /** * Tabellen-Format Parser * @param {Array} lines - Textzeilen * @param {number} clubId - ID des Vereins * @returns {Object} Geparste Matches */ - static parseTableFormat(lines, clubId) { + static parseTableFormat(lines, clubId, lineEntries = null) { const matches = []; // Suche nach Tabellen-Header @@ -428,7 +574,7 @@ class PDFParserService { * @param {number} clubId - ID des Vereins * @returns {Object} Geparste Matches */ - static parseListFormat(lines, clubId) { + static parseListFormat(lines, clubId, lineEntries = null) { const matches = []; for (let i = 0; i < lines.length; i++) { @@ -559,13 +705,10 @@ class PDFParserService { const matchingMatch = existingMatches.find(match => { if (!match.guestTeam) return false; - const guestTeamName = match.guestTeam.name.toLowerCase(); - const searchGuestName = matchData.guestTeamName.toLowerCase(); - - // Exakte Übereinstimmung oder Teilstring-Match - return guestTeamName === searchGuestName || - guestTeamName.includes(searchGuestName) || - searchGuestName.includes(guestTeamName); + const guestTeamName = match.guestTeam.name; + const searchGuestName = matchData.guestTeamName; + + return PDFParserService.namesRoughlyMatch(guestTeamName, searchGuestName); }); if (matchingMatch) { @@ -631,8 +774,7 @@ class PDFParserService { // Fuzzy-Matching für Team-Namen if (!homeTeam) { homeTeam = allTeams.find(t => - t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) || - matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase()) + PDFParserService.namesRoughlyMatch(t.name, matchData.homeTeamName) ); if (homeTeam) { @@ -642,8 +784,7 @@ class PDFParserService { if (!guestTeam) { guestTeam = allTeams.find(t => - t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) || - matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase()) + PDFParserService.namesRoughlyMatch(t.name, matchData.guestTeamName) ); if (guestTeam) { @@ -694,6 +835,150 @@ class PDFParserService { throw error; } } + + static async extractPdfTextWithLayout(filePath) { + const { default: pdfjsLib } = await import('pdfjs-dist/legacy/build/pdf.js'); + const pdfData = new Uint8Array(fs.readFileSync(filePath)); + const loadingTask = pdfjsLib.getDocument({ data: pdfData, disableWorker: true }); + const pdf = await loadingTask.promise; + + const lineEntries = []; + const lineTolerance = 2; // Toleranz für Zeilenhöhe + const spaceGapThreshold = 1.5; // Mindestabstand, um ein Leerzeichen einzufügen + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) { + const page = await pdf.getPage(pageNumber); + const textContent = await page.getTextContent({ normalizeWhitespace: false }); + const pageLines = []; + + textContent.items.forEach((item) => { + if (!item || typeof item.str !== 'string') { + return; + } + const text = item.str; + if (!text || text.trim().length === 0) { + return; + } + + const [scaleX, , , , x, y] = item.transform; + const width = (item.width || 0) * (scaleX || 1); + + let targetLine = pageLines.find((line) => Math.abs(line.y - y) < lineTolerance); + if (!targetLine) { + targetLine = { y, items: [] }; + pageLines.push(targetLine); + } + + targetLine.items.push({ + text, + x, + y, + width + }); + }); + + // Sortiere Zeilen von oben nach unten + pageLines.sort((a, b) => b.y - a.y); + + pageLines.forEach((line) => { + // Sortiere Zeichen von links nach rechts + line.items.sort((a, b) => a.x - b.x); + + let lineText = ''; + let previousItem = null; + + line.items.forEach((item) => { + if (previousItem) { + const previousEnd = previousItem.x + previousItem.width; + const gap = item.x - previousEnd; + if (gap > spaceGapThreshold) { + lineText += ' '; + } + } + + lineText += item.text; + previousItem = item; + }); + + const normalized = lineText.trim(); + if (normalized.length > 0) { + lineEntries.push({ + text: normalized, + items: line.items.map((item) => ({ + text: item.text, + x: item.x, + y: item.y, + width: item.width + })) + }); + } + }); + } + + await pdf.destroy(); + + const lines = lineEntries.map((entry) => entry.text); + const text = lines.join('\n'); + return { text, lines, entries: lineEntries }; + } + + static normalizeTeamName(name) { + if (!name || typeof name !== 'string') return ''; + return name + .toLowerCase() + .replace(/\u2026/g, '...') + .replace(/\s+/g, ' ') + .trim(); + } + + static matchWithEllipsis(pattern, target) { + const normalizedPattern = PDFParserService.normalizeTeamName(pattern); + const normalizedTarget = PDFParserService.normalizeTeamName(target); + + if (!normalizedPattern.includes('...')) { + return normalizedTarget.includes(normalizedPattern); + } + + const segments = normalizedPattern.split('...').map(segment => segment.trim()).filter(Boolean); + if (segments.length === 0) { + return true; + } + + let currentIndex = 0; + for (const segment of segments) { + const foundIndex = normalizedTarget.indexOf(segment, currentIndex); + if (foundIndex === -1) { + return false; + } + currentIndex = foundIndex + segment.length; + } + + return true; + } + + static namesRoughlyMatch(nameA, nameB) { + const normalizedA = PDFParserService.normalizeTeamName(nameA); + const normalizedB = PDFParserService.normalizeTeamName(nameB); + + if (!normalizedA || !normalizedB) { + return false; + } + + if (normalizedA === normalizedB) { + return true; + } + + if (normalizedA.includes('...') || normalizedB.includes('...')) { + if (PDFParserService.matchWithEllipsis(normalizedA, normalizedB)) { + return true; + } + if (PDFParserService.matchWithEllipsis(normalizedB, normalizedA)) { + return true; + } + } + + return normalizedA.includes(normalizedB) || normalizedB.includes(normalizedA); + } } export default PDFParserService; diff --git a/backend/uploads/team-documents/2_code_list_1762593197315.pdf b/backend/uploads/team-documents/2_code_list_1762593197315.pdf new file mode 100644 index 0000000000000000000000000000000000000000..38108945db75125928316787bcd59f3bbe5da190 GIT binary patch literal 28743 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 b0gKep$;83wXxTwvz{3DYI5=cgQ6&ElOhrr{ literal 0 HcmV?d00001 diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index a927452..2513252 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -213,7 +213,7 @@ - + @@ -241,26 +241,26 @@ @delete-note="deleteNote" @close="closeNotesModal" /> - - - - - - + + + + + + - -
-
- Ausgewählte Datei: {{ pendingUploadFile?.name }} -
- Typ: {{ pendingUploadType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }} -
- Team: {{ teamToEdit?.name }} -
- Liga: {{ getTeamLeagueName() }} -
-
- - -
-
- - -
- 📄 Hochgeladene Dokumente - - - - - - - - - - - - - - - - - -
DateinameTypGrößeAktionen
{{ document.originalFileName }} - - {{ document.documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }} - - {{ formatFileSize(document.fileSize) }} - -
-
-
🔄 Automatische Jobs @@ -443,10 +382,8 @@ export default { const selectedSeasonId = ref(null); const currentSeason = ref(null); const teamDocuments = ref([]); - const pendingUploadFile = ref(null); - const pendingUploadType = ref(null); - const showLeagueSelection = ref(false); const parsingInProgress = ref(false); + const parsingDocuments = ref({}); // PDF-Dialog Variablen const showPDFViewer = ref(false); @@ -594,42 +531,34 @@ export default { const uploadCodeList = () => { if (!teamToEdit.value) return; + if (parsingInProgress.value) { + return; + } - // Erstelle ein verstecktes File-Input-Element const input = document.createElement('input'); input.type = 'file'; input.accept = '.pdf,.doc,.docx,.txt,.csv'; input.onchange = async (event) => { const file = event.target.files[0]; if (!file) return; - - // Speichere die Datei und den Typ für späteres Parsing - pendingUploadFile.value = file; - pendingUploadType.value = 'code_list'; - - // Zeige Liga-Auswahl für Parsing - showLeagueSelection.value = true; + await uploadAndParseDocument(file, 'code_list'); }; input.click(); }; const uploadPinList = () => { if (!teamToEdit.value) return; + if (parsingInProgress.value) { + return; + } - // Erstelle ein verstecktes File-Input-Element const input = document.createElement('input'); input.type = 'file'; input.accept = '.pdf,.doc,.docx,.txt,.csv'; input.onchange = async (event) => { const file = event.target.files[0]; if (!file) return; - - // Speichere die Datei und den Typ für späteres Parsing - pendingUploadFile.value = file; - pendingUploadType.value = 'pin_list'; - - // Zeige Liga-Auswahl für Parsing - showLeagueSelection.value = true; + await uploadAndParseDocument(file, 'pin_list'); }; input.click(); }; @@ -702,136 +631,153 @@ export default { } }; - const confirmUploadAndParse = async () => { - if (!pendingUploadFile.value || !teamToEdit.value?.leagueId) { - alert('Team ist keiner Liga zugeordnet!'); + const uploadAndParseDocument = async (file, documentType) => { + if (!teamToEdit.value?.leagueId) { + await showInfo( + 'Hinweis', + 'Dieses Team ist keiner Liga zugeordnet.', + 'Bitte ordnen Sie dem Team zuerst eine Liga zu, damit Dokumente verarbeitet werden können.', + 'warning' + ); return; } - + + if (parsingInProgress.value) { + return; + } + parsingInProgress.value = true; - + try { - - // Schritt 1: Datei als Team-Dokument hochladen const formData = new FormData(); - formData.append('document', pendingUploadFile.value); - formData.append('documentType', pendingUploadType.value); - + formData.append('document', file); + formData.append('documentType', documentType); + const uploadResponse = await apiClient.post(`/team-documents/club-team/${teamToEdit.value.id}/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); - - - // Schritt 2: Datei parsen (nur für PDF/TXT-Dateien) - const fileExtension = pendingUploadFile.value.name.toLowerCase().split('.').pop(); + + const fileExtension = file.name.toLowerCase().split('.').pop(); + const documentLabel = documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste'; + if (fileExtension === 'pdf' || fileExtension === 'txt') { const parseResponse = await apiClient.post(`/team-documents/${uploadResponse.data.id}/parse?leagueid=${teamToEdit.value.leagueId}`); - - const { parseResult, saveResult } = parseResponse.data; - - let message = `${pendingUploadType.value === 'code_list' ? 'Code-Liste' : 'Pin-Liste'} erfolgreich hochgeladen und geparst!\n\n`; + + let message = `${documentLabel} erfolgreich hochgeladen und geparst!\n\n`; message += `Gefundene Spiele: ${parseResult.matchesFound}\n`; message += `Neue Spiele erstellt: ${saveResult.created}\n`; - message += `Spiele aktualisiert: ${saveResult.updated}\n`; - + message += `Spiele aktualisiert: ${saveResult.updated}`; + if (saveResult.errors.length > 0) { - message += `\nFehler: ${saveResult.errors.length}\n`; + message += `\n\nFehler: ${saveResult.errors.length}\n`; message += saveResult.errors.slice(0, 3).join('\n'); if (saveResult.errors.length > 3) { message += `\n... und ${saveResult.errors.length - 3} weitere`; } } - - // Debug-Informationen anzeigen wenn keine Matches gefunden wurden + + let dialogTitle = 'Erfolg'; + let dialogType = 'success'; + if (parseResult.matchesFound === 0) { - message += `\n\n--- DEBUG-INFORMATIONEN ---\n`; - message += `Text-Länge: ${parseResult.debugInfo.totalTextLength} Zeichen\n`; - message += `Zeilen: ${parseResult.debugInfo.totalLines}\n`; - message += `Erste Zeilen:\n`; - parseResult.debugInfo.firstFewLines.forEach((line, index) => { - message += `${index + 1}: "${line}"\n`; - }); - message += `\nLetzte Zeilen:\n`; - parseResult.debugInfo.lastFewLines.forEach((line, index) => { - message += `${parseResult.debugInfo.totalLines - 5 + index + 1}: "${line}"\n`; - }); - // Fehler-Dialog wenn nichts gefunden wurde - await showInfo('Fehler', message, '', 'error'); + dialogTitle = 'Keine Spiele gefunden'; + dialogType = 'warning'; + if (parseResult.debugInfo) { + message += `\n\nHinweis: Keine Spiele erkannt.\nZeilen im Dokument: ${parseResult.debugInfo.totalLines}`; + } } else if (saveResult.errors.length > 0) { - // Warnung wenn Spiele gefunden wurden, aber Fehler auftraten - await showInfo('Warnung', message, '', 'warning'); - } else { - // Erfolg wenn alles geklappt hat - await showInfo('Erfolg', message, '', 'success'); + dialogTitle = 'Warnung'; + dialogType = 'warning'; } + + await showInfo(dialogTitle, message, '', dialogType); } else { - // Für andere Dateitypen nur Upload-Bestätigung - await showInfo('Information', `${pendingUploadType.value === 'code_list' ? 'Code-Liste' : 'Pin-Liste'} "${pendingUploadFile.value.name}" wurde erfolgreich hochgeladen!`, '', 'info'); + await showInfo('Information', `${documentLabel} "${file.name}" wurde erfolgreich hochgeladen!`, '', 'info'); } - - // Dokumente neu laden + await loadTeamDocuments(); - } catch (error) { console.error('Fehler beim Hochladen und Parsen der Datei:', error); - await showInfo('Fehler', 'Fehler beim Hochladen und Parsen der Datei', '', 'error'); + const responseData = error?.response?.data || {}; + const errorMessage = responseData.message || responseData.error || error.message || 'Fehler beim Hochladen und Parsen der Datei'; + await showInfo('Fehler', errorMessage, '', 'error'); } finally { parsingInProgress.value = false; - pendingUploadFile.value = null; - pendingUploadType.value = null; - showLeagueSelection.value = false; } }; - - const cancelUpload = () => { - pendingUploadFile.value = null; - pendingUploadType.value = null; - showLeagueSelection.value = false; - }; - - const getTeamLeagueName = () => { - if (!teamToEdit.value?.leagueId) return 'Keine Liga zugeordnet'; - const league = leagues.value.find(l => l.id === teamToEdit.value.leagueId); - return league ? league.name : 'Unbekannte Liga'; - }; - - - - const parsePDF = async (document) => { - // Finde das Team für dieses Dokument +const parsePDF = async (document) => { const team = teams.value.find(t => t.id === document.clubTeamId); if (!team || !team.leagueId) { - alert('Team ist keiner Liga zugeordnet!'); + await showInfo( + 'Hinweis', + 'Dieses Team ist keiner Liga zugeordnet.', + 'Bitte ordnen Sie dem Team zuerst eine Liga zu, um PDF-Dateien zu parsen.', + 'warning' + ); return; } + + if (parsingDocuments.value[document.id]) { + return; + } + + parsingDocuments.value = { + ...parsingDocuments.value, + [document.id]: true + }; try { - const response = await apiClient.post(`/team-documents/${document.id}/parse?leagueid=${team.leagueId}`); - - const { parseResult, saveResult } = response.data; - - let message = `PDF erfolgreich geparst!\n\n`; - message += `Gefundene Spiele: ${parseResult.matchesFound}\n`; + + let message = `Gefundene Spiele: ${parseResult.matchesFound}\n`; message += `Neue Spiele erstellt: ${saveResult.created}\n`; - message += `Spiele aktualisiert: ${saveResult.updated}\n`; - - if (saveResult.errors.length > 0) { - message += `\nFehler: ${saveResult.errors.length}\n`; + message += `Spiele aktualisiert: ${saveResult.updated}`; + + let dialogTitle = 'Erfolg'; + let dialogType = 'success'; + + if (parseResult.matchesFound === 0) { + dialogTitle = 'Keine Spiele gefunden'; + dialogType = 'warning'; + if (parseResult.debugInfo) { + message += `\n\nHinweis: Keine Spiele erkannt.\nZeilen im Dokument: ${parseResult.debugInfo.totalLines}`; + } + } else if (saveResult.errors.length > 0) { + dialogTitle = 'Warnung'; + dialogType = 'warning'; + message += `\n\nFehler: ${saveResult.errors.length}\n`; message += saveResult.errors.slice(0, 3).join('\n'); if (saveResult.errors.length > 3) { message += `\n... und ${saveResult.errors.length - 3} weitere`; } } - - this.showInfo('Fehler', message, '', 'error'); + + await showInfo(dialogTitle, message, '', dialogType); + await loadTeamDocuments(); } catch (error) { console.error('Fehler beim Parsen der PDF:', error); - this.showInfo('Fehler', 'Fehler beim Parsen der PDF-Datei', '', 'error'); + const responseData = error?.response?.data || {}; + const status = error?.response?.status; + let errorMessage = responseData.message || responseData.error || error.message || 'Fehler beim Parsen der PDF-Datei'; + let details = ''; + + if (status === 404 && responseData.error === 'documentnotfound') { + errorMessage = 'Das ausgewählte Dokument wurde nicht gefunden.'; + } else if (status === 400 && responseData.error === 'missingleagueid') { + errorMessage = 'Für das ausgewählte Team wurde keine Liga übermittelt.'; + } else if (error.code === 'ENOENT' || errorMessage.includes('ENOENT')) { + errorMessage = 'Die PDF-Datei konnte nicht gefunden werden.'; + details = 'Bitte laden Sie die Datei erneut hoch und versuchen Sie es noch einmal.'; + } + + await showInfo('Fehler', errorMessage, details, 'error'); + } finally { + const { [document.id]: _ignored, ...rest } = parsingDocuments.value; + parsingDocuments.value = rest; } }; @@ -1289,10 +1235,8 @@ export default { selectedSeasonId, currentSeason, teamDocuments, - pendingUploadFile, - pendingUploadType, - showLeagueSelection, parsingInProgress, + parsingDocuments, showPDFViewer, pdfUrl, pdfDialogTitle, @@ -1317,9 +1261,6 @@ export default { uploadPinList, loadTeamDocuments, loadAllTeamDocuments, - confirmUploadAndParse, - cancelUpload, - getTeamLeagueName, parsePDF, getTeamDocuments, showPDFDialog,