diff --git a/backend/services/tournamentSuggestionService.js b/backend/services/tournamentSuggestionService.js index 8ea36ad7..335ed99c 100644 --- a/backend/services/tournamentSuggestionService.js +++ b/backend/services/tournamentSuggestionService.js @@ -8,6 +8,48 @@ const MY_TT_BASE_URL = 'https://www.mytischtennis.de'; const cleanText = (value = '') => String(value).replace(/<[^>]*>/g, ' ').replace(/ /gi, ' ').replace(/&/gi, '&').replace(/\s+/g, ' ').trim(); const absoluteUrl = (href) => href.startsWith('http') ? href : `${MY_TT_BASE_URL}${href.startsWith('/') ? '' : '/'}${href}`; +function extractJsonArray(html, propertyName) { + const propertyIndex = String(html).indexOf(`"${propertyName}":[`); + if (propertyIndex < 0) return []; + const start = String(html).indexOf('[', propertyIndex); + let depth = 0; + let quoted = false; + let escaped = false; + for (let index = start; index < String(html).length; index += 1) { + const character = String(html)[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + continue; + } + if (character === '"') quoted = true; + else if (character === '[') depth += 1; + else if (character === ']') { + depth -= 1; + if (depth === 0) { + try { return JSON.parse(String(html).slice(start, index + 1)); } + catch (_error) { return []; } + } + } + } + return []; +} + +function parseTournamentWorldCalendar(html, federation) { + const tournaments = extractJsonArray(html, 'tournaments'); + return tournaments + .filter((tournament) => tournament?.id && tournament?.name) + .map((tournament) => ({ + title: cleanText(tournament.name).slice(0, 255), + eventDate: tournament.formattedStartDate || null, + organizer: cleanText(tournament.host || '').slice(0, 255) || null, + location: cleanText([tournament.location_zip, tournament.location].filter(Boolean).join(' ')).slice(0, 255) || null, + sourceUrl: `${MY_TT_BASE_URL}/turnierwelt/turniere/${encodeURIComponent(tournament.id)}`, + federation: tournament.organization_short || federation, + })); +} + function parseCalendar(html, federation) { const items = []; const anchors = [...String(html).matchAll(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)]; @@ -31,9 +73,23 @@ class TournamentSuggestionService { const club = await Club.findByPk(clubId); if (!club) throw new Error('Verein nicht gefunden.'); const federation = String(club.myTischtennisFedNickname || 'HeTTV').trim(); - const calendarUrl = `${MY_TT_BASE_URL}/click-tt/${encodeURIComponent(federation)}/turnierkalender`; + const startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(startDate); + endDate.setMonth(endDate.getMonth() + 6); + const calendarParams = new URLSearchParams({ + association: federation, + date_start: startDate.toISOString().slice(0, 10), + date_end: endDate.toISOString().slice(0, 10), + }); + const calendarUrl = `${MY_TT_BASE_URL}/turnierwelt/kalender?${calendarParams.toString()}`; const response = await axios.get(calendarUrl, { timeout: 30000, headers: { 'User-Agent': 'Trainingstagebuch/1.0 (+turnierkalender)' } }); - const parsed = parseCalendar(response.data, federation); + let parsed = parseTournamentWorldCalendar(response.data, federation); + if (!parsed.length) { + const legacyCalendarUrl = `${MY_TT_BASE_URL}/click-tt/${encodeURIComponent(federation)}/turnierkalender`; + const legacyResponse = await axios.get(legacyCalendarUrl, { timeout: 30000, headers: { 'User-Agent': 'Trainingstagebuch/1.0 (+turnierkalender)' } }); + parsed = parseCalendar(legacyResponse.data, federation); + } if (!parsed.length) throw new Error('Im Turnierkalender konnten keine Turniere erkannt werden.'); let newCount = 0; diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index 1f5321f9..f73324f9 100755 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -2185,6 +2185,8 @@ export default { .panel-header h3 { margin: 0; color: #17366d; font-size: 1rem; } .panel-header p { margin: 0; color: #64748b; font-size: .9rem; } .panel-toolbar { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; } +.suggestion-toolbar .title-input { flex: 1 1 22rem; min-width: 16rem; width: auto; } +.suggestion-toolbar select { flex: 0 0 15rem; width: 15rem; max-width: 30%; } .toolbar-checkbox { display: inline-flex; align-items: center; gap: .35rem; color: #556070; font-size: .9rem; white-space: nowrap; } .toolbar-meta { color: #64748b; font-size: .88rem; white-space: nowrap; } .event-list { list-style: none; padding: 0; margin: 0; } @@ -2451,6 +2453,14 @@ th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-ali align-items: stretch; } + .suggestion-toolbar .title-input, + .suggestion-toolbar select { + flex: 1 1 auto; + width: 100%; + min-width: 0; + max-width: none; + } + .overview-stats { grid-template-columns: repeat(2, minmax(120px, 1fr)); }