/** * Übungen: Liste/CRUD, Medien & Archiv-Anbindung, Progressionsgraphen, KI-Hilfen. */ import { stripHtmlToText } from '../utils/htmlUtils' import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi' import { normalizeExerciseSkillIntensity } from '../constants/exerciseSkillIntensity' import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js' /** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */ function withActiveClubHeaders(headers = {}) { const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) if (cid && /^\d+$/.test(String(cid).trim())) { return { ...headers, 'X-Active-Club-Id': String(cid).trim() } } return { ...headers } } // ============================================================================ // Exercises // ============================================================================ export async function listExercises(filters = {}) { const q = new URLSearchParams() Object.entries(filters).forEach(([k, v]) => { if (v === undefined || v === null) return if (typeof v === 'boolean') { q.set(k, v ? 'true' : 'false') return } if (Array.isArray(v)) { if (v.length === 0) return v.forEach((item) => { if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') { q.append(k, String(item)) } }) return } if (String(v).trim() !== '') q.set(k, String(v)) }) const query = q.toString() return request(`/api/exercises${query ? '?' + query : ''}`) } /** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */ export function buildExerciseApiPayload(formData, extras = {}) { const num = (v) => (v === '' || v == null ? null : Number(v)) const goalHtml = formData.goal || '' const execHtml = formData.execution || '' const goalText = stripHtmlToText(goalHtml) const execText = stripHtmlToText(execHtml) if (!goalText && !execText) { throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).') } const mapFocus = (formData.focus_areas_multi || []) .filter((x) => x && x.focus_area_id) .map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary })) const mapStyles = (formData.training_styles_multi || []) .filter((x) => x && x.training_style_id) .map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary })) const mapTTypes = (formData.training_types_multi || []) .filter((x) => x && x.training_type_id) .map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary })) const mapTg = (formData.target_groups_multi || []) .filter((x) => x && x.target_group_id) .map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary })) const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase() const payload = { title: (formData.title || '').trim(), summary: formData.summary || null, goal: goalHtml.trim() ? goalHtml : null, execution: execHtml.trim() ? execHtml : null, preparation: formData.preparation || null, trainer_notes: formData.trainer_notes || null, duration_min: num(formData.duration_min), duration_max: num(formData.duration_max), group_size_min: num(formData.group_size_min), group_size_max: num(formData.group_size_max), equipment: Array.isArray(formData.equipment) ? formData.equipment : [], focus_areas_multi: mapFocus, training_styles_multi: mapStyles, training_types_multi: mapTTypes, target_groups_multi: mapTg, age_groups: [], skills: (formData.skills || []).map((s) => ({ skill_id: s.skill_id, is_primary: !!s.is_primary, intensity: normalizeExerciseSkillIntensity(s.intensity), required_level: s.required_level || null, target_level: s.target_level || null, ai_suggested: !!s.ai_suggested, })), visibility: visibilityNorm, status: formData.status || 'draft', club_id: visibilityNorm === 'club' ? num(formData.club_id) : null, exercise_kind: String(formData.exercise_kind || 'simple').toLowerCase() === 'combination' ? 'combination' : 'simple', ...extras, } const isCombo = payload.exercise_kind === 'combination' if (isCombo) { let mpObj = {} const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : '' if (mpRaw) { try { const parsed = JSON.parse(mpRaw) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('Ablaufprofil muss ein JSON-Objekt sein.') } mpObj = parsed } catch (e) { if (e instanceof SyntaxError) { throw new Error('Ablaufprofil (JSON): Syntax ungültig.') } throw e } } const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : [] const combination_slots = [] function parseTimingField(raw) { if (raw === '' || raw == null || raw === undefined) return undefined const n = parseInt(String(raw), 10) return Number.isFinite(n) ? n : undefined } for (let i = 0; i < slotRows.length; i += 1) { const row = slotRows[i] || {} let ids = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n)) : [] /** Legacy: noch idsText Unterstützung für Import von älteren FormStand */ if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) { ids = row.idsText .split(/[\s,;]+/) .map((s) => s.trim()) .filter(Boolean) .map((s) => parseInt(s, 10)) .filter((n) => Number.isFinite(n)) } combination_slots.push({ slot_index: i, title: (typeof row.title === 'string' && row.title.trim()) || null, candidate_exercise_ids: ids, }) } const slot_profiles_v1_next = [] for (let i = 0; i < slotRows.length; i += 1) { const row = slotRows[i] || {} const o = { slot_index: i } const advanceMode = normalizeAdvanceMode(row.advance_mode) if (advanceMode !== 'timed') o.advance_mode = advanceMode const load = parseTimingField(row.load_sec) const crs = parseTimingField(row.consecutive_reps) const rsc = parseTimingField(row.rep_series_count) const intra = parseTimingField(row.intra_rep_rest_sec) const tran = parseTimingField(row.transition_after_sec) const serienUi = parseComboRepSeriesCountUi(row.rep_series_count) const allowInterSeriesPause = advanceMode === 'timed' || ((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2) if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load) if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs) if ( rsc !== undefined && rsc >= 1 && (advanceMode === 'rep' || advanceMode === 'manual') ) { o.rep_series_count = Math.round(rsc) } if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra) if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran) if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o) } payload.method_archetype = (formData.method_archetype || '').trim() || null if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next else delete mpObj.slot_profiles_v1 payload.method_profile = mpObj payload.combination_slots = combination_slots } else { payload.method_archetype = null payload.method_profile = {} } return payload } export async function uploadExerciseMedia(exerciseId, formData) { const token = localStorage.getItem('authToken') const headers = withActiveClubHeaders({}) if (token) headers['X-Auth-Token'] = token const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, { method: 'POST', headers, body: formData, }) if (!response.ok) { const text = await response.text() let parsed = null try { parsed = text ? JSON.parse(text) : null } catch { parsed = null } const d = parsed?.detail if ( response.status === 409 && d && typeof d === 'object' && !Array.isArray(d) && typeof d.code === 'string' ) { const e = new Error( typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden', ) e.code = d.code e.status = 409 e.payload = d throw e } if (response.status === 413) { const nginx = (text || '').toLowerCase().includes('nginx') throw new Error( nginx ? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).' : 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.', ) } const msg = typeof d === 'string' ? d : d != null && typeof d === 'object' && typeof d.message === 'string' ? d.message : d != null ? JSON.stringify(d) : text && text.length < 400 && !/^\s*