From 025b161d2f347d71f1391172e5f07e8f4b4d824a Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 28 Apr 2026 07:25:33 +0200 Subject: [PATCH] feat: enhance exercise mapping and filtering capabilities - Added support for style direction mappings in the backend, allowing for improved categorization of exercises. - Introduced a new function to normalize property synonyms, enhancing the mapping of exercise properties. - Updated the exercise catalog assignment logic to include style directions, ensuring proper database entries. - Enhanced the ExercisesListPage with new filtering options for style directions, improving user experience and search capabilities. --- backend/routers/import_wiki.py | 58 ++++-- backend/smw_mapper.py | 50 ++++- frontend/src/components/SearchableSelect.jsx | 67 +++++++ frontend/src/pages/ExercisesListPage.jsx | 197 ++++++++++--------- 4 files changed, 254 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/SearchableSelect.jsx diff --git a/backend/routers/import_wiki.py b/backend/routers/import_wiki.py index 2fc1f41..256d7a9 100644 --- a/backend/routers/import_wiki.py +++ b/backend/routers/import_wiki.py @@ -518,6 +518,29 @@ def _upsert_exercise(mapped: dict, reimport: bool, created_by: int) -> Optional[ return ex_id +def _find_skill_id_by_label(cur, label: str) -> Optional[int]: + """Katalog-Skill anhand Wiki-Label; exakt, ohne Leerzeichen, zuletzt Teilstring.""" + if not label or not str(label).strip(): + return None + raw = str(label).strip() + cur.execute("SELECT id FROM skills WHERE TRIM(name) ILIKE %s", (raw,)) + row = cur.fetchone() + if row: + return row["id"] + comp = "".join(raw.split()) + if len(comp) >= 3: + cur.execute( + "SELECT id FROM skills WHERE regexp_replace(TRIM(name), E'\\s+', '', 'g') ILIKE %s", + (comp,), + ) + row = cur.fetchone() + if row: + return row["id"] + cur.execute("SELECT id FROM skills WHERE name ILIKE %s LIMIT 1", (f"%{raw}%",)) + row = cur.fetchone() + return row["id"] if row else None + + def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict): """Weist M:N Katalog-Zuordnungen für eine importierte Übung zu.""" @@ -535,16 +558,18 @@ def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict): else: logger.warning("Focus Area '%s' nicht im Katalog gefunden", name) - # Style Directions - for name in mapped.get("style_names", []): - cur.execute("SELECT id FROM style_directions WHERE name ILIKE %s", (name,)) + # Stilrichtungen + for idx, name in enumerate(mapped.get("style_names", [])): + cur.execute("SELECT id FROM style_directions WHERE name ILIKE %s", (name.strip(),)) row = cur.fetchone() if row: cur.execute( - """INSERT INTO exercise_training_styles (exercise_id, style_direction_id) - VALUES (%s, %s) ON CONFLICT DO NOTHING""", - (exercise_id, row['id']) + """INSERT INTO exercise_style_directions (exercise_id, style_direction_id, is_primary) + VALUES (%s, %s, %s) ON CONFLICT (exercise_id, style_direction_id) DO NOTHING""", + (exercise_id, row["id"], idx == 0), ) + else: + logger.warning("Stilrichtung '%s' nicht im Katalog gefunden", name) # Target Groups for name in mapped.get("target_group_names", []): @@ -557,16 +582,7 @@ def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict): (exercise_id, row['id']) ) - # Skills - for name in mapped.get("skill_names", []): - cur.execute("SELECT id FROM skills WHERE name ILIKE %s", (name,)) - row = cur.fetchone() - if row: - cur.execute( - """INSERT INTO exercise_skills (exercise_id, skill_id) - VALUES (%s, %s) ON CONFLICT DO NOTHING""", - (exercise_id, row['id']) - ) + # Fähigkeiten: nur über _assign_exercise_skills (Levels + is_primary), nicht doppelt hier conn.commit() @@ -574,10 +590,9 @@ def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict): def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list): """Weist Skill-Zuordnungen mit Levels einer Übung zu.""" for assignment in skill_assignments: - cur.execute("SELECT id FROM skills WHERE name ILIKE %s", (assignment["skill_name"],)) - row = cur.fetchone() - if not row: - logger.warning("Skill '%s' nicht im Katalog gefunden", assignment["skill_name"]) + sid = _find_skill_id_by_label(cur, assignment.get("skill_name") or "") + if not sid: + logger.warning("Skill '%s' nicht im Katalog gefunden", assignment.get("skill_name")) continue cur.execute( """INSERT INTO exercise_skills @@ -587,7 +602,8 @@ def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list target_level = EXCLUDED.target_level, is_primary = EXCLUDED.is_primary""", ( - exercise_id, row['id'], + exercise_id, + sid, assignment.get("target_level"), assignment.get("required_level"), assignment.get("intensity"), diff --git a/backend/smw_mapper.py b/backend/smw_mapper.py index 61cdf4a..9115061 100644 --- a/backend/smw_mapper.py +++ b/backend/smw_mapper.py @@ -51,6 +51,7 @@ EXERCISE_PROPERTY_MAP = { "Zielgruppe": "target_group_names", "Altersgruppe": "age_group_names", "Trainingsmethode": "method_names", # Wiki-Seitenname z.B. "Plyometrisches_Training" + "Stilrichtung": "style_names", # z. B. Shotokan; siehe EXERCISE_PROPERTY_SYNONYM_TO_TARGET # Fähigkeiten (als Namen + Level) "PrimaryCapability": "skill_names", # Skill-Namen (können mehrere sein) "CapabilityLevel": "skill_levels_raw", # Integer-Levels ["3", "2"] → aufbau, grundlagen @@ -171,6 +172,49 @@ def map_capability_level(level_str: str) -> str: return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis") +# ------------------------------------------------------------------ # +# SMW-Property-Label → Mapper-Zielfeld (Werte wie in EXERCISE_PROPERTY_MAP) # +# browse_subject liefert Anzeigenamen, nicht zwingend interne Property-IDs. # +# ------------------------------------------------------------------ # + +def _norm_prop_synonym(name: str) -> str: + s = (name or "").strip().lower() + for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): + s = s.replace(a, b) + return "".join(c for c in s if c.isalnum()) + + +# alternative Labels → Zielfeld-Name (gleiche Strings wie Werte in EXERCISE_PROPERTY_MAP) +EXERCISE_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = { + "primarycapability": "skill_names", + "hauptfaehigkeit": "skill_names", + "primaerefaehigkeit": "skill_names", + "hauptfhigkeit": "skill_names", + "hauptfahigkeit": "skill_names", + "capabilitylevel": "skill_levels_raw", + "faehigkeitsstufe": "skill_levels_raw", + "faehigkeitslevel": "skill_levels_raw", + "capabilitystufe": "skill_levels_raw", + "stilrichtung": "style_names", + "trainingsstilrichtung": "style_names", +} + + +def _exercise_property_target(prop_name: str) -> str | None: + """Ermittelt Zielfeld für eine SMW-Property; None = unbekannt.""" + if prop_name in EXERCISE_PROPERTY_MAP: + return EXERCISE_PROPERTY_MAP[prop_name] + n = _norm_prop_synonym(prop_name) + if n in EXERCISE_PROPERTY_SYNONYM_TO_TARGET: + return EXERCISE_PROPERTY_SYNONYM_TO_TARGET[n] + nlow = (prop_name or "").lower() + if "primary" in nlow and "capab" in nlow and "level" not in nlow: + return "skill_names" + if "capab" in nlow and "level" in nlow: + return "skill_levels_raw" + return None + + # ------------------------------------------------------------------ # # Haupt-Mapping-Funktion # # ------------------------------------------------------------------ # @@ -206,6 +250,7 @@ def map_wiki_to_exercise( "age_group_names": [], "skill_names": [], "skill_levels_raw": [], # Integer-Strings ["3", "2"] + "style_names": [], "method_names": [], # Equipment "equipment": [], @@ -217,7 +262,7 @@ def map_wiki_to_exercise( if not values: continue - target = EXERCISE_PROPERTY_MAP.get(prop_name) + target = _exercise_property_target(prop_name) if not target: continue @@ -259,6 +304,9 @@ def map_wiki_to_exercise( elif target == "method_names": mapped["method_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])] + elif target == "style_names": + mapped["style_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])] + elif target == "skill_names": mapped["skill_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])] diff --git a/frontend/src/components/SearchableSelect.jsx b/frontend/src/components/SearchableSelect.jsx new file mode 100644 index 0000000..5c30227 --- /dev/null +++ b/frontend/src/components/SearchableSelect.jsx @@ -0,0 +1,67 @@ +import React, { useMemo, useState } from 'react' + +/** + * Kombination aus Filter-Eingabe und Auswahl: Liste wird per Tippen eingeschränkt. + * Die aktuelle Auswahl bleibt sichtbar, auch wenn sie durch den Filter ausgeblendet würde. + */ +export default function SearchableSelect({ + value, + onChange, + options = [], + idKey = 'id', + labelKey = 'label', + allLabel = 'Alle', + filterPlaceholder = 'Tippen zum Filtern…', + selectClassName = 'form-input', + className = '', +}) { + const [q, setQ] = useState('') + + const rows = useMemo(() => { + return options.map((o) => ({ + id: o[idKey], + label: typeof o[labelKey] === 'function' ? o[labelKey](o) : String(o[labelKey] ?? ''), + })) + }, [options, idKey, labelKey]) + + const filtered = useMemo(() => { + const t = q.trim().toLowerCase() + if (!t) return rows + return rows.filter((r) => r.label.toLowerCase().includes(t) || String(r.id).includes(t)) + }, [rows, q]) + + const withSelection = useMemo(() => { + if (value === '' || value == null) return filtered + const sel = String(value) + if (filtered.some((r) => String(r.id) === sel)) return filtered + const found = rows.find((r) => String(r.id) === sel) + if (found) return [found, ...filtered] + return filtered + }, [filtered, rows, value]) + + return ( +
+ setQ(e.target.value)} + autoComplete="off" + aria-label="Filter" + /> + +
+ ) +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 418b45e..e9c3834 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' +import SearchableSelect from '../components/SearchableSelect' const PAGE_SIZE = 100 const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) @@ -46,6 +47,52 @@ function ExercisesListPage() { return () => clearTimeout(t) }, [aiSearchInput]) + const focusOptions = useMemo( + () => + catalogs.focusAreas.map((fa) => ({ + id: fa.id, + label: `${fa.icon || ''} ${fa.name || ''}`.trim(), + })), + [catalogs.focusAreas] + ) + const styleOptions = useMemo( + () => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })), + [catalogs.styleDirections] + ) + const trainingTypeOptions = useMemo( + () => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })), + [catalogs.trainingTypes] + ) + const targetGroupOptions = useMemo( + () => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })), + [catalogs.targetGroups] + ) + const skillOptions = useMemo( + () => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })), + [catalogs.skills] + ) + const levelFilterOptions = useMemo( + () => LEVEL_FILTER_OPTS.map((o) => ({ id: o.level, label: o.label })), + [] + ) + const visibilityOptions = useMemo( + () => [ + { id: 'private', label: 'Privat' }, + { id: 'club', label: 'Verein' }, + { id: 'official', label: 'Offiziell' }, + ], + [] + ) + const statusOptions = useMemo( + () => [ + { id: 'draft', label: 'Entwurf' }, + { id: 'in_review', label: 'In Prüfung' }, + { id: 'approved', label: 'Freigegeben' }, + { id: 'archived', label: 'Archiviert' }, + ], + [] + ) + const queryBase = useMemo(() => { const q = {} const n = (v) => (v === '' || v == null ? undefined : Number(v)) @@ -205,135 +252,93 @@ function ExercisesListPage() {
- + onChange={(v) => setFilters({ ...filters, focus_area: v })} + options={focusOptions} + allLabel="Alle Fokusbereiche" + filterPlaceholder="Fokus filtern…" + />
- + onChange={(v) => setFilters({ ...filters, style_direction_id: v })} + options={styleOptions} + allLabel="Alle Stilrichtungen" + filterPlaceholder="Stilrichtung filtern…" + />
- + onChange={(v) => setFilters({ ...filters, training_type_id: v })} + options={trainingTypeOptions} + allLabel="Alle Trainingsstile" + filterPlaceholder="Trainingsstil filtern…" + />
- + onChange={(v) => setFilters({ ...filters, target_group_id: v })} + options={targetGroupOptions} + allLabel="Alle Zielgruppen" + filterPlaceholder="Zielgruppe filtern…" + />
- + onChange={(v) => setFilters({ ...filters, skill_id: v })} + options={skillOptions} + allLabel="Alle Fähigkeiten" + filterPlaceholder="Fähigkeit filtern…" + />
- + onChange={(v) => setFilters({ ...filters, skill_min_level: v })} + options={levelFilterOptions} + allLabel="egal (min)" + filterPlaceholder="Stufe suchen…" + />
- + onChange={(v) => setFilters({ ...filters, skill_max_level: v })} + options={levelFilterOptions} + allLabel="egal (max)" + filterPlaceholder="Stufe suchen…" + />
- + onChange={(v) => setFilters({ ...filters, visibility: v })} + options={visibilityOptions} + allLabel="Alle" + filterPlaceholder="Filtern…" + />
- + onChange={(v) => setFilters({ ...filters, status: v })} + options={statusOptions} + allLabel="Alle" + filterPlaceholder="Filtern…" + />