chore(version): update version and changelog for release 0.8.136
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s

- Bumped APP_VERSION to 0.8.136 and updated the changelog to reflect recent changes.
- Fixed the Mandanten-Header handling in `api/exercises.js` for improved API requests.
- Continued Frontend Phase 4 with the addition of the `exercises.js` module, enhancing the API structure.
- Updated architecture documentation to include details on the new `exercises.js` API and its integration with the existing client structure.
- Enhanced `utils/api.js` to re-export the new exercises module, streamlining API access.
This commit is contained in:
Lars 2026-05-14 21:49:56 +02:00
parent 8175e239b4
commit e759076a6c
6 changed files with 621 additions and 628 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.134"
APP_VERSION = "0.8.136"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
@ -36,6 +36,20 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.136",
"date": "2026-05-13",
"changes": [
"Fix: api/exercises.js — Mandanten-Header für Raw-fetch (PUT Übung, Medien-Upload, Bulk-Archiv) über lokale withActiveClubHeaders statt mergeActiveClubHeader-Import (ReferenceError beim Speichern).",
],
},
{
"version": "0.8.135",
"date": "2026-05-13",
"changes": [
"Frontend Phase 4 Welle 3: frontend/src/api/exercises.js (Übungen, Medien/Archiv, Progressionsgraphen, KI); client.js exportiert API_URL und mergeActiveClubHeader; utils/api.js re-exportiert Modul, api-Objekt spread exercises.",
],
},
{
"version": "0.8.134",
"date": "2026-05-14",

View File

@ -9,7 +9,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md) | Zielarchitektur (Frontend, API, Daten), Qualitätsziele, Einbindung neuer Features |
| [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md) | Erfasste Architekturschuld, Reihenfolge und Massnahmen zur Behebung |
| [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md) | Phasen, Meilensteine, Abnahmekriterien, Aufwandsschwerpunkte |
| [`frontend/src/api/client.js`](../../frontend/src/api/client.js) | Phase 4: zentraler HTTP-Client (`request`, `ACTIVE_CLUB_STORAGE_KEY`) |
| [`frontend/src/api/client.js`](../../frontend/src/api/client.js) | Phase 4: zentraler HTTP-Client (`request`, `ACTIVE_CLUB_STORAGE_KEY`, `API_URL`, `mergeActiveClubHeader`) |
| [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen |
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) |

View File

