/** * KI-gestützte Schnellanlage / Vorschau (Planung / ExercisePickerModal). */ import { stripHtmlToText } from './htmlUtils' import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../constants/skillLevels' import { EXERCISE_SKILL_INTENSITY_DEFAULT, normalizeExerciseSkillIntensity, formatExerciseSkillIntensityLabel, } from '../constants/exerciseSkillIntensity' export const INSTRUCTION_AI_FIELD_DEFS = [ { key: 'goal', label: 'Ziel' }, { key: 'execution', label: 'Durchführung' }, { key: 'preparation', label: 'Vorbereitung / Aufbau' }, { key: 'trainer_notes', label: 'Hinweise für Trainer' }, ] function escapeHtmlText(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** Suchstring → Titel + Skizze für Schnellanlage (erste Zeile / Satz als Titel). */ export function parseSearchQueryForQuickCreate(searchText) { const raw = String(searchText || '').trim() if (!raw) return { title: '', sketch: '' } const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean) if (lines.length >= 2) { return { title: lines[0].slice(0, 300), sketch: lines.slice(1).join('\n'), } } const single = lines[0] || raw if (single.length <= 80) { return { title: single.slice(0, 300), sketch: single } } const sentenceMatch = single.match(/^(.{10,120}?[.!?;:])\s+(.+)$/s) if (sentenceMatch) { return { title: sentenceMatch[1].trim().slice(0, 300), sketch: single, } } const words = single.split(/\s+/).filter(Boolean) const titleWordCount = Math.min(8, Math.max(3, Math.ceil(words.length / 3))) const title = words.slice(0, titleWordCount).join(' ').slice(0, 120) return { title, sketch: single } } /** Plaintext → Absätze für RichTextEditor / API. */ export function aiPlainTextToMinimalHtml(text) { const raw = String(text || '').trim() if (!raw) return '' const parts = raw.split(/\n+/).map((p) => p.trim()).filter(Boolean) const paras = parts.length ? parts : [raw] return paras.map((p) => `
${escapeHtmlText(p)}
`).join('') } export function normalizeAiSkillRowFromApi(sug) { const sid = Number(sug?.skill_id) if (!Number.isFinite(sid) || sid < 1) return null return { skill_id: sid, intensity: normalizeExerciseSkillIntensity(sug.intensity) || EXERCISE_SKILL_INTENSITY_DEFAULT, required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen', target_level: normalizeSkillLevelSlug(sug.target_level) || normalizeSkillLevelSlug(sug.required_level) || 'grundlagen', is_primary: !!sug.is_primary, ai_suggested: true, } } /** Vorschau für Schnellanlage: Kurzfassung + Anleitung + Fähigkeiten. */ export function buildQuickCreateAiPreview(apiRes, { sketchPlain = '' } = {}) { const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) const snapshotInstructions = { goal: sketchHtml, execution: '', preparation: '', trainer_notes: '', } let summaryAfterHtml = null let summaryAfterPlain = '' if (apiRes?.summary?.text) { summaryAfterPlain = String(apiRes.summary.text).trim() if (summaryAfterPlain) { summaryAfterHtml = aiPlainTextToMinimalHtml(apiRes.summary.text) } } const skillChoices = [] if (Array.isArray(apiRes?.skills)) { for (const sug of apiRes.skills) { const after = normalizeAiSkillRowFromApi(sug) if (!after) continue skillChoices.push({ key: String(after.skill_id), skill_id: after.skill_id, kind: 'add', before: null, after, include: true, }) } } const instructionChoices = [] const fields = apiRes?.instructions?.fields || {} for (const def of INSTRUCTION_AI_FIELD_DEFS) { const afterHtml = fields[def.key] if (!afterHtml || !String(afterHtml).trim()) continue const beforeHtml = snapshotInstructions[def.key] || '' instructionChoices.push({ key: def.key, field: def.key, label: def.label, beforePlain: stripHtmlToText(beforeHtml).trim(), afterHtml: String(afterHtml), afterPlain: stripHtmlToText(afterHtml).trim(), include: true, }) } const hasSummaryProposal = !!summaryAfterHtml const hasSkillChoices = skillChoices.length > 0 const hasInstructionChoices = instructionChoices.length > 0 return { mode: 'quick_create', applySummary: hasSummaryProposal, summaryBeforePlain: stripHtmlToText(sketchPlain).trim(), summaryAfterPlain, summaryAfterHtml, skillChoices, instructionChoices, hasSummaryProposal, hasSkillChoices, hasInstructionChoices, summaryRequested: true, skillsRequested: true, instructionsRequested: true, } } export function describeAiSkillRowForPreview(row, skillsCatalog) { if (!row) return '' const sk = (skillsCatalog || []).find((x) => Number(x.id) === Number(row.skill_id)) const name = sk?.name || `Fähigkeit #${row.skill_id}` const int = formatExerciseSkillIntensityLabel(row.intensity) const from = formatSkillLevelSlug(row.required_level) || '—' const to = formatSkillLevelSlug(row.target_level) || '—' const prim = row.is_primary ? ' · Primär' : '' return `${name}: Intensität ${int}, Niveau ${from} → ${to}${prim}` } /** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */ /** Rohes API-ai_suggestion oder bereits bearbeiteter Entwurf → Preview-Entwurf. */ export function ensureQuickCreateDraftFromAiSuggestion( aiSuggestion, { title = '', focusAreaId = '', sketchPlain = '' } = {}, ) { if (!aiSuggestion || typeof aiSuggestion !== 'object') return null if (aiSuggestion.instructionFields) return aiSuggestion const preview = buildQuickCreateAiPreview(aiSuggestion, { sketchPlain }) if ( !preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices ) { return null } return aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) } export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) { const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) const instructionFields = { goal: sketchHtml, execution: '', preparation: '', trainer_notes: '', } for (const c of preview?.instructionChoices || []) { if (c.field && c.include !== false && c.afterHtml) { instructionFields[c.field] = String(c.afterHtml) } } return { title: (title || '').trim(), focusAreaId: focusAreaId != null && focusAreaId !== '' ? String(focusAreaId) : '', summaryHtml: preview?.applySummary !== false && preview?.summaryAfterHtml ? String(preview.summaryAfterHtml) : '', instructionFields, skillChoices: (preview?.skillChoices || []).map((c) => ({ ...c })), } } /** * createExercise-Payload aus bearbeitetem Entwurf. * @throws {Error} */ export function buildQuickCreateExercisePayloadFromDraft(draft) { const title = (draft?.title || '').trim() if (title.length < 3) { throw new Error('Titel: mindestens 3 Zeichen.') } const fid = Number(draft?.focusAreaId) if (!Number.isFinite(fid) || fid < 1) { throw new Error('Fokusbereich ist erforderlich.') } const fields = draft?.instructionFields || {} let goal = (fields.goal || '').trim() let execution = (fields.execution || '').trim() const prep = (fields.preparation || '').trim() || null const trainerNotes = (fields.trainer_notes || '').trim() || null if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) { throw new Error('Mindestens Ziel oder Durchführung ausfüllen.') } if (!stripHtmlToText(goal).trim()) goal = execution if (!stripHtmlToText(execution).trim()) execution = goal let summary = (draft?.summaryHtml || '').trim() || null if (summary && !stripHtmlToText(summary).trim()) summary = null const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after) return { title, summary, goal, execution, preparation: prep, trainer_notes: trainerNotes, visibility: 'private', status: 'draft', equipment: [], focus_areas_multi: [{ focus_area_id: fid, is_primary: true }], training_styles_multi: [], training_types_multi: [], target_groups_multi: [], age_groups: [], skills, club_id: null, } } /** * createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus). * @throws {Error} */ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) { const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) const fieldMap = {} for (const c of preview?.instructionChoices || []) { if (c.include && c.afterHtml) fieldMap[c.field] = c.afterHtml } let goal = (fieldMap.goal || '').trim() || sketchHtml let execution = (fieldMap.execution || '').trim() const prep = (fieldMap.preparation || '').trim() || null const trainerNotes = (fieldMap.trainer_notes || '').trim() || null if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) { throw new Error('Mindestens Ziel oder Durchführung muss übernommen werden.') } if (!stripHtmlToText(goal).trim()) goal = execution if (!stripHtmlToText(execution).trim()) execution = goal let summary = null if (preview?.applySummary && preview?.summaryAfterHtml) { summary = preview.summaryAfterHtml } const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after) const fid = Number(focusAreaId) if (!Number.isFinite(fid) || fid < 1) { throw new Error('Fokusbereich ist erforderlich.') } return { title: (title || '').trim(), summary, goal, execution, preparation: prep, trainer_notes: trainerNotes, visibility: 'private', status: 'draft', equipment: [], focus_areas_multi: [{ focus_area_id: fid, is_primary: true }], training_styles_multi: [], training_types_multi: [], target_groups_multi: [], age_groups: [], skills, club_id: null, } } /** @deprecated Direkt aus API — nutze Preview + buildQuickCreateExercisePayloadFromPreview */ export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) { const preview = buildQuickCreateAiPreview(apiRes, { sketchPlain }) return buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) }