Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m49s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 2m37s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 8s
505 lines
15 KiB
JavaScript
505 lines
15 KiB
JavaScript
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`
|
|
}
|