import { promises as fs } from 'fs' import path from 'path' import { getProjectPath, getServerDataPath } from './paths.js' import { error as loggerError, info as loggerInfo } from './logger.js' const SPIELPLAN_HEADERS = [ 'Termin', 'Timestamp', 'Wochentag', 'Verband', 'Saison', 'Meisterschaft', 'Altersklasse', 'Liga', 'Staffel', 'Runde', 'BegegnungNr', 'HalleNr', 'HalleName', 'HalleStrasse', 'HallePLZ', 'HalleOrt', 'HeimVereinVerband', 'HeimVereinNr', 'HeimVereinName', 'HeimMannschaftAltersklasse', 'HeimMannschaftNr', 'HeimMannschaft', 'GastVereinVerband', 'GastVereinNr', 'GastVereinName', 'GastMannschaftAltersklasse', 'GastMannschaftNr', 'GastMannschaft', 'SpieleHeim', 'SpieleGast' ] const CLUB_ID = '43030' const CLUB_NAME = 'Harheimer TC' const SEASON_SLUG_PATTERN = /^\d{2}--\d{2}$/ const SEASON_FILE_PATTERN = /^spielplan-(\d{2}--\d{2})\.json$/ function formatGermanDateTimeFromTimestamp(timestamp) { if (!Number.isFinite(timestamp)) return '' const parts = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date(timestamp * 1000)) const values = Object.fromEntries(parts.map((part) => [part.type, part.value])) return `${values.day}.${values.month}.${values.year} ${values.hour}:${values.minute}` } function getMatchTimestamp(match) { if (Number.isFinite(match?.timestamp)) return match.timestamp const parsedDate = match?.date ? Date.parse(match.date) : NaN return Number.isNaN(parsedDate) ? null : Math.floor(parsedDate / 1000) } function seasonLabel(season) { if (!season?.startYear || !season?.endYear) return '' return `${season.startYear}/${String(season.endYear).slice(-2)}` } function seasonSlugToLabel(slug) { const match = String(slug || '').match(/^(\d{2})--(\d{2})$/) if (!match) return '' return `20${match[1]}/${match[2]}` } function logReadError(message, filePath, error) { loggerError(message, { filePath, error }) } export function requireSeasonSlug(seasonSlug) { const value = String(seasonSlug || '') if (!SEASON_SLUG_PATTERN.test(value)) { throw new Error(`Ungueltiger Spielplan-Saison-Slug: ${value}`) } return value } export function validateSeasonSlug(seasonSlug) { try { requireSeasonSlug(seasonSlug) return true } catch (_e) { return false } } function normalizeSpielplanCsvPath(csvPath) { const value = String(csvPath || '') if (value.includes('\0') || path.basename(value) !== 'spielplan.csv') { throw new Error('Ungueltiger Spielplan-CSV-Pfad') } return path.normalize(value) } export function getCurrentSeasonSlug(date = new Date()) { const year = date.getFullYear() const month = date.getMonth() const startYear = month >= 6 ? year : year - 1 const endYear = startYear + 1 return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}` } export function inferSeasonSlugFromRows(data) { if (!Array.isArray(data)) return null for (const row of data) { const seasonValue = String(row?.Saison || '') const seasonMatch = seasonValue.match(/(\d{4})\/(\d{2})/) if (seasonMatch) { return `${seasonMatch[1].slice(-2)}--${seasonMatch[2]}` } } for (const row of data) { const timestamp = Number(row?.Timestamp) if (Number.isFinite(timestamp) && timestamp > 0) { return getCurrentSeasonSlug(new Date(timestamp * 1000)) } const termin = String(row?.Termin || '').trim() const dateMatch = termin.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/) if (dateMatch) { const [, day, month, year] = dateMatch return getCurrentSeasonSlug(new Date(Number(year), Number(month) - 1, Number(day))) } } return null } function inferAgeClass(match, teamName) { const haystack = [ teamName, match.leagueName, match.leagueShortName ].filter(Boolean).join(' ').toLowerCase() if (haystack.includes('jugend') || /\bj\d{2}\b/.test(haystack)) { const ageMatch = haystack.match(/j(?:ugend\s*)?(\d{2})/) return ageMatch ? `Jugend ${ageMatch[1]}` : 'Jugend' } return 'Erwachsene' } function inferTeamNumber(teamName) { const normalized = String(teamName || '').trim() if (!normalized) return '' if (normalized === CLUB_NAME || normalized.includes('(J')) return '1' const match = normalized.match(/\b([IVX]+)\b$/i) if (!match) return '1' const values = { I: 1, V: 5, X: 10 } const roman = match[1].toUpperCase() let total = 0 for (let i = 0; i < roman.length; i += 1) { const current = values[roman[i]] || 0 const next = values[roman[i + 1]] || 0 total += current < next ? -current : current } return String(total || '') } function splitResult(result) { const match = String(result || '').match(/^(\d+)\s*:\s*(\d+)$/) return { home: match ? match[1] : '', away: match ? match[2] : '' } } function isHarheimerMatch(match) { return match?.teamHomeClubId === CLUB_ID || match?.teamAwayClubId === CLUB_ID } export function validateImportedSpielplan(imported) { if (!imported || typeof imported !== 'object') { throw new Error('Import-Datei ist kein JSON-Objekt') } if (imported.source?.clubId !== CLUB_ID) { throw new Error(`Unerwartete Vereinsnummer: ${imported.source?.clubId || 'leer'}`) } if (!Array.isArray(imported.matches) || imported.matches.length === 0) { throw new Error('Import-Datei enthaelt keine Spiele') } if (imported.matchCount !== imported.matches.length) { throw new Error(`matchCount passt nicht zu matches.length (${imported.matchCount} != ${imported.matches.length})`) } imported.matches.forEach((match, index) => { for (const field of ['date', 'meetingId', 'teamHome', 'teamAway']) { if (!match[field]) { throw new Error(`Spiel ${index + 1}: Pflichtfeld ${field} fehlt`) } } if (!Number.isFinite(getMatchTimestamp(match))) { throw new Error(`Spiel ${index + 1}: timestamp/date fehlt oder ist ungueltig`) } if (!isHarheimerMatch(match)) { throw new Error(`Spiel ${index + 1}: keine Harheimer Beteiligung`) } }) return true } export function convertImportedSpielplanToJson(imported) { validateImportedSpielplan(imported) const season = seasonLabel(imported.source?.season) const data = imported.matches.map((match) => { const result = splitResult(match.result) const timestamp = getMatchTimestamp(match) const homeIsHarheim = match.teamHomeClubId === CLUB_ID const awayIsHarheim = match.teamAwayClubId === CLUB_ID return { Termin: formatGermanDateTimeFromTimestamp(timestamp), Timestamp: String(timestamp), Wochentag: match.formattedDay ? match.formattedDay.split(',')[0] : '', Verband: match.leagueOrgShortName || imported.source?.association || '', Saison: season, Meisterschaft: season ? `Kreis Frankfurt ${season}` : '', Altersklasse: inferAgeClass(match, homeIsHarheim ? match.teamHome : match.teamAway), Liga: match.leagueName || '', Staffel: match.leagueShortName || '', Runde: match.roundName || '', BegegnungNr: match.meetingNumber || match.meetingId || '', HalleNr: match.hallNumber || '', HalleName: match.location?.label || '', HalleStrasse: match.location?.street || '', HallePLZ: match.location?.zip || '', HalleOrt: match.location?.city || '', HeimVereinVerband: match.leagueOrgShortName || imported.source?.association || '', HeimVereinNr: match.teamHomeClubId || '', HeimVereinName: homeIsHarheim ? CLUB_NAME : '', HeimMannschaftAltersklasse: inferAgeClass(match, match.teamHome), HeimMannschaftNr: inferTeamNumber(match.teamHome), HeimMannschaft: match.teamHome || '', GastVereinVerband: match.leagueOrgShortName || imported.source?.association || '', GastVereinNr: match.teamAwayClubId || '', GastVereinName: awayIsHarheim ? CLUB_NAME : '', GastMannschaftAltersklasse: inferAgeClass(match, match.teamAway), GastMannschaftNr: inferTeamNumber(match.teamAway), GastMannschaft: match.teamAway || '', SpieleHeim: result.home, SpieleGast: result.away } }) return { format: 'harheimertc.spielplan.v1', sourceFormat: 'mytischtennis.import.v1', updatedAt: new Date().toISOString(), importedAt: imported.importedAt || null, source: imported.source, headers: SPIELPLAN_HEADERS, data } } export function parseDelimitedLine(line, delimiter) { const values = [] let current = '' let inQuotes = false for (let i = 0; i < line.length; i += 1) { const char = line[i] const nextChar = line[i + 1] if (char === '"' && inQuotes && nextChar === '"') { current += '"' i += 1 } else if (char === '"') { inQuotes = !inQuotes } else if (char === delimiter && !inQuotes) { values.push(current.trim()) current = '' } else { current += char } } values.push(current.trim()) return values } export function detectDelimiter(headerLine) { const tabCount = (headerLine.match(/\t/g) || []).length const semicolonCount = (headerLine.match(/;/g) || []).length const commaCount = (headerLine.match(/,/g) || []).length if (tabCount > semicolonCount && tabCount > commaCount) return '\t' if (commaCount > semicolonCount) return ',' return ';' } export function parseSpielplanCsv(content) { const lines = content.split(/\r?\n/).filter(line => line.trim() !== '') if (lines.length < 2) { return { headers: [], data: [] } } const delimiter = detectDelimiter(lines[0]) const headers = parseDelimitedLine(lines[0], delimiter) const data = lines.slice(1).map((line) => { const values = parseDelimitedLine(line, delimiter) const row = {} headers.forEach((header, index) => { row[header] = values[index] || '' }) return row }) return { headers, data } } function escapeCsvValue(value) { const stringValue = String(value ?? '') if (/[;"\n\r]/.test(stringValue)) { return `"${stringValue.replace(/"/g, '""')}"` } return stringValue } export function stringifySpielplanCsv(headers, data) { return [ headers.map(escapeCsvValue).join(';'), ...data.map((row) => headers.map((header) => escapeCsvValue(row[header])).join(';')) ].join('\n') } export function createSpielplanJsonFromCsv(content) { const parsed = parseSpielplanCsv(content) const seasonSlug = inferSeasonSlugFromRows(parsed.data) return { format: 'harheimertc.spielplan.v1', updatedAt: new Date().toISOString(), source: seasonSlug ? { season: { seasonSlug, label: seasonSlugToLabel(seasonSlug) } } : undefined, headers: parsed.headers, data: parsed.data } } async function readFirstExistingJson(paths) { for (const filePath of paths) { try { const content = await fs.readFile(filePath, 'utf8') const parsed = JSON.parse(content) const headers = Array.isArray(parsed.headers) ? parsed.headers : SPIELPLAN_HEADERS const data = Array.isArray(parsed.data) ? parsed.data : [] const fileName = path.basename(filePath) const season = parsed.source?.season?.seasonSlug || fileName.match(SEASON_FILE_PATTERN)?.[1] || null return { source: 'json', filePath, season, headers, data } } catch (error) { if (error.code !== 'ENOENT') { logReadError('Fehler beim Lesen der Spielplan-JSON', filePath, error) } } } return null } async function readFirstExistingCsv(paths) { for (const filePath of paths) { try { const content = await fs.readFile(filePath, 'utf8') const parsed = parseSpielplanCsv(content) return { source: 'csv', filePath, season: inferSeasonSlugFromRows(parsed.data), ...parsed } } catch (error) { if (error.code !== 'ENOENT') { logReadError('Fehler beim Lesen der Spielplan-CSV', filePath, error) } } } return null } async function readSeasonFileInfo(filePath, slug) { try { const content = await fs.readFile(filePath, 'utf8') const parsed = JSON.parse(content) return { slug, label: parsed.source?.season?.label || seasonSlugToLabel(slug), filePath, updatedAt: parsed.updatedAt || null, importedAt: parsed.importedAt || null, count: Array.isArray(parsed.data) ? parsed.data.length : 0 } } catch (_error) { return { slug, label: seasonSlugToLabel(slug), filePath, updatedAt: null, importedAt: null, count: 0 } } } export async function listSpielplanSeasons() { const directories = [ getServerDataPath('public-data', 'spielplaene'), getProjectPath('public', 'data', 'spielplaene') ] const bySlug = new Map() for (const directory of directories) { let entries = [] try { entries = await fs.readdir(directory) } catch (error) { if (error.code !== 'ENOENT') { logReadError('Fehler beim Lesen des Spielplan-Saisonverzeichnisses', directory, error) } continue } for (const entry of entries) { const match = entry.match(SEASON_FILE_PATTERN) if (!match || bySlug.has(match[1])) continue bySlug.set(match[1], await readSeasonFileInfo(path.join(directory, entry), match[1])) } } return [...bySlug.values()].sort((a, b) => b.slug.localeCompare(a.slug)) } export async function getDefaultSpielplanSeason() { const seasons = await listSpielplanSeasons() const currentSeason = getCurrentSeasonSlug() return seasons.find((season) => season.slug === currentSeason)?.slug || seasons[0]?.slug || null } export async function readSpielplanData(options = {}) { const season = options.season && SEASON_SLUG_PATTERN.test(options.season) ? options.season : await getDefaultSpielplanSeason() const seasonFile = season ? `spielplan-${season}.json` : null const jsonPaths = seasonFile ? [ getServerDataPath('public-data', 'spielplaene', seasonFile), getProjectPath('public', 'data', 'spielplaene', seasonFile) ] : [] const csvPaths = seasonFile ? [] : [ getServerDataPath('public-data', 'spielplan.csv'), getServerDataPath('spielplan.csv'), getProjectPath('public', 'data', 'spielplan.csv') ] return await readFirstExistingJson(jsonPaths) || await readFirstExistingCsv(csvPaths) || { source: null, filePath: null, season, headers: [], data: [] } } export function getSpielplanSeasonJsonPathForCsvPath(csvPath, seasonSlug) { const safeCsvPath = normalizeSpielplanCsvPath(csvPath) const safeSeasonSlug = requireSeasonSlug(seasonSlug) return `${path.dirname(safeCsvPath)}/spielplaene/spielplan-${safeSeasonSlug}.json` }