@ -8,7 +8,7 @@
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
- **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 910**.
- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...planning`).
- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; **Welle 3** `exercises.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...exercises`, `...planning`).
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
@ -93,7 +93,7 @@
## Phase 4 API-Client Modularisierung
**Status:** **fortlaufend** (2026-05-14) — Welle 1: **`client.js`**; Welle 2: **`planning.js`**; **`utils/api.js`** bleibt vollständige Facade.
**Status:** **fortlaufend** (2026-05-14) — Welle 1: **`client.js`**; Welle 2: **`planning.js`**; Welle 3: **`exercises.js`**; **`utils/api.js`** bleibt vollständige Facade.
**Fokus:** Wartbarkeit für viele neue Features.
@ -101,7 +101,8 @@
|------|-------|--------|
| `frontend/src/api/client.js` — zentraler HTTP-Client | A2 | erledigt (Welle 1) |
| `frontend/src/api/planning.js` — Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, Dashboard-KPIs) | A2 | erledigt (Welle 2) |
| Domänen-Module unter `frontend/src/api/` + schrittweise Entlastung von `api.js` | A2 | offen |
| `frontend/src/api/exercises.js` — Übungen, Medien/Archiv, Varianten, Progressionsgraphen, KI | A2 | erledigt (Welle 3) |
| Weitere Domänen-Module unter `frontend/src/api/` + Entlastung von `utils/api.js` | A2 | offen |
| Neue Endpoints primär in Domänen-Dateien | S3 | offen |
**Abnahme:** Anteil neuer Module > X% der neuen Zeilen (Team-Ziel); Monolith wächst nicht weiter.

View File

@ -1,14 +1,14 @@
/**
* HTTP-Client: Token, Mandanten-Header, Fehler-Mapping.
* Alle API-Aufrufe laufen über request() siehe utils/api.js (Facade) und künftige Domänenmodule.
* Alle API-Aufrufe laufen über request() siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js).
*/
const API_URL = import.meta.env.VITE_API_URL || ''
export const API_URL = import.meta.env.VITE_API_URL || ''
/** LocalStorage + Request-Header für Mandanten-Kontext */
export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id'
function mergeActiveClubHeader(headers = {}) {
export function mergeActiveClubHeader(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() }

View File

@ -0,0 +1,593 @@
/**
* Übungen: Liste/CRUD, Medien & Archiv-Anbindung, Progressionsgraphen, KI-Hilfen.
*/
import { stripHtmlToText } from '../utils/htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
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: s.intensity || null,
required_level: s.required_level || null,
target_level: s.target_level || null,
})),
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*</.test(text)
? `HTTP ${response.status}: ${text.trim()}`
: `HTTP ${response.status}`
throw new Error(msg)
}
return response.json()
}
export async function updateExerciseMedia(exerciseId, mediaId, data) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseMedia(exerciseId, mediaId) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
}
export async function reorderExerciseMedia(exerciseId, mediaIds) {
return request(`/api/exercises/${exerciseId}/media/reorder`, {
method: 'PUT',
body: JSON.stringify({ media_ids: mediaIds }),
})
}
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST',
body: JSON.stringify({ action, ...extra }),
})
}
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
export async function listMediaAssets(params = {}) {
const sp = new URLSearchParams()
if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function bulkMediaLifecycle(data) {
return request('/api/media-assets/bulk-lifecycle', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function bulkPatchMediaAssets(data) {
return request('/api/media-assets/bulk-patch', {
method: 'POST',
body: JSON.stringify(data),
})
}
/**
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
* @param {File[]} files
* @param {{ visibility?: string, club_id?: number }} [options]
*/
export async function bulkUploadMediaAssets(files, options = {}) {
const visibility = options.visibility || 'private'
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({})
if (token) headers['X-Auth-Token'] = token
const formData = new FormData()
formData.append('visibility', String(visibility))
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
if (options.copyright_notice != null && String(options.copyright_notice).trim())
formData.append('copyright_notice', String(options.copyright_notice).trim())
const p06Fields = [
'rights_holder_confirmed',
'contains_identifiable_persons',
'person_consent_confirmed',
'person_consent_context',
'contains_minors',
'parental_consent_confirmed',
'parental_consent_context',
'contains_music',
'music_rights_confirmed',
'music_rights_context',
'contains_third_party_content',
'third_party_rights_confirmed',
'third_party_rights_context',
]
for (const f of p06Fields) {
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
}
const url = `${API_URL}/api/media-assets/bulk-upload`
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function getMediaAssetJournal(assetId) {
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
}
export async function addMediaAssetDeclarationCorrection(assetId, body) {
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
body: JSON.stringify(body),
})
}
// P-11: Legal-Hold-Endpunkte
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
method: 'POST',
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
})
}
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
method: 'POST',
body: JSON.stringify({ release_note: releaseNote }),
})
}
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
export async function createExercise(data) {
return request('/api/exercises', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExercise(id, data) {
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({ 'Content-Type': 'application/json' })
if (token) headers['X-Auth-Token'] = token
const url = `${API_URL}/api/exercises/${id}`
const response = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(data),
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 422 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
e.status = 422
e.code = d.code
e.payload = d
throw e
}
if (parsed?.detail != null) {
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
throw new Error(msg)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
export async function createExerciseVariant(exerciseId, data) {
return request(`/api/exercises/${exerciseId}/variants`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseVariant(exerciseId, variantId, data) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseVariant(exerciseId, variantId) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
}
export async function reorderExerciseVariants(exerciseId, variantIds) {
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
method: 'PUT',
body: JSON.stringify({ variant_ids: variantIds }),
})
}
// Progressionsgraphen (Übung → Übung), Migration 032/033
export async function listExerciseProgressionGraphs() {
return request('/api/exercise-progression-graphs')
}
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
const q = includeEdges ? '?include_edges=true' : ''
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionGraph(id, data) {
return request(`/api/exercise-progression-graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionGraph(id) {
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
}
export async function listExerciseProgressionEdges(graphId, query = {}) {
const q = new URLSearchParams()
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
const qs = q.toString()
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
}
export async function createExerciseProgressionEdge(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
}
export async function createExerciseProgressionSequence(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
method: 'POST',
body: JSON.stringify({ edge_ids: edgeIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function regenerateExerciseAi(exerciseId, payload) {
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
method: 'POST',
body: JSON.stringify(payload),
})
}

View File

@ -4,12 +4,12 @@
* Zentrale API-Kommunikation mit automatischer Token-Injektion
*/
import { stripHtmlToText } from './htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi'
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
import * as exercises from '../api/exercises.js'
import * as planning from '../api/planning.js'
export { ACTIVE_CLUB_STORAGE_KEY }
export * from '../api/exercises.js'
export * from '../api/planning.js'
// ============================================================================
@ -326,583 +326,6 @@ export async function deleteMethod(id) {
return request(`/api/methods/${id}`, { method: 'DELETE' })
}
// ============================================================================
// 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: s.intensity || null,
required_level: s.required_level || null,
target_level: s.target_level || null,
})),
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 = mergeActiveClubHeader({})
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*</.test(text)
? `HTTP ${response.status}: ${text.trim()}`
: `HTTP ${response.status}`
throw new Error(msg)
}
return response.json()
}
export async function updateExerciseMedia(exerciseId, mediaId, data) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseMedia(exerciseId, mediaId) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
}
export async function reorderExerciseMedia(exerciseId, mediaIds) {
return request(`/api/exercises/${exerciseId}/media/reorder`, {
method: 'PUT',
body: JSON.stringify({ media_ids: mediaIds }),
})
}
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST',
body: JSON.stringify({ action, ...extra }),
})
}
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
export async function listMediaAssets(params = {}) {
const sp = new URLSearchParams()
if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function bulkMediaLifecycle(data) {
return request('/api/media-assets/bulk-lifecycle', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function bulkPatchMediaAssets(data) {
return request('/api/media-assets/bulk-patch', {
method: 'POST',
body: JSON.stringify(data),
})
}
/**
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
* @param {File[]} files
* @param {{ visibility?: string, club_id?: number }} [options]
*/
export async function bulkUploadMediaAssets(files, options = {}) {
const visibility = options.visibility || 'private'
const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({})
if (token) headers['X-Auth-Token'] = token
const formData = new FormData()
formData.append('visibility', String(visibility))
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
if (options.copyright_notice != null && String(options.copyright_notice).trim())
formData.append('copyright_notice', String(options.copyright_notice).trim())
const p06Fields = [
'rights_holder_confirmed',
'contains_identifiable_persons',
'person_consent_confirmed',
'person_consent_context',
'contains_minors',
'parental_consent_confirmed',
'parental_consent_context',
'contains_music',
'music_rights_confirmed',
'music_rights_context',
'contains_third_party_content',
'third_party_rights_confirmed',
'third_party_rights_context',
]
for (const f of p06Fields) {
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
}
const url = `${API_URL}/api/media-assets/bulk-upload`
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function getMediaAssetJournal(assetId) {
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
}
export async function addMediaAssetDeclarationCorrection(assetId, body) {
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
body: JSON.stringify(body),
})
}
// P-11: Legal-Hold-Endpunkte
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
method: 'POST',
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
})
}
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
method: 'POST',
body: JSON.stringify({ release_note: releaseNote }),
})
}
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
export async function createExercise(data) {
return request('/api/exercises', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateExercise(id, data) {
const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({ 'Content-Type': 'application/json' })
if (token) headers['X-Auth-Token'] = token
const url = `${API_URL}/api/exercises/${id}`
const response = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(data),
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 422 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
e.status = 422
e.code = d.code
e.payload = d
throw e
}
if (parsed?.detail != null) {
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
throw new Error(msg)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
export async function createExerciseVariant(exerciseId, data) {
return request(`/api/exercises/${exerciseId}/variants`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseVariant(exerciseId, variantId, data) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseVariant(exerciseId, variantId) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
}
export async function reorderExerciseVariants(exerciseId, variantIds) {
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
method: 'PUT',
body: JSON.stringify({ variant_ids: variantIds }),
})
}
// Progressionsgraphen (Übung → Übung), Migration 032/033
export async function listExerciseProgressionGraphs() {
return request('/api/exercise-progression-graphs')
}
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
const q = includeEdges ? '?include_edges=true' : ''
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionGraph(id, data) {
return request(`/api/exercise-progression-graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionGraph(id) {
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
}
export async function listExerciseProgressionEdges(graphId, query = {}) {
const q = new URLSearchParams()
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
const qs = q.toString()
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
}
export async function createExerciseProgressionEdge(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
}
export async function createExerciseProgressionSequence(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
method: 'POST',
body: JSON.stringify({ edge_ids: edgeIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function regenerateExerciseAi(exerciseId, payload) {
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
method: 'POST',
body: JSON.stringify(payload),
})
}
// ============================================================================
// Catalogs (Admin-verwaltbare Stammdaten)
// ============================================================================
@ -1324,47 +747,8 @@ export const api = {
updateMethod,
deleteMethod,
// Exercises
listExercises,
getExercise,
createExercise,
updateExercise,
bulkPatchExercisesMetadata,
deleteExercise,
createExerciseVariant,
updateExerciseVariant,
deleteExerciseVariant,
reorderExerciseVariants,
buildExerciseApiPayload,
suggestExerciseAi,
regenerateExerciseAi,
uploadExerciseMedia,
updateExerciseMedia,
deleteExerciseMedia,
reorderExerciseMedia,
postMediaAssetLifecycle,
listMediaAssets,
patchMediaAsset,
bulkMediaLifecycle,
bulkPatchMediaAssets,
bulkUploadMediaAssets,
getMediaAssetJournal,
addMediaAssetDeclarationCorrection,
attachExerciseMediaFromAsset,
setMediaAssetLegalHold,
releaseMediaAssetLegalHold,
listMediaAssetsWithLegalHold,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,
createExerciseProgressionGraph,
updateExerciseProgressionGraph,
deleteExerciseProgressionGraph,
listExerciseProgressionEdges,
createExerciseProgressionEdge,
updateExerciseProgressionEdge,
deleteExerciseProgressionEdge,
createExerciseProgressionSequence,
deleteExerciseProgressionEdgesBatch,
// Exercises + Medien/Archiv (Progression, KI) → frontend/src/api/exercises.js
...exercises,
// Training Planning → frontend/src/api/planning.js
...planning,