Add script for importing match schedule and logging
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 2m2s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 33s
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 10s
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 2m2s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 33s
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 10s
- Created `import-spielplan.js` to fetch and parse the match schedule from the specified URL, saving the output as JSON. - Added `run-spielplan-import.sh` to automate the execution of the import script and log output. - Introduced `spielplan.html` file to store the downloaded HTML content for further processing.
This commit is contained in:
@@ -397,7 +397,11 @@ const save = async () => {
|
|||||||
try {
|
try {
|
||||||
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
|
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
|
||||||
const response = await fetch('/api/cms/save-csv', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: 'spielplan.csv', content: csv }) })
|
const response = await fetch('/api/cms/save-csv', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: 'spielplan.csv', content: csv }) })
|
||||||
if (response.ok) alert('Spielplan erfolgreich gespeichert!'); else alert('Fehler beim Speichern!')
|
if (response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
const seasonFile = result.jsonWrittenTo?.[0]?.split('/').pop()
|
||||||
|
alert(seasonFile ? `Spielplan erfolgreich als ${seasonFile} gespeichert!` : 'Spielplan erfolgreich gespeichert!')
|
||||||
|
} else alert('Fehler beim Speichern!')
|
||||||
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
|
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,11 +410,12 @@ const closeUploadModal = () => { showUploadModal.value = false; selectedFile.val
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/data/spielplan.csv'); if (!response.ok) return; const text = await response.text()
|
const response = await fetch('/api/spielplan'); if (!response.ok) return
|
||||||
const lines = text.split('\n').filter(line => line.trim() !== ''); if (lines.length < 2) return
|
const result = await response.json(); if (!result.success || !Array.isArray(result.headers) || !Array.isArray(result.data)) return
|
||||||
const parseCSVLine = (line) => { const values = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }; values.push(current.trim()); return values }
|
csvHeaders.value = result.headers
|
||||||
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
|
||||||
currentFile.value = { name: 'spielplan.csv', size: text.length, lastModified: null }
|
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
||||||
|
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', size: csvData.value.length, lastModified: null }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})()
|
})()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
"check-security": "node scripts/verify-no-public-writes.js",
|
"check-security": "node scripts/verify-no-public-writes.js",
|
||||||
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
||||||
"sync-public-data": "node scripts/sync-public-data.js",
|
"sync-public-data": "node scripts/sync-public-data.js",
|
||||||
|
"import-spielplan": "node scripts/import-spielplan.js",
|
||||||
|
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
||||||
"test:watch": "vitest watch",
|
"test:watch": "vitest watch",
|
||||||
"lint": "eslint . --fix"
|
"lint": "eslint . --fix"
|
||||||
},
|
},
|
||||||
|
|||||||
9
pages/mannschaft/[slug].vue
Normal file
9
pages/mannschaft/[slug].vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
await navigateTo(`/mannschaften/${route.params.slug}`, { replace: true })
|
||||||
|
</script>
|
||||||
@@ -73,6 +73,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Aktueller Spielplan -->
|
||||||
|
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900">
|
||||||
|
Aktueller Spielplan
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="spielplanSeasonLabel"
|
||||||
|
class="text-sm text-gray-600 mt-1"
|
||||||
|
>
|
||||||
|
Saison {{ spielplanSeasonLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isSpielplanLoading"
|
||||||
|
class="p-6 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
Spielplan wird geladen...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="spielplanError"
|
||||||
|
class="p-6 text-sm text-red-600"
|
||||||
|
>
|
||||||
|
{{ spielplanError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="mannschaftSpielplan.length === 0"
|
||||||
|
class="p-6 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
Für diese Mannschaft sind im aktuellen Spielplan keine Spiele vorhanden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Termin
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Heim
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Gast
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Ergebnis
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Runde
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Gruppe
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="game in mannschaftSpielplan"
|
||||||
|
:key="`${game.Termin}-${game.HeimMannschaft}-${game.GastMannschaft}`"
|
||||||
|
:class="getRowClass(game)"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ game.Termin || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
|
||||||
|
{{ game.HeimMannschaft || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
|
||||||
|
{{ game.GastMannschaft || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ formatResult(game) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ formatRunde(game.Runde) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<span class="text-xs leading-tight text-gray-700">
|
||||||
|
<span class="block">
|
||||||
|
{{ game.Altersklasse || '-' }}
|
||||||
|
</span>
|
||||||
|
<span class="block text-gray-500">
|
||||||
|
{{ formatStaffel(game.Staffel) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Links -->
|
<!-- Links -->
|
||||||
<div class="bg-white rounded-xl shadow-lg p-6">
|
<div class="bg-white rounded-xl shadow-lg p-6">
|
||||||
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
|
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
|
||||||
@@ -134,11 +233,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Users } from 'lucide-vue-next'
|
import { Users } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const mannschaft = ref(null)
|
const mannschaft = ref(null)
|
||||||
|
const mannschaftSpielplan = ref([])
|
||||||
|
const spielplanSeason = ref('')
|
||||||
|
const isSpielplanLoading = ref(false)
|
||||||
|
const spielplanError = ref('')
|
||||||
|
|
||||||
|
const spielplanSeasonLabel = computed(() => {
|
||||||
|
const match = String(spielplanSeason.value || '').match(/^(\d{2})--(\d{2})$/)
|
||||||
|
return match ? `20${match[1]}/${match[2]}` : ''
|
||||||
|
})
|
||||||
|
|
||||||
async function fetchCsvText(url) {
|
async function fetchCsvText(url) {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
@@ -209,12 +317,118 @@ const loadMannschaften = async () => {
|
|||||||
useHead({
|
useHead({
|
||||||
title: `${mannschaft.value.mannschaft} - Harheimer TC`,
|
title: `${mannschaft.value.mannschaft} - Harheimer TC`,
|
||||||
})
|
})
|
||||||
|
await loadSpielplan()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Mannschaften:', error)
|
console.error('Fehler beim Laden der Mannschaften:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTeamVariants = (cmsMannschaft) => {
|
||||||
|
const mannschaftMapping = {
|
||||||
|
'Erwachsene 1': ['harheimer tc'],
|
||||||
|
'Erwachsene 2': ['harheimer tc ii'],
|
||||||
|
'Erwachsene 3': ['harheimer tc iii'],
|
||||||
|
'Erwachsene 4': ['harheimer tc iv'],
|
||||||
|
'Erwachsene 5': ['harheimer tc v'],
|
||||||
|
'Jugendmannschaft': ['harheimer tc']
|
||||||
|
}
|
||||||
|
|
||||||
|
return mannschaftMapping[cmsMannschaft] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExactHarheimTeam = (teamName, variant) => {
|
||||||
|
if (variant === 'harheimer tc') {
|
||||||
|
return teamName === 'harheimer tc' ||
|
||||||
|
(teamName.startsWith('harheimer tc ') && !teamName.match(/harheimer tc\s+[ivx]+/i))
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamName === variant || teamName.startsWith(`${variant} `)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSpielForMannschaft = (row, cmsMannschaft) => {
|
||||||
|
const variants = getTeamVariants(cmsMannschaft)
|
||||||
|
if (!variants.length) return false
|
||||||
|
|
||||||
|
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
|
||||||
|
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
|
||||||
|
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
|
||||||
|
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
|
||||||
|
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
|
||||||
|
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
|
||||||
|
|
||||||
|
if (!isHarheimerHeim && !isHarheimerGast) return false
|
||||||
|
|
||||||
|
const mannschaftMatch = variants.some((variant) => {
|
||||||
|
if (isHarheimerHeim && isExactHarheimTeam(heimMannschaft, variant)) return true
|
||||||
|
if (isHarheimerGast && isExactHarheimTeam(gastMannschaft, variant)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!mannschaftMatch) return false
|
||||||
|
|
||||||
|
if (cmsMannschaft.startsWith('Erwachsene')) {
|
||||||
|
const isErwachsenenHeim = isHarheimerHeim &&
|
||||||
|
heimAltersklasse.includes('erwachsene') &&
|
||||||
|
!heimAltersklasse.includes('jugend')
|
||||||
|
const isErwachsenenGast = isHarheimerGast &&
|
||||||
|
gastAltersklasse.includes('erwachsene') &&
|
||||||
|
!gastAltersklasse.includes('jugend')
|
||||||
|
return isErwachsenenHeim || isErwachsenenGast
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmsMannschaft === 'Jugendmannschaft') {
|
||||||
|
const isJugendHeim = isHarheimerHeim &&
|
||||||
|
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
|
||||||
|
const isJugendGast = isHarheimerGast &&
|
||||||
|
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
|
||||||
|
return isJugendHeim || isJugendGast
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseTerminTimestamp = (row) => {
|
||||||
|
const timestamp = Number(row.Timestamp)
|
||||||
|
if (Number.isFinite(timestamp) && timestamp > 0) return timestamp
|
||||||
|
|
||||||
|
const termin = String(row.Termin || '')
|
||||||
|
const match = termin.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/)
|
||||||
|
if (!match) return 0
|
||||||
|
|
||||||
|
const [, day, month, year, hour = '0', minute = '0'] = match
|
||||||
|
return new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute)).getTime() / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSpielplan = async () => {
|
||||||
|
if (!mannschaft.value) return
|
||||||
|
|
||||||
|
isSpielplanLoading.value = true
|
||||||
|
spielplanError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/spielplan')
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
spielplanError.value = result.message || 'Spielplan konnte nicht geladen werden.'
|
||||||
|
mannschaftSpielplan.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spielplanSeason.value = result.season || ''
|
||||||
|
mannschaftSpielplan.value = result.data
|
||||||
|
.filter(row => isSpielForMannschaft(row, mannschaft.value.mannschaft))
|
||||||
|
.sort((a, b) => parseTerminTimestamp(a) - parseTerminTimestamp(b))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden des Spielplans:', error)
|
||||||
|
spielplanError.value = 'Spielplan konnte nicht geladen werden.'
|
||||||
|
mannschaftSpielplan.value = []
|
||||||
|
} finally {
|
||||||
|
isSpielplanLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getSpielerListe = (mannschaft) => {
|
const getSpielerListe = (mannschaft) => {
|
||||||
if (!mannschaft.spieler) return []
|
if (!mannschaft.spieler) return []
|
||||||
return mannschaft.spieler.split(';').map(s => s.trim()).filter(s => s !== '')
|
return mannschaft.spieler.split(';').map(s => s.trim()).filter(s => s !== '')
|
||||||
@@ -241,6 +455,46 @@ const formatDate = (dateString) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatResult = (row) => {
|
||||||
|
const heim = String(row?.SpieleHeim || '').trim()
|
||||||
|
const gast = String(row?.SpieleGast || '').trim()
|
||||||
|
return heim || gast ? `${heim}:${gast}` : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRunde = (rundeString) => {
|
||||||
|
if (!rundeString) return '-'
|
||||||
|
|
||||||
|
const runde = rundeString.toLowerCase()
|
||||||
|
if (runde === 'vr') return 'Vorrunde'
|
||||||
|
if (runde === 'rr') return 'Rückrunde'
|
||||||
|
if (runde === 'pokal') return 'Pokal'
|
||||||
|
return rundeString
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatStaffel = (staffelString) => {
|
||||||
|
const staffel = String(staffelString || '').trim()
|
||||||
|
if (!staffel) return '-'
|
||||||
|
return staffel.replace(/^E(?=\d)/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowClass = (row) => {
|
||||||
|
const timestamp = parseTerminTimestamp(row)
|
||||||
|
if (!timestamp) return 'bg-white'
|
||||||
|
|
||||||
|
const spielDatum = new Date(timestamp * 1000)
|
||||||
|
spielDatum.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const heute = new Date()
|
||||||
|
heute.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const in7Tagen = new Date(heute)
|
||||||
|
in7Tagen.setDate(in7Tagen.getDate() + 7)
|
||||||
|
|
||||||
|
if (spielDatum.getTime() === heute.getTime()) return 'bg-yellow-100'
|
||||||
|
if (spielDatum > heute && spielDatum <= in7Tagen) return 'bg-blue-100'
|
||||||
|
return 'bg-white'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMannschaften()
|
loadMannschaften()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,33 @@
|
|||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<!-- Filter Selection -->
|
<!-- Filter Selection -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<!-- Saison-Filter -->
|
||||||
|
<div
|
||||||
|
v-if="seasons.length"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="season-select"
|
||||||
|
class="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Saison:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="season-select"
|
||||||
|
v-model="selectedSeason"
|
||||||
|
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white text-sm"
|
||||||
|
@change="onSeasonChange"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="season in seasons"
|
||||||
|
:key="season.slug"
|
||||||
|
:value="season.slug"
|
||||||
|
>
|
||||||
|
{{ season.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Wettbewerbs-Filter -->
|
<!-- Wettbewerbs-Filter -->
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<label
|
<label
|
||||||
@@ -83,31 +110,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download Button -->
|
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<button
|
<button
|
||||||
:disabled="isLoading || !filteredData.length"
|
:disabled="isLoading"
|
||||||
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
class="inline-flex items-center justify-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
||||||
@click="downloadPDF"
|
@click="loadData"
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
Spielplan laden
|
||||||
stroke-linecap="round"
|
</button>
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
<!-- Download Button -->
|
||||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
<button
|
||||||
/>
|
:disabled="isLoading || !filteredData.length"
|
||||||
</svg>
|
class="inline-flex items-center justify-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
||||||
PDF Download
|
@click="downloadPDF"
|
||||||
</button>
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
PDF Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Info -->
|
<!-- Filter Info -->
|
||||||
<div class="mt-4 text-sm text-gray-600">
|
<div
|
||||||
|
v-if="hasLoadedSpielplan"
|
||||||
|
class="mt-4 text-sm text-gray-600"
|
||||||
|
>
|
||||||
<span v-if="selectedFilter === 'all'">
|
<span v-if="selectedFilter === 'all'">
|
||||||
{{ getWettbewerbText() }} - Alle Mannschaften ({{ filteredData.length }} von {{ spielplanData.length }} Einträgen)
|
{{ getWettbewerbText() }} - Alle Mannschaften ({{ filteredData.length }} von {{ spielplanData.length }} Einträgen)
|
||||||
</span>
|
</span>
|
||||||
@@ -179,6 +219,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
v-else-if="!hasLoadedSpielplan"
|
||||||
|
class="text-center py-12 bg-white rounded-xl shadow-lg"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Spielplan bereit
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Laden Sie den aktuellen Spielplan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="!spielplanData || spielplanData.length === 0"
|
v-else-if="!spielplanData || spielplanData.length === 0"
|
||||||
class="text-center py-12 bg-white rounded-xl shadow-lg"
|
class="text-center py-12 bg-white rounded-xl shadow-lg"
|
||||||
@@ -263,6 +328,17 @@
|
|||||||
<span v-else-if="header.toLowerCase().includes('runde')">
|
<span v-else-if="header.toLowerCase().includes('runde')">
|
||||||
{{ formatRunde(row[getOriginalHeader(header)]) }}
|
{{ formatRunde(row[getOriginalHeader(header)]) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="header.toLowerCase().includes('gruppe')"
|
||||||
|
class="text-xs leading-tight text-gray-700"
|
||||||
|
>
|
||||||
|
<span class="block">
|
||||||
|
{{ row.Altersklasse || '-' }}
|
||||||
|
</span>
|
||||||
|
<span class="block text-gray-500">
|
||||||
|
{{ formatStaffel(row[getOriginalHeader(header)]) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ row[getOriginalHeader(header)] || '-' }}
|
{{ row[getOriginalHeader(header)] || '-' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -298,6 +374,9 @@ const selectedFilter = ref('all')
|
|||||||
const selectedWettbewerb = ref('punktrunde')
|
const selectedWettbewerb = ref('punktrunde')
|
||||||
const filteredData = ref([])
|
const filteredData = ref([])
|
||||||
const mannschaften = ref([])
|
const mannschaften = ref([])
|
||||||
|
const seasons = ref([])
|
||||||
|
const selectedSeason = ref('')
|
||||||
|
const hasLoadedSpielplan = ref(false)
|
||||||
|
|
||||||
async function fetchCsvText(url) {
|
async function fetchCsvText(url) {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
@@ -321,20 +400,28 @@ const loadData = async () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Lade Spielplandaten und Mannschaften parallel
|
const params = new URLSearchParams()
|
||||||
|
if (selectedSeason.value) params.set('season', selectedSeason.value)
|
||||||
|
|
||||||
const [spielplanResponse, mannschaftenResponse] = await Promise.all([
|
const [spielplanResponse, mannschaftenResponse] = await Promise.all([
|
||||||
fetch('/api/spielplan'),
|
fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`),
|
||||||
fetchCsvText('/api/mannschaften')
|
fetchCsvText('/api/mannschaften')
|
||||||
])
|
])
|
||||||
|
|
||||||
// Spielplandaten verarbeiten
|
|
||||||
const spielplanResult = await spielplanResponse.json()
|
const spielplanResult = await spielplanResponse.json()
|
||||||
|
|
||||||
|
await applyMannschaftenResponse(mannschaftenResponse)
|
||||||
|
|
||||||
if (spielplanResult.success) {
|
if (spielplanResult.success) {
|
||||||
spielplanData.value = spielplanResult.data
|
spielplanData.value = spielplanResult.data.map(row => ({
|
||||||
|
...row,
|
||||||
|
Ergebnis: formatResult(row)
|
||||||
|
}))
|
||||||
|
seasons.value = spielplanResult.seasons?.length ? spielplanResult.seasons : seasons.value
|
||||||
|
selectedSeason.value = spielplanResult.season || selectedSeason.value
|
||||||
|
|
||||||
// Nur die gewünschten Spalten anzeigen
|
// Nur die gewünschten Spalten anzeigen
|
||||||
const originalHeaders = spielplanResult.headers
|
const originalHeaders = [...spielplanResult.headers, 'Ergebnis']
|
||||||
const desiredHeaders = ['Termin', 'HeimMannschaft', 'GastMannschaft', 'Runde', 'Staffel']
|
const desiredHeaders = ['Termin', 'HeimMannschaft', 'GastMannschaft', 'Ergebnis', 'Runde', 'Staffel']
|
||||||
|
|
||||||
// Finde die Indizes der gewünschten Spalten
|
// Finde die Indizes der gewünschten Spalten
|
||||||
const headerIndices = desiredHeaders.map(desiredHeader => {
|
const headerIndices = desiredHeaders.map(desiredHeader => {
|
||||||
@@ -364,41 +451,11 @@ const loadData = async () => {
|
|||||||
window.spielplanHeaderMapping = headerMapping
|
window.spielplanHeaderMapping = headerMapping
|
||||||
|
|
||||||
lastUpdated.value = new Date().toLocaleString('de-DE')
|
lastUpdated.value = new Date().toLocaleString('de-DE')
|
||||||
|
hasLoadedSpielplan.value = true
|
||||||
} else {
|
} else {
|
||||||
error.value = spielplanResult.message
|
error.value = spielplanResult.message
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mannschaften aus CMS laden (manuell eingegebene Mannschaften)
|
|
||||||
if (mannschaftenResponse) {
|
|
||||||
const csvText = mannschaftenResponse
|
|
||||||
const lines = csvText.split('\n').filter(line => line.trim() !== '')
|
|
||||||
|
|
||||||
if (lines.length > 1) {
|
|
||||||
mannschaften.value = lines.slice(1).map(line => {
|
|
||||||
// Besserer CSV-Parser: Respektiert Anführungszeichen
|
|
||||||
const values = []
|
|
||||||
let current = ''
|
|
||||||
let inQuotes = false
|
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const char = line[i]
|
|
||||||
|
|
||||||
if (char === '"') {
|
|
||||||
inQuotes = !inQuotes
|
|
||||||
} else if (char === ',' && !inQuotes) {
|
|
||||||
values.push(current.trim())
|
|
||||||
current = ''
|
|
||||||
} else {
|
|
||||||
current += char
|
|
||||||
}
|
|
||||||
}
|
|
||||||
values.push(current.trim())
|
|
||||||
|
|
||||||
return values[0] // Erste Spalte ist der Mannschaftsname
|
|
||||||
}).filter(name => name && name !== '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filterData() // Initial filter
|
filterData() // Initial filter
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Laden der Daten:', err)
|
console.error('Fehler beim Laden der Daten:', err)
|
||||||
@@ -408,68 +465,67 @@ const loadData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyMannschaftenResponse = async (csvText) => {
|
||||||
|
const lines = csvText.split('\n').filter(line => line.trim() !== '')
|
||||||
|
|
||||||
|
if (lines.length <= 1) return
|
||||||
|
|
||||||
|
mannschaften.value = lines.slice(1).map(line => {
|
||||||
|
const values = []
|
||||||
|
let current = ''
|
||||||
|
let inQuotes = false
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i]
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim())
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim())
|
||||||
|
|
||||||
|
return values[0]
|
||||||
|
}).filter(name => name && name !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSeasonChange = () => {
|
||||||
|
spielplanData.value = []
|
||||||
|
filteredData.value = []
|
||||||
|
headers.value = []
|
||||||
|
lastUpdated.value = ''
|
||||||
|
hasLoadedSpielplan.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const filterData = () => {
|
const filterData = () => {
|
||||||
if (!spielplanData.value || spielplanData.value.length === 0) {
|
if (!spielplanData.value || spielplanData.value.length === 0) {
|
||||||
filteredData.value = []
|
filteredData.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zuerst nach aktueller Saison filtern (immer aktiv)
|
let saisonFiltered = spielplanData.value
|
||||||
|
|
||||||
// Da die Spiele bis 2026 gehen, nehmen wir die Saison 2025/26
|
|
||||||
// Saison läuft vom 01.07. bis 30.06. des Folgejahres
|
|
||||||
const saisonStartYear = 2025
|
|
||||||
const saisonEndYear = 2026
|
|
||||||
|
|
||||||
const saisonStart = new Date(saisonStartYear, 6, 1) // 01.07.2025
|
|
||||||
const saisonEnd = new Date(saisonEndYear, 5, 30) // 30.06.2026
|
|
||||||
|
|
||||||
let saisonFiltered = spielplanData.value.filter(row => {
|
|
||||||
const termin = row.Termin
|
|
||||||
if (!termin) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse deutsches Datumsformat: "27.10.2025 20:00"
|
|
||||||
let spielDatum
|
|
||||||
|
|
||||||
if (termin.includes(' ')) {
|
|
||||||
// Uhrzeit entfernen: "27.10.2025 20:00" -> "27.10.2025"
|
|
||||||
const datumTeil = termin.split(' ')[0]
|
|
||||||
|
|
||||||
// Deutsches Format parsen: "27.10.2025" -> Date
|
|
||||||
const [tag, monat, jahr] = datumTeil.split('.')
|
|
||||||
spielDatum = new Date(jahr, monat - 1, tag) // Monat ist 0-basiert
|
|
||||||
} else {
|
|
||||||
spielDatum = new Date(termin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(spielDatum.getTime())) return false
|
|
||||||
|
|
||||||
// Prüfe ob das Spiel in der aktuellen Saison liegt
|
|
||||||
const inSaison = spielDatum >= saisonStart && spielDatum <= saisonEnd
|
|
||||||
|
|
||||||
return inSaison
|
|
||||||
} catch (_error) {
|
|
||||||
console.error('Fehler beim Parsen von Termin:', termin, error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Dann nach Wettbewerb filtern
|
// Dann nach Wettbewerb filtern
|
||||||
let wettbewerbFiltered = saisonFiltered
|
let wettbewerbFiltered = saisonFiltered
|
||||||
if (selectedWettbewerb.value === 'punktrunde') {
|
if (selectedWettbewerb.value === 'punktrunde') {
|
||||||
wettbewerbFiltered = saisonFiltered.filter(row => {
|
wettbewerbFiltered = saisonFiltered.filter(row => {
|
||||||
// Filtere nach Punktrunde-Spielen (VR = Vorrunde, RR = Rückrunde)
|
|
||||||
const runde = (row.Runde || '').toLowerCase()
|
const runde = (row.Runde || '').toLowerCase()
|
||||||
const isMatch = runde === 'vr' || runde === 'rr' || runde.includes('vorrunde') || runde.includes('rückrunde')
|
const staffel = (row.Staffel || '').toLowerCase()
|
||||||
|
const liga = (row.Liga || '').toLowerCase()
|
||||||
return isMatch
|
const isPokal = runde.includes('pokal') || staffel.includes('pokal') || liga.includes('pokal')
|
||||||
|
|
||||||
|
return !isPokal
|
||||||
})
|
})
|
||||||
} else if (selectedWettbewerb.value === 'pokal') {
|
} else if (selectedWettbewerb.value === 'pokal') {
|
||||||
wettbewerbFiltered = saisonFiltered.filter(row => {
|
wettbewerbFiltered = saisonFiltered.filter(row => {
|
||||||
// Filtere nach Pokal-Spielen
|
|
||||||
const runde = (row.Runde || '').toLowerCase()
|
const runde = (row.Runde || '').toLowerCase()
|
||||||
return runde === 'pokal' || runde.includes('pokal')
|
const staffel = (row.Staffel || '').toLowerCase()
|
||||||
|
const liga = (row.Liga || '').toLowerCase()
|
||||||
|
return runde.includes('pokal') || staffel.includes('pokal') || liga.includes('pokal')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// "alle" zeigt alle Spiele ohne weitere Filterung
|
// "alle" zeigt alle Spiele ohne weitere Filterung
|
||||||
@@ -634,6 +690,7 @@ const downloadPDF = () => {
|
|||||||
team: teamParam,
|
team: teamParam,
|
||||||
wettbewerb: selectedWettbewerb.value
|
wettbewerb: selectedWettbewerb.value
|
||||||
})
|
})
|
||||||
|
if (selectedSeason.value) params.set('season', selectedSeason.value)
|
||||||
const downloadUrl = `/api/spielplan/pdf?${params.toString()}`
|
const downloadUrl = `/api/spielplan/pdf?${params.toString()}`
|
||||||
|
|
||||||
// Öffne Download in neuem Tab
|
// Öffne Download in neuem Tab
|
||||||
@@ -703,6 +760,18 @@ const formatRunde = (rundeString) => {
|
|||||||
return rundeString
|
return rundeString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatStaffel = (staffelString) => {
|
||||||
|
const staffel = String(staffelString || '').trim()
|
||||||
|
if (!staffel) return '-'
|
||||||
|
return staffel.replace(/^E(?=\d)/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatResult = (row) => {
|
||||||
|
const heim = String(row?.SpieleHeim || '').trim()
|
||||||
|
const gast = String(row?.SpieleGast || '').trim()
|
||||||
|
return heim || gast ? `${heim}:${gast}` : '-'
|
||||||
|
}
|
||||||
|
|
||||||
const getOriginalHeader = (displayHeader) => {
|
const getOriginalHeader = (displayHeader) => {
|
||||||
// Verwende das Mapping um den ursprünglichen Header-Namen zu finden
|
// Verwende das Mapping um den ursprünglichen Header-Namen zu finden
|
||||||
if (window.spielplanHeaderMapping && window.spielplanHeaderMapping[displayHeader]) {
|
if (window.spielplanHeaderMapping && window.spielplanHeaderMapping[displayHeader]) {
|
||||||
@@ -774,4 +843,5 @@ const getWettbewerbText = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
</script>
|
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -13,6 +13,31 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
|
<div
|
||||||
|
v-if="seasons.length"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="season-select"
|
||||||
|
class="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Saison:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="season-select"
|
||||||
|
v-model="selectedSeason"
|
||||||
|
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white text-sm"
|
||||||
|
@change="loadData"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="season in seasons"
|
||||||
|
:key="season.slug"
|
||||||
|
:value="season.slug"
|
||||||
|
>
|
||||||
|
{{ season.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
||||||
@@ -217,18 +242,34 @@ const headers = ref([])
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const lastUpdated = ref('')
|
const lastUpdated = ref('')
|
||||||
|
const seasons = ref([])
|
||||||
|
const selectedSeason = ref('')
|
||||||
|
|
||||||
|
const loadSeasons = async () => {
|
||||||
|
const response = await fetch('/api/spielplan/seasons')
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
seasons.value = result.seasons || []
|
||||||
|
selectedSeason.value = result.defaultSeason || seasons.value[0]?.slug || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/spielplan')
|
const params = new URLSearchParams()
|
||||||
|
if (selectedSeason.value) params.set('season', selectedSeason.value)
|
||||||
|
const response = await fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`)
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
spielplanData.value = result.data
|
spielplanData.value = result.data
|
||||||
headers.value = result.headers
|
headers.value = result.headers
|
||||||
|
seasons.value = result.seasons?.length ? result.seasons : seasons.value
|
||||||
|
selectedSeason.value = result.season || selectedSeason.value
|
||||||
lastUpdated.value = new Date().toLocaleString('de-DE')
|
lastUpdated.value = new Date().toLocaleString('de-DE')
|
||||||
} else {
|
} else {
|
||||||
error.value = result.message
|
error.value = result.message
|
||||||
@@ -291,7 +332,8 @@ const formatTime = (timeString) => {
|
|||||||
return timeString
|
return timeString
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadData()
|
await loadSeasons()
|
||||||
|
await loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
10
scripts/import-spielplan.js
Normal file
10
scripts/import-spielplan.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { importSpielplan } from '../server/utils/spielplan-import.js'
|
||||||
|
|
||||||
|
const result = await importSpielplan()
|
||||||
|
|
||||||
|
console.log(`Spielplan gespeichert: ${result.jsonFile}`)
|
||||||
|
console.log(`Roh-HTML gespeichert: ${result.htmlFile}`)
|
||||||
|
console.log(`Spiele: ${result.matchCount}`)
|
||||||
|
console.log(`Zeitraum: ${result.source.season.dateStart} bis ${result.source.season.dateEnd}`)
|
||||||
49
scripts/publish-imported-spielplan.js
Normal file
49
scripts/publish-imported-spielplan.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
convertImportedSpielplanToJson,
|
||||||
|
validateImportedSpielplan
|
||||||
|
} from '../server/utils/spielplan-data.js'
|
||||||
|
|
||||||
|
const DEFAULT_INPUT = 'temp/webpage-downloads/data/harheimer_tc_spielplan.json'
|
||||||
|
const PUBLIC_SEASONS_DIR = 'public/data/spielplaene'
|
||||||
|
const INTERNAL_SEASONS_DIR = 'server/data/public-data/spielplaene'
|
||||||
|
|
||||||
|
function getArgValue(name) {
|
||||||
|
const index = process.argv.indexOf(name)
|
||||||
|
return index >= 0 ? process.argv[index + 1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomic(filePath, data) {
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
|
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
||||||
|
const content = `${JSON.stringify(data, null, 2)}\n`
|
||||||
|
|
||||||
|
await fs.writeFile(tmpPath, content, 'utf8')
|
||||||
|
await fs.rename(tmpPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPath = getArgValue('--input') || DEFAULT_INPUT
|
||||||
|
const publicSeasonsDir = getArgValue('--output-dir') || PUBLIC_SEASONS_DIR
|
||||||
|
const raw = await fs.readFile(inputPath, 'utf8')
|
||||||
|
const imported = JSON.parse(raw)
|
||||||
|
|
||||||
|
validateImportedSpielplan(imported)
|
||||||
|
|
||||||
|
const spielplan = convertImportedSpielplanToJson(imported)
|
||||||
|
const seasonSlug = imported.source?.season?.seasonSlug
|
||||||
|
if (!seasonSlug) {
|
||||||
|
throw new Error('Saison-Slug fehlt in der Import-Datei')
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicSeasonPath = path.join(publicSeasonsDir, `spielplan-${seasonSlug}.json`)
|
||||||
|
const internalSeasonPath = path.join(INTERNAL_SEASONS_DIR, `spielplan-${seasonSlug}.json`)
|
||||||
|
|
||||||
|
await writeJsonAtomic(publicSeasonPath, spielplan)
|
||||||
|
await writeJsonAtomic(internalSeasonPath, spielplan)
|
||||||
|
|
||||||
|
console.log(`Validiert: ${imported.matchCount} Spiele, Saison ${seasonSlug}`)
|
||||||
|
console.log(`Saison-Datei: ${publicSeasonPath}`)
|
||||||
|
console.log(`Interne Saison-Datei: ${internalSeasonPath}`)
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||||
|
import {
|
||||||
|
createSpielplanJsonFromCsv,
|
||||||
|
getCurrentSeasonSlug,
|
||||||
|
getSpielplanSeasonJsonPathForCsvPath,
|
||||||
|
inferSeasonSlugFromRows
|
||||||
|
} from '../../utils/spielplan-data.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -104,11 +110,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
const uniquePaths = [...new Set([...internalPaths])]
|
const uniquePaths = [...new Set([...internalPaths])]
|
||||||
const writeResults = []
|
const writeResults = []
|
||||||
const writeErrors = []
|
const writeErrors = []
|
||||||
|
const jsonWriteResults = []
|
||||||
|
|
||||||
for (const targetPath of uniquePaths) {
|
for (const targetPath of uniquePaths) {
|
||||||
try {
|
try {
|
||||||
await writeFileAtomicAndVerify(targetPath, content)
|
await writeFileAtomicAndVerify(targetPath, content)
|
||||||
writeResults.push(targetPath)
|
writeResults.push(targetPath)
|
||||||
|
if (filename === 'spielplan.csv') {
|
||||||
|
const spielplanJson = createSpielplanJsonFromCsv(content)
|
||||||
|
const seasonSlug = inferSeasonSlugFromRows(spielplanJson.data) || getCurrentSeasonSlug()
|
||||||
|
const jsonContent = `${JSON.stringify(spielplanJson, null, 2)}\n`
|
||||||
|
const jsonPath = getSpielplanSeasonJsonPathForCsvPath(targetPath, seasonSlug)
|
||||||
|
await writeFileAtomicAndVerify(jsonPath, jsonContent)
|
||||||
|
jsonWriteResults.push(jsonPath)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
writeErrors.push({ targetPath, error: e?.message || String(e) })
|
writeErrors.push({ targetPath, error: e?.message || String(e) })
|
||||||
}
|
}
|
||||||
@@ -125,7 +140,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Datei erfolgreich gespeichert',
|
message: 'Datei erfolgreich gespeichert',
|
||||||
writtenTo: writeResults
|
writtenTo: writeResults,
|
||||||
|
jsonWrittenTo: jsonWriteResults
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,61 +1,39 @@
|
|||||||
import fs from 'fs/promises'
|
import { listSpielplanSeasons, readSpielplanData } from '../utils/spielplan-data.js'
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(process.cwd(), 'public', 'data', 'spielplan.csv')
|
const query = getQuery(event)
|
||||||
|
const [spielplan, seasons] = await Promise.all([
|
||||||
// Prüfe ob Datei existiert
|
readSpielplanData({ season: query.season }),
|
||||||
try {
|
listSpielplanSeasons()
|
||||||
await fs.access(filePath)
|
])
|
||||||
} catch (_error) {
|
|
||||||
|
if (!spielplan.data.length || !spielplan.headers.length) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Spielplan-Datei nicht gefunden',
|
message: 'Spielplan-Datei nicht gefunden oder leer',
|
||||||
data: []
|
data: [],
|
||||||
|
headers: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV-Datei lesen
|
|
||||||
const csvContent = await fs.readFile(filePath, 'utf-8')
|
|
||||||
const lines = csvContent.split('\n').filter(line => line.trim() !== '')
|
|
||||||
|
|
||||||
if (lines.length < 2) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Spielplan-Datei ist leer oder unvollständig',
|
|
||||||
data: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header-Zeile parsen
|
|
||||||
const headers = lines[0].split(';').map(header => header.trim())
|
|
||||||
|
|
||||||
// Datenzeilen parsen
|
|
||||||
const data = lines.slice(1).map(line => {
|
|
||||||
const values = line.split(';').map(value => value.trim())
|
|
||||||
const row = {}
|
|
||||||
|
|
||||||
headers.forEach((header, index) => {
|
|
||||||
row[header] = values[index] || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
return row
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Spielplan erfolgreich geladen',
|
message: 'Spielplan erfolgreich geladen',
|
||||||
data: data,
|
data: spielplan.data,
|
||||||
headers: headers
|
headers: spielplan.headers,
|
||||||
|
source: spielplan.source,
|
||||||
|
filePath: spielplan.filePath,
|
||||||
|
season: spielplan.season,
|
||||||
|
seasons
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden des Spielplans:', error)
|
console.error('Fehler beim Laden des Spielplans:', error)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Fehler beim Laden des Spielplans',
|
message: 'Fehler beim Laden des Spielplans',
|
||||||
data: []
|
data: [],
|
||||||
|
headers: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { readSpielplanData } from '../../utils/spielplan-data.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -13,56 +14,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lade Spielplandaten - bevorzugt aus server/data
|
const spielplan = await readSpielplanData({ season: query.season })
|
||||||
let csvPath = path.join(process.cwd(), 'server/data/spielplan.csv')
|
if (!spielplan.data.length || !spielplan.headers.length) {
|
||||||
try {
|
|
||||||
await fs.access(csvPath)
|
|
||||||
} catch {
|
|
||||||
csvPath = path.join(process.cwd(), 'public/data/spielplan.csv')
|
|
||||||
}
|
|
||||||
|
|
||||||
let csvContent
|
|
||||||
try {
|
|
||||||
csvContent = await fs.readFile(csvPath, 'utf-8')
|
|
||||||
} catch (_error) {
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: 'Spielplandaten nicht gefunden'
|
statusMessage: 'Spielplandaten nicht gefunden'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse CSV
|
const dataRows = spielplan.data
|
||||||
const lines = csvContent.split('\n').filter(line => line.trim())
|
|
||||||
if (lines.length < 2) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Keine Spielplandaten verfügbar'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatische Erkennung des Trennzeichens
|
|
||||||
const firstLine = lines[0]
|
|
||||||
const tabCount = (firstLine.match(/\t/g) || []).length
|
|
||||||
const semicolonCount = (firstLine.match(/;/g) || []).length
|
|
||||||
const delimiter = tabCount > semicolonCount ? '\t' : ';'
|
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
||||||
console.log(`Verwendetes Trennzeichen: ${delimiter === '\t' ? 'Tab' : 'Semikolon'}`)
|
|
||||||
|
|
||||||
const headers = firstLine.split(delimiter)
|
|
||||||
console.log('CSV-Header:', headers)
|
|
||||||
|
|
||||||
const dataRows = lines.slice(1).map(line => {
|
|
||||||
const values = line.split(delimiter)
|
|
||||||
const row = {}
|
|
||||||
headers.forEach((header, index) => {
|
|
||||||
row[header] = values[index] || ''
|
|
||||||
})
|
|
||||||
return row
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('Anzahl Datenzeilen:', dataRows.length)
|
|
||||||
console.log('Erste Datenzeile:', dataRows[0])
|
|
||||||
|
|
||||||
// Filtere Daten basierend auf Team
|
// Filtere Daten basierend auf Team
|
||||||
let filteredData = dataRows
|
let filteredData = dataRows
|
||||||
@@ -175,33 +135,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtere nach aktueller Saison (2025/26)
|
|
||||||
const currentSaisonStart = new Date(2025, 6, 1) // 01.07.2025
|
|
||||||
const currentSaisonEnd = new Date(2026, 5, 30) // 30.06.2026
|
|
||||||
|
|
||||||
filteredData = filteredData.filter(row => {
|
|
||||||
const termin = row.Termin
|
|
||||||
if (!termin) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
let spielDatum
|
|
||||||
|
|
||||||
if (termin.includes(' ')) {
|
|
||||||
const datumTeil = termin.split(' ')[0]
|
|
||||||
const [tag, monat, jahr] = datumTeil.split('.')
|
|
||||||
spielDatum = new Date(jahr, monat - 1, tag)
|
|
||||||
} else {
|
|
||||||
spielDatum = new Date(termin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(spielDatum.getTime())) return false
|
|
||||||
|
|
||||||
return spielDatum >= currentSaisonStart && spielDatum <= currentSaisonEnd
|
|
||||||
} catch (_error) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sammle Halle-Informationen für die jeweilige Mannschaft
|
// Sammle Halle-Informationen für die jeweilige Mannschaft
|
||||||
const hallenMap = new Map()
|
const hallenMap = new Map()
|
||||||
|
|
||||||
|
|||||||
24
server/api/spielplan/seasons.get.js
Normal file
24
server/api/spielplan/seasons.get.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { getCurrentSeasonSlug, listSpielplanSeasons } from '../../utils/spielplan-data.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
const seasons = await listSpielplanSeasons()
|
||||||
|
const currentSeason = getCurrentSeasonSlug()
|
||||||
|
const defaultSeason = seasons.find((season) => season.slug === currentSeason)?.slug || seasons[0]?.slug || null
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
seasons,
|
||||||
|
currentSeason,
|
||||||
|
defaultSeason
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Spielplan-Saisons:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
seasons: [],
|
||||||
|
currentSeason: getCurrentSeasonSlug(),
|
||||||
|
defaultSeason: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
107
server/plugins/spielplan-import-scheduler.js
Normal file
107
server/plugins/spielplan-import-scheduler.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { importSpielplan } from '../utils/spielplan-import.js'
|
||||||
|
|
||||||
|
const TIME_ZONE = 'Europe/Berlin'
|
||||||
|
const RUN_HOUR = 7
|
||||||
|
const RUN_MINUTE = 0
|
||||||
|
const MAX_TIMEOUT = 2_147_483_647
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
function getTimeParts(date) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}).formatToParts(date)
|
||||||
|
|
||||||
|
return Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeZoneOffset(date) {
|
||||||
|
const parts = getTimeParts(date)
|
||||||
|
const zonedAsUtc = Date.UTC(
|
||||||
|
Number(parts.year),
|
||||||
|
Number(parts.month) - 1,
|
||||||
|
Number(parts.day),
|
||||||
|
Number(parts.hour),
|
||||||
|
Number(parts.minute),
|
||||||
|
Number(parts.second)
|
||||||
|
)
|
||||||
|
|
||||||
|
return zonedAsUtc - date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function zonedDateToUtc(year, month, day, hour, minute) {
|
||||||
|
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0))
|
||||||
|
const offset = getTimeZoneOffset(utcGuess)
|
||||||
|
return new Date(utcGuess.getTime() - offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRunAt(now = new Date()) {
|
||||||
|
const parts = getTimeParts(now)
|
||||||
|
let year = Number(parts.year)
|
||||||
|
let month = Number(parts.month)
|
||||||
|
let day = Number(parts.day)
|
||||||
|
let candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
|
||||||
|
|
||||||
|
if (candidate <= now) {
|
||||||
|
const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0)
|
||||||
|
const nextParts = getTimeParts(nextDay)
|
||||||
|
year = Number(nextParts.year)
|
||||||
|
month = Number(nextParts.month)
|
||||||
|
day = Number(nextParts.day)
|
||||||
|
candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImport(reason) {
|
||||||
|
if (running) return
|
||||||
|
|
||||||
|
running = true
|
||||||
|
try {
|
||||||
|
const result = await importSpielplan()
|
||||||
|
console.log(`[spielplan-import] ${reason}: ${result.matchCount} Spiele importiert (${result.source.season.dateStart} bis ${result.source.season.dateEnd})`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[spielplan-import] Import fehlgeschlagen:', error)
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNext() {
|
||||||
|
const runAt = nextRunAt()
|
||||||
|
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
|
||||||
|
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
await runImport('taeglicher Lauf')
|
||||||
|
scheduleNext()
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
timer.unref?.()
|
||||||
|
console.log(`[spielplan-import] Naechster Lauf: ${runAt.toISOString()} (${TIME_ZONE} ${String(RUN_HOUR).padStart(2, '0')}:${String(RUN_MINUTE).padStart(2, '0')})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
if (process.env.SPIELPLAN_IMPORT_DISABLED === 'true') {
|
||||||
|
console.log('[spielplan-import] Scheduler deaktiviert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext()
|
||||||
|
|
||||||
|
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
|
||||||
|
runImport('Startlauf')
|
||||||
|
}
|
||||||
|
|
||||||
|
nitroApp.hooks.hookOnce('close', () => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
})
|
||||||
|
})
|
||||||
472
server/utils/spielplan-data.js
Normal file
472
server/utils/spielplan-data.js
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { getProjectPath, getServerDataPath } from './paths.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]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
console.error(`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') {
|
||||||
|
console.error(`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') {
|
||||||
|
console.error(`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) {
|
||||||
|
return path.join(path.dirname(csvPath), 'spielplaene', `spielplan-${seasonSlug}.json`)
|
||||||
|
}
|
||||||
202
server/utils/spielplan-import.js
Normal file
202
server/utils/spielplan-import.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { getServerDataPath } from './paths.js'
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
association: 'HeTTV',
|
||||||
|
clubId: '43030',
|
||||||
|
clubName: 'Harheimer_TC'
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTPUT_DIR = getServerDataPath('spielplan-import')
|
||||||
|
const JSON_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.json')
|
||||||
|
const HTML_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.html')
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(value).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpieljahrForDate(date = new Date()) {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const startYear = date.getMonth() >= 6 ? year : year - 1
|
||||||
|
const endYear = startYear + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
startYear,
|
||||||
|
endYear,
|
||||||
|
seasonSlug: `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`,
|
||||||
|
dateStart: `${startYear}-07-01`,
|
||||||
|
dateEnd: `${endYear}-06-30`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpielplanUrl(season, config = DEFAULT_CONFIG) {
|
||||||
|
const base = `https://www.mytischtennis.de/click-tt/${config.association}/${season.seasonSlug}/verein/${config.clubId}/${config.clubName}/spielplan`
|
||||||
|
return `${base}?date_start=${season.dateStart}&date_end=${season.dateEnd}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRemixContext(html) {
|
||||||
|
const marker = 'window.__remixContext = '
|
||||||
|
const start = html.indexOf(marker)
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error('window.__remixContext nicht gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStart = start + marker.length
|
||||||
|
let depth = 0
|
||||||
|
let inString = false
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (let i = jsonStart; i < html.length; i += 1) {
|
||||||
|
const char = html[i]
|
||||||
|
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false
|
||||||
|
} else if (char === '\\') {
|
||||||
|
escaped = true
|
||||||
|
} else if (char === '"') {
|
||||||
|
inString = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inString = true
|
||||||
|
} else if (char === '{') {
|
||||||
|
depth += 1
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth -= 1
|
||||||
|
if (depth === 0) {
|
||||||
|
return JSON.parse(html.slice(jsonStart, i + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Ende von window.__remixContext nicht gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeScheduleByDate(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(value).some(([key, list]) => (
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/.test(key) &&
|
||||||
|
Array.isArray(list) &&
|
||||||
|
list.some((item) => item && item.team_home && item.team_away && item.meeting_id)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSchedule(value, trail = []) {
|
||||||
|
if (looksLikeScheduleByDate(value)) {
|
||||||
|
return { schedule: value, path: trail }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
const result = findSchedule(child, trail.concat(key))
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatch(day, match) {
|
||||||
|
const dateTimestamp = match.date ? Date.parse(match.date) : NaN
|
||||||
|
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
date: match.date ?? null,
|
||||||
|
timestamp: Number.isNaN(dateTimestamp) ? null : Math.floor(dateTimestamp / 1000),
|
||||||
|
formattedDay: match.formattedDay ?? null,
|
||||||
|
formattedTime: match.formattedTime ?? null,
|
||||||
|
state: match.state ?? null,
|
||||||
|
meetingId: match.meeting_id ?? null,
|
||||||
|
meetingNumber: match.meeting_number ?? null,
|
||||||
|
leagueId: match.league_id ?? null,
|
||||||
|
leagueName: match.league_name ?? null,
|
||||||
|
leagueShortName: match.league_short_name ?? null,
|
||||||
|
leagueOrgShortName: match.league_org_short_name ?? null,
|
||||||
|
roundName: match.round_name ?? null,
|
||||||
|
teamHome: match.team_home ?? null,
|
||||||
|
teamHomeId: match.team_home_id ?? null,
|
||||||
|
teamHomeClubId: match.team_home_club_id ?? null,
|
||||||
|
teamAway: match.team_away ?? null,
|
||||||
|
teamAwayId: match.team_away_id ?? null,
|
||||||
|
teamAwayClubId: match.team_away_club_id ?? null,
|
||||||
|
result: match.matches_won != null && match.matches_lost != null
|
||||||
|
? `${match.matches_won}:${match.matches_lost}`
|
||||||
|
: null,
|
||||||
|
isConfirmed: match.is_confirmed ?? null,
|
||||||
|
isComplete: match.is_meeting_complete ?? null,
|
||||||
|
originalDate: match.original_date ?? null,
|
||||||
|
location: match.location ?? null,
|
||||||
|
pdfUrl: match.pdf_url ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSpielplanHtml(html, source) {
|
||||||
|
const context = extractRemixContext(html)
|
||||||
|
const result = findSchedule(context.state?.loaderData)
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Keinen Spielplan im Remix loaderData gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesByDay = result.schedule
|
||||||
|
const matches = Object.keys(matchesByDay)
|
||||||
|
.sort()
|
||||||
|
.flatMap((day) => matchesByDay[day].map((match) => normalizeMatch(day, match)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
source,
|
||||||
|
loaderDataPath: result.path.join('.'),
|
||||||
|
matchCount: matches.length,
|
||||||
|
matchesByDay,
|
||||||
|
matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSpielplan(options = {}) {
|
||||||
|
const today = options.today ?? new Date()
|
||||||
|
const season = getSpieljahrForDate(today)
|
||||||
|
const url = buildSpielplanUrl(season)
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'accept': 'text/html,application/xhtml+xml',
|
||||||
|
'accept-language': 'de-DE,de;q=0.9'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Spielplan-Download fehlgeschlagen: HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text()
|
||||||
|
const parsed = parseSpielplanHtml(html, {
|
||||||
|
url,
|
||||||
|
clubId: DEFAULT_CONFIG.clubId,
|
||||||
|
clubName: DEFAULT_CONFIG.clubName,
|
||||||
|
association: DEFAULT_CONFIG.association,
|
||||||
|
season
|
||||||
|
})
|
||||||
|
|
||||||
|
await fs.mkdir(OUTPUT_DIR, { recursive: true })
|
||||||
|
await fs.writeFile(HTML_FILE, html, 'utf8')
|
||||||
|
await fs.writeFile(JSON_FILE, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8')
|
||||||
|
|
||||||
|
return {
|
||||||
|
jsonFile: JSON_FILE,
|
||||||
|
htmlFile: HTML_FILE,
|
||||||
|
...parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readImportedSpielplan() {
|
||||||
|
const content = await fs.readFile(JSON_FILE, 'utf8')
|
||||||
|
return JSON.parse(content)
|
||||||
|
}
|
||||||
3
temp/webpage-downloads/crontab-spielplan.example
Normal file
3
temp/webpage-downloads/crontab-spielplan.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Taeglicher Spielplan-Import um 07:00 Uhr.
|
||||||
|
# Installieren mit: crontab crontab-spielplan.example
|
||||||
|
0 7 * * * /home/torsten/Programs/harheimertc/temp/webpage-downloads/run-spielplan-import.sh
|
||||||
25
temp/webpage-downloads/data/harheimer_tc_spielplan.html
Normal file
25
temp/webpage-downloads/data/harheimer_tc_spielplan.html
Normal file
File diff suppressed because one or more lines are too long
9719
temp/webpage-downloads/data/harheimer_tc_spielplan.json
Normal file
9719
temp/webpage-downloads/data/harheimer_tc_spielplan.json
Normal file
File diff suppressed because it is too large
Load Diff
236
temp/webpage-downloads/import-spielplan.js
Executable file
236
temp/webpage-downloads/import-spielplan.js
Executable file
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
association: "HeTTV",
|
||||||
|
clubId: "43030",
|
||||||
|
clubName: "Harheimer_TC",
|
||||||
|
outputDir: path.join(__dirname, "data"),
|
||||||
|
outputFile: "harheimer_tc_spielplan.json",
|
||||||
|
htmlFile: "harheimer_tc_spielplan.html",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {};
|
||||||
|
for (let i = 2; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg.startsWith("--")) {
|
||||||
|
const key = arg.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
args[key] = true;
|
||||||
|
} else {
|
||||||
|
args[key] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function seasonForDate(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const startYear = date.getMonth() >= 6 ? year : year - 1;
|
||||||
|
const endYear = startYear + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startYear,
|
||||||
|
endYear,
|
||||||
|
seasonSlug: `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`,
|
||||||
|
dateStart: `${startYear}-07-01`,
|
||||||
|
dateEnd: `${endYear}-06-30`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(season) {
|
||||||
|
const base = `https://www.mytischtennis.de/click-tt/${CONFIG.association}/${season.seasonSlug}/verein/${CONFIG.clubId}/${CONFIG.clubName}/spielplan`;
|
||||||
|
return `${base}?date_start=${season.dateStart}&date_end=${season.dateEnd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHtml(args, url) {
|
||||||
|
if (args.input) {
|
||||||
|
return fs.readFileSync(path.resolve(args.input), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = execFileSync("curl", ["-fsSL", "--compressed", url], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 20 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(CONFIG.outputDir, CONFIG.htmlFile), html);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRemixContext(html) {
|
||||||
|
const marker = "window.__remixContext = ";
|
||||||
|
const start = html.indexOf(marker);
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error("window.__remixContext nicht gefunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStart = start + marker.length;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (let i = jsonStart; i < html.length; i += 1) {
|
||||||
|
const char = html[i];
|
||||||
|
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === "\"") {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\"") {
|
||||||
|
inString = true;
|
||||||
|
} else if (char === "{") {
|
||||||
|
depth += 1;
|
||||||
|
} else if (char === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return JSON.parse(html.slice(jsonStart, i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Ende von window.__remixContext nicht gefunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeScheduleByDate(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.some(([key, list]) => (
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/.test(key)
|
||||||
|
&& Array.isArray(list)
|
||||||
|
&& list.some((item) => item && item.team_home && item.team_away && item.meeting_id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSchedule(value, trail = []) {
|
||||||
|
if (looksLikeScheduleByDate(value)) {
|
||||||
|
return { schedule: value, path: trail };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
const result = findSchedule(child, trail.concat(key));
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatch(day, match) {
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
date: match.date ?? null,
|
||||||
|
formattedDay: match.formattedDay ?? null,
|
||||||
|
formattedTime: match.formattedTime ?? null,
|
||||||
|
state: match.state ?? null,
|
||||||
|
meetingId: match.meeting_id ?? null,
|
||||||
|
meetingNumber: match.meeting_number ?? null,
|
||||||
|
leagueId: match.league_id ?? null,
|
||||||
|
leagueName: match.league_name ?? null,
|
||||||
|
leagueShortName: match.league_short_name ?? null,
|
||||||
|
leagueOrgShortName: match.league_org_short_name ?? null,
|
||||||
|
roundName: match.round_name ?? null,
|
||||||
|
teamHome: match.team_home ?? null,
|
||||||
|
teamHomeId: match.team_home_id ?? null,
|
||||||
|
teamHomeClubId: match.team_home_club_id ?? null,
|
||||||
|
teamAway: match.team_away ?? null,
|
||||||
|
teamAwayId: match.team_away_id ?? null,
|
||||||
|
teamAwayClubId: match.team_away_club_id ?? null,
|
||||||
|
result: match.matches_won != null && match.matches_lost != null
|
||||||
|
? `${match.matches_won}:${match.matches_lost}`
|
||||||
|
: null,
|
||||||
|
isConfirmed: match.is_confirmed ?? null,
|
||||||
|
isComplete: match.is_meeting_complete ?? null,
|
||||||
|
originalDate: match.original_date ?? null,
|
||||||
|
location: match.location ?? null,
|
||||||
|
pdfUrl: match.pdf_url ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSchedule(html, meta) {
|
||||||
|
const context = extractRemixContext(html);
|
||||||
|
const result = findSchedule(context.state?.loaderData);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Keinen Spielplan im Remix loaderData gefunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesByDay = result.schedule;
|
||||||
|
const matches = Object.keys(matchesByDay)
|
||||||
|
.sort()
|
||||||
|
.flatMap((day) => matchesByDay[day].map((match) => normalizeMatch(day, match)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
source: meta,
|
||||||
|
loaderDataPath: result.path.join("."),
|
||||||
|
matchCount: matches.length,
|
||||||
|
matchesByDay,
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
const today = args.today ? new Date(`${args.today}T12:00:00`) : new Date();
|
||||||
|
if (Number.isNaN(today.getTime())) {
|
||||||
|
throw new Error(`Ungueltiges Datum fuer --today: ${args.today}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const season = seasonForDate(today);
|
||||||
|
const url = buildUrl(season);
|
||||||
|
const html = readHtml(args, url);
|
||||||
|
const parsed = parseSchedule(html, {
|
||||||
|
url,
|
||||||
|
clubId: CONFIG.clubId,
|
||||||
|
clubName: CONFIG.clubName,
|
||||||
|
association: CONFIG.association,
|
||||||
|
season,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
|
||||||
|
const outputPath = path.join(CONFIG.outputDir, CONFIG.outputFile);
|
||||||
|
fs.writeFileSync(outputPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(`Spielplan gespeichert: ${outputPath}`);
|
||||||
|
console.log(`Spiele: ${parsed.matchCount}`);
|
||||||
|
console.log(`Zeitraum: ${season.dateStart} bis ${season.dateEnd}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
6
temp/webpage-downloads/run-spielplan-import.sh
Executable file
6
temp/webpage-downloads/run-spielplan-import.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /home/torsten/Programs/harheimertc/temp/webpage-downloads
|
||||||
|
mkdir -p data
|
||||||
|
/usr/bin/env node import-spielplan.js >> data/spielplan-import.log 2>&1
|
||||||
18
temp/webpage-downloads/spielplan.html
Normal file
18
temp/webpage-downloads/spielplan.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user