shinkan-jinkendo/frontend/src/pages/MediaWikiImportPage.jsx
Lars c738f1234b
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m54s
fix: MediaWiki import - increase limit to 500 and add validation
Error: 422 Unprocessable Entity when limit > 100
Root cause: Backend enforced max=100, frontend allowed any value

Backend fix:
- Increased preview limit from 100 to 500 (consistent with execute)
- import_wiki.py line 64: le=100 → le=500

Frontend fix:
- Added Math.min/max validation to both limit inputs
- Preview limit: max 500 with auto-clamp
- Execute limit: max 500 with auto-clamp
- Updated placeholder: 'Kein Limit (max 500)'

Prevents 422 errors from invalid limit values
2026-04-24 17:15:17 +02:00

540 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// Preview Tab State
const [previewCategory, setPreviewCategory] = useState('Übungen')
const [previewType, setPreviewType] = useState('exercise')
const [previewLimit, setPreviewLimit] = useState(10)
const [previewData, setPreviewData] = useState(null)
// Execute Tab State
const [executeCategory, setExecuteCategory] = useState('Übungen')
const [executeType, setExecuteType] = useState('exercise')
const [executeReimport, setExecuteReimport] = useState(false)
const [executeDryRun, setExecuteDryRun] = useState(false)
const [executeLimit, setExecuteLimit] = useState(null)
const [currentImport, setCurrentImport] = useState(null)
const [pollingInterval, setPollingInterval] = useState(null)
// History Tab State
const [logs, setLogs] = useState([])
// Load logs on mount and tab switch
useEffect(() => {
if (activeTab === 'history') {
loadLogs()
}
}, [activeTab])
// Polling cleanup
useEffect(() => {
return () => {
if (pollingInterval) {
clearInterval(pollingInterval)
}
}
}, [pollingInterval])
const loadLogs = async () => {
try {
setLoading(true)
const data = await api.listMediaWikiImportLogs()
setLogs(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handlePreview = async () => {
try {
setLoading(true)
setError(null)
const data = await api.previewMediaWikiImport(previewCategory, previewType, previewLimit)
setPreviewData(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleExecute = async () => {
try {
setLoading(true)
setError(null)
const result = await api.executeMediaWikiImport({
category: executeCategory,
import_type: executeType,
reimport_existing: executeReimport,
dry_run: executeDryRun,
limit: executeLimit || null
})
setCurrentImport(result)
// Start polling
const interval = setInterval(async () => {
try {
const status = await api.getMediaWikiImportStatus(result.log_id)
setCurrentImport(status)
if (status.import_status === 'completed' || status.import_status === 'failed') {
clearInterval(interval)
setPollingInterval(null)
setLoading(false)
}
} catch (err) {
console.error('Polling error:', err)
}
}, 2000)
setPollingInterval(interval)
} catch (err) {
setError(err.message)
setLoading(false)
}
}
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<AdminPageNav />
<h1>MediaWiki Import (Semantic MediaWiki)</h1>
<p style={{ color: 'var(--text2)', marginBottom: '24px' }}>
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
</p>
{/* Tabs */}
<div style={{ borderBottom: '2px solid var(--border)', marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{['preview', 'execute', 'history'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '12px 24px',
background: activeTab === tab ? 'var(--accent)' : 'transparent',
color: activeTab === tab ? 'white' : 'var(--text1)',
border: 'none',
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === tab ? 'bold' : 'normal',
transition: 'all 0.2s'
}}
>
{tab === 'preview' && '👁️ Vorschau'}
{tab === 'execute' && '▶️ Ausführen'}
{tab === 'history' && '📜 Historie'}
</button>
))}
</div>
</div>
{/* Error Display */}
{error && (
<div style={{
padding: '16px',
background: '#FEE',
border: '1px solid #C00',
borderRadius: '8px',
marginBottom: '20px'
}}>
<strong>Fehler:</strong> {error}
</div>
)}
{/* Preview Tab */}
{activeTab === 'preview' && (
<div>
<div style={{ background: 'var(--surface)', padding: '20px', borderRadius: '12px', marginBottom: '20px' }}>
<h2>Import-Vorschau</h2>
<div style={{ display: 'grid', gap: '16px', marginTop: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Kategorie
</label>
<input
type="text"
value={previewCategory}
onChange={(e) => setPreviewCategory(e.target.value)}
placeholder="Übungen"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Import-Typ
</label>
<select
value={previewType}
onChange={(e) => setPreviewType(e.target.value)}
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
>
<option value="exercise">Übungen</option>
<option value="skill">Fähigkeiten</option>
<option value="method">Methoden</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Limit
</label>
<input
type="number"
value={previewLimit}
onChange={(e) => setPreviewLimit(Math.min(500, Math.max(1, parseInt(e.target.value) || 10)))}
min="1"
max="500"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<button
onClick={handlePreview}
disabled={loading}
style={{
padding: '16px',
background: 'var(--accent)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{loading ? 'Lade...' : '👁️ Vorschau laden'}
</button>
</div>
</div>
{/* Preview Results */}
{previewData && (
<div>
<h3>
{previewData.total_found} Einträge gefunden in Kategorie "{previewData.category}"
</h3>
<div style={{ marginTop: '16px', display: 'grid', gap: '12px' }}>
{previewData.preview.map((item, idx) => (
<details
key={idx}
style={{
background: 'var(--surface)',
padding: '16px',
borderRadius: '8px',
border: `2px solid ${item.errors.length > 0 ? '#C00' : item.warnings.length > 0 ? '#F90' : 'var(--border)'}`
}}
>
<summary style={{ cursor: 'pointer', fontWeight: 'bold', fontSize: '16px' }}>
{item.already_imported && '✅ '}
{item.wiki_page_title}
{item.errors.length > 0 && ' ❌'}
{item.warnings.length > 0 && ' ⚠️'}
</summary>
<div style={{ marginTop: '12px', fontSize: '14px', color: 'var(--text2)' }}>
{item.already_imported && (
<p><strong>Bereits importiert:</strong> {new Date(item.last_imported_at).toLocaleString('de-DE')}</p>
)}
{item.warnings.length > 0 && (
<div style={{ background: '#FFC', padding: '8px', borderRadius: '4px', marginTop: '8px' }}>
<strong>Warnungen:</strong>
<ul>{item.warnings.map((w, i) => <li key={i}>{w}</li>)}</ul>
</div>
)}
{item.errors.length > 0 && (
<div style={{ background: '#FEE', padding: '8px', borderRadius: '4px', marginTop: '8px' }}>
<strong>Fehler:</strong>
<ul>{item.errors.map((e, i) => <li key={i}>{e}</li>)}</ul>
</div>
)}
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer', color: 'var(--accent)' }}>
Gemappte Felder anzeigen
</summary>
<pre style={{
background: 'var(--bg)',
padding: '12px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '12px',
marginTop: '8px'
}}>
{JSON.stringify(item.mapped_fields, null, 2)}
</pre>
</details>
</div>
</details>
))}
</div>
</div>
)}
</div>
)}
{/* Execute Tab */}
{activeTab === 'execute' && (
<div>
<div style={{ background: 'var(--surface)', padding: '20px', borderRadius: '12px', marginBottom: '20px' }}>
<h2>Import ausführen</h2>
<div style={{ display: 'grid', gap: '16px', marginTop: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Kategorie
</label>
<input
type="text"
value={executeCategory}
onChange={(e) => setExecuteCategory(e.target.value)}
placeholder="Übungen"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Import-Typ
</label>
<select
value={executeType}
onChange={(e) => setExecuteType(e.target.value)}
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
>
<option value="exercise">Übungen</option>
<option value="skill">Fähigkeiten</option>
<option value="method">Methoden</option>
</select>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={executeReimport}
onChange={(e) => setExecuteReimport(e.target.checked)}
/>
<span>Existierende überschreiben (Reimport)</span>
</label>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={executeDryRun}
onChange={(e) => setExecuteDryRun(e.target.checked)}
/>
<span>Dry-Run (nicht speichern)</span>
</label>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Limit (optional)
</label>
<input
type="number"
value={executeLimit || ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value) : null
setExecuteLimit(val ? Math.min(500, Math.max(1, val)) : null)
}}
placeholder="Kein Limit (max 500)"
min="1"
max="500"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<button
onClick={handleExecute}
disabled={loading}
style={{
padding: '16px',
background: executeDryRun ? 'var(--accent-dark)' : 'var(--accent)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{loading ? '⏳ Import läuft...' : executeDryRun ? '🧪 Dry-Run starten' : '▶️ Import starten'}
</button>
</div>
</div>
{/* Import Status */}
{currentImport && (
<div style={{
background: 'var(--surface)',
padding: '20px',
borderRadius: '12px',
border: `2px solid ${
currentImport.import_status === 'completed' ? '#0A0' :
currentImport.import_status === 'failed' ? '#C00' :
'var(--accent)'
}`
}}>
<h3>
{currentImport.import_status === 'running' && '⏳ Import läuft...'}
{currentImport.import_status === 'completed' && '✅ Import abgeschlossen'}
{currentImport.import_status === 'failed' && '❌ Import fehlgeschlagen'}
</h3>
<div style={{ marginTop: '16px', display: 'grid', gap: '8px', fontSize: '14px' }}>
<div><strong>Log ID:</strong> {currentImport.id}</div>
<div><strong>Kategorie:</strong> {currentImport.category}</div>
<div><strong>Typ:</strong> {currentImport.import_type}</div>
<div><strong>Total:</strong> {currentImport.items_total}</div>
<div style={{ color: '#0A0' }}><strong> Importiert:</strong> {currentImport.items_imported}</div>
<div style={{ color: '#F90' }}><strong> Übersprungen:</strong> {currentImport.items_skipped}</div>
<div style={{ color: '#C00' }}><strong> Fehlgeschlagen:</strong> {currentImport.items_failed}</div>
{currentImport.started_at && (
<div><strong>Gestartet:</strong> {new Date(currentImport.started_at).toLocaleString('de-DE')}</div>
)}
{currentImport.finished_at && (
<div><strong>Beendet:</strong> {new Date(currentImport.finished_at).toLocaleString('de-DE')}</div>
)}
</div>
{currentImport.error_log && currentImport.error_log.length > 0 && (
<details style={{ marginTop: '16px' }}>
<summary style={{ cursor: 'pointer', color: '#C00', fontWeight: 'bold' }}>
Fehler anzeigen ({currentImport.error_log.length})
</summary>
<div style={{
marginTop: '8px',
background: '#FEE',
padding: '12px',
borderRadius: '4px',
maxHeight: '300px',
overflow: 'auto'
}}>
{currentImport.error_log.map((err, idx) => (
<div key={idx} style={{ marginBottom: '8px', paddingBottom: '8px', borderBottom: '1px solid #CCC' }}>
<strong>{err.item}:</strong> {err.error}
</div>
))}
</div>
</details>
)}
</div>
)}
</div>
)}
{/* History Tab */}
{activeTab === 'history' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2>Import-Historie</h2>
<button
onClick={loadLogs}
disabled={loading}
style={{
padding: '8px 16px',
background: 'var(--accent)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
🔄 Aktualisieren
</button>
</div>
{logs.length === 0 && !loading && (
<p style={{ color: 'var(--text2)', textAlign: 'center', padding: '40px' }}>
Noch keine Imports durchgeführt
</p>
)}
<div style={{ display: 'grid', gap: '12px' }}>
{logs.map((log) => (
<div
key={log.id}
style={{
background: 'var(--surface)',
padding: '16px',
borderRadius: '8px',
border: `2px solid ${
log.import_status === 'completed' ? '#0A0' :
log.import_status === 'failed' ? '#C00' :
'var(--accent)'
}`
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
{log.import_status === 'completed' && '✅ '}
{log.import_status === 'failed' && '❌ '}
{log.import_status === 'running' && '⏳ '}
{log.category} ({log.import_type})
{log.dry_run && ' [Dry-Run]'}
</div>
<div style={{ fontSize: '14px', color: 'var(--text2)', display: 'grid', gap: '4px' }}>
<div>{new Date(log.started_at).toLocaleString('de-DE')}</div>
<div>
<span style={{ color: '#0A0' }}> {log.items_imported}</span>
{' | '}
<span style={{ color: '#F90' }}> {log.items_skipped}</span>
{' | '}
<span style={{ color: '#C00' }}> {log.items_failed}</span>
{' | '}
<span>Total: {log.items_total}</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}