Passed
Push — master ( 15a517...3e63d2 )
by
unknown
16:13 queued 07:43
created

clean_label()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nop 1
dl 0
loc 12
rs 10
c 0
b 0
f 0
nc 1
1
<?php
2
/* For licensing terms, see /license.txt */
3
4
declare(strict_types=1);
5
6
use Chamilo\CoreBundle\Framework\Container;
7
use Chamilo\CourseBundle\Entity\CQuiz;
8
9
$cidReset = true;
10
require_once __DIR__ . '/../inc/global.inc.php';
11
12
api_protect_admin_script(true);
13
14
$this_section = SECTION_PLATFORM_ADMIN;
15
16
$interbreadcrumb[] = ['url' => 'index.php', 'name' => get_lang('Administration')];
17
18
$toolName = get_lang('Export all test results');
19
20
// -----------------------------------------------------------------------------
21
// Helpers
22
// -----------------------------------------------------------------------------
23
/**
24
 * Sends a JSON response and exits.
25
 */
26
function send_json(array $payload, int $statusCode = 200): void
27
{
28
    http_response_code($statusCode);
29
    header('Content-Type: application/json; charset=utf-8');
30
    echo json_encode($payload, JSON_UNESCAPED_UNICODE);
31
    exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
32
}
33
34
/**
35
 * Normalize a label for Select/Select2:
36
 * - Decode HTML entities (e.g., &eacute; → é)
37
 * - Strip HTML tags (safety)
38
 * - Collapse newlines/tabs/spaces into single spaces
39
 * - Trim edges
40
 */
41
function clean_label(string $label): string
42
{
43
    // Decode entities to real UTF-8 characters
44
    $decoded = html_entity_decode($label, ENT_QUOTES | ENT_HTML5, 'UTF-8');
45
46
    // Remove any HTML tags just in case
47
    $decoded = strip_tags($decoded);
48
49
    // Replace CR/LF/TAB by single spaces and collapse multiple spaces
50
    $decoded = preg_replace('/\s+/u', ' ', $decoded ?? '');
51
52
    return trim($decoded ?? '');
53
}
54
55
/**
56
 * Send a minimal HTML page to the iframe that forwards a message to the parent.
57
 * This avoids breaking binary downloads while still surfacing errors to the user.
58
 */
59
function iframe_post_message(string $message, bool $ok = false): void
60
{
61
    // Keep it extremely small; no BOM; no extra whitespace.
62
    echo '<!doctype html><meta charset="utf-8"><script>try{parent.postMessage({type:"export-status",ok:'
63
        . ($ok ? 'true' : 'false')
64
        . ',message:"' . addslashes($message) . '"},"*");}catch(e){}</script>';
65
    exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
66
}
67
68
// -----------------------------------------------------------------------------
69
// Services / repositories
70
// -----------------------------------------------------------------------------
71
$user            = api_get_user_entity(api_get_user_id());
72
$sessionRepo     = Container::getSessionRepository();
73
$courseRepo      = Container::getCourseRepository();
74
$cQuizRepo       = Container::getQuizRepository();
75
$isPlatformAdmin = api_is_platform_admin();
76
77
// -----------------------------------------------------------------------------
78
// AJAX endpoints (no full-page reload).
79
// -----------------------------------------------------------------------------
80
if (isset($_GET['ajax'])) {
81
    if (!api_is_allowed_to_edit(null, true)) {
82
        send_json(['ok' => false, 'message' => get_lang('NotAllowed')], 403);
83
    }
84
85
    $ajax = (string) $_GET['ajax'];
86
87
    try {
88
        if ($ajax === 'courses') {
89
            $sessionId = isset($_GET['session_id']) ? (int) $_GET['session_id'] : 0;
90
            $options = [];
91
92
            if ($sessionId > 0) {
93
                $session = $sessionRepo->find($sessionId);
94
                if ($session) {
95
                    foreach ($session->getCourses() as $sessionCourse) {
96
                        $course = $sessionCourse->getCourse();
97
                        $options[] = [
98
                            'id'   => (int) $course->getId(),
99
                            'text' => clean_label((string) $course->getTitle()), // decode + normalize
100
                        ];
101
                    }
102
                }
103
            } else {
104
                if ($isPlatformAdmin) {
105
                    if (class_exists('CourseManager') && method_exists('CourseManager', 'get_courses_list')) {
106
                        $all = CourseManager::get_courses_list(0, 0, 'title');
107
                        foreach ($all as $c) {
108
                            $options[] = [
109
                                'id'   => (int) $c['real_id'],
110
                                'text' => clean_label((string) $c['title']),
111
                            ];
112
                        }
113
                    } else {
114
                        foreach ($courseRepo->findAll() as $courseEntity) {
115
                            $options[] = [
116
                                'id'   => (int) $courseEntity->getId(),
117
                                'text' => clean_label((string) $courseEntity->getTitle()),
118
                            ];
119
                        }
120
                    }
121
                } else {
122
                    if (class_exists('CourseManager') && method_exists('CourseManager', 'get_course_list_of_user_as_course_admin')) {
123
                        $mine = CourseManager::get_course_list_of_user_as_course_admin(api_get_user_id());
124
                        foreach ($mine as $c) {
125
                            $options[] = [
126
                                'id'   => (int) $c['real_id'],
127
                                'text' => clean_label((string) $c['title']),
128
                            ];
129
                        }
130
                    }
131
                }
132
            }
133
134
            usort($options, static fn($a, $b) => strcasecmp($a['text'], $b['text']));
135
136
            send_json(['ok' => true, 'options' => $options]);
137
        }
138
139
        if ($ajax === 'exercises') {
140
            $sessionId = isset($_GET['session_id']) ? (int) $_GET['session_id'] : 0;
141
            $courseId  = isset($_GET['selected_course']) ? (int) $_GET['selected_course'] : 0;
142
143
            if ($courseId <= 0) {
144
                send_json(['ok' => true, 'options' => []]);
145
            }
146
147
            $course  = $courseRepo->find($courseId);
148
            $session = $sessionId > 0 ? $sessionRepo->find($sessionId) : null;
149
150
            if (!$course) {
151
                send_json(['ok' => true, 'options' => []]);
152
            }
153
154
            $qb     = $cQuizRepo->findAllByCourse($course, $session);
155
            $exList = $qb->getQuery()->getResult();
156
157
            $options = [];
158
            foreach ($exList as $ex) {
159
                /** @var \Chamilo\CourseBundle\Entity\CQuiz $ex */
160
                $options[] = [
161
                    'id'   => (int) $ex->getIid(),
162
                    'text' => clean_label((string) $ex->getTitle()), // decode + normalize
163
                ];
164
            }
165
166
            usort($options, static fn($a, $b) => strcasecmp($a['text'], $b['text']));
167
168
            send_json(['ok' => true, 'options' => $options]);
169
        }
170
171
        send_json(['ok' => false, 'message' => 'Unknown action'], 400);
172
    } catch (Throwable $e) {
173
        error_log('[export_exercise_results AJAX] '.$e->getMessage());
174
        send_json(['ok' => false, 'message' => 'Server error'], 500);
175
    }
176
}
177
178
// -----------------------------------------------------------------------------
179
// Request parameters (for initial render / submit)
180
// -----------------------------------------------------------------------------
181
$sessionId  = isset($_REQUEST['session_id']) ? (int) $_REQUEST['session_id'] : 0;
182
$courseId   = isset($_REQUEST['selected_course']) ? (int) $_REQUEST['selected_course'] : 0;
183
$exerciseId = isset($_REQUEST['exerciseId']) ? (int) $_REQUEST['exerciseId'] : 0;
184
185
// -----------------------------------------------------------------------------
186
// Initial lists (first paint) – apply same normalization to avoid mismatches
187
// -----------------------------------------------------------------------------
188
$sessionSelectList = [0 => get_lang('Select')];
189
try {
190
    $sessionList = $isPlatformAdmin
191
        ? $sessionRepo->findAll()
192
        : $sessionRepo->getSessionsByUser($user, api_get_url_entity())->getQuery()->getResult();
193
194
    foreach ($sessionList as $s) {
195
        $sessionSelectList[$s->getId()] = clean_label((string) $s->getTitle()); // decode + normalize
196
    }
197
} catch (Exception $e) {
198
    error_log('[export_exercise_results] Error loading sessions: '.$e->getMessage());
199
    $sessionSelectList = [0 => get_lang('Error loading sessions')];
200
}
201
202
$courseSelectList = [0 => get_lang('Select')];
203
try {
204
    if ($sessionId > 0) {
205
        $session = $sessionRepo->find($sessionId);
206
        if ($session) {
207
            foreach ($session->getCourses() as $sc) {
208
                $course = $sc->getCourse();
209
                $courseSelectList[(int) $course->getId()] = clean_label((string) $course->getTitle()); // decode
210
            }
211
        }
212
    } else {
213
        if ($isPlatformAdmin) {
214
            if (class_exists('CourseManager') && method_exists('CourseManager', 'get_courses_list')) {
215
                $all = CourseManager::get_courses_list(0, 0, 'title');
216
                foreach ($all as $c) {
217
                    $courseSelectList[(int) $c['real_id']] = clean_label((string) $c['title']); // decode
218
                }
219
            } else {
220
                foreach ($courseRepo->findAll() as $c) {
221
                    $courseSelectList[(int) $c->getId()] = clean_label((string) $c->getTitle()); // decode
222
                }
223
            }
224
        } else {
225
            if (class_exists('CourseManager') && method_exists('CourseManager', 'get_course_list_of_user_as_course_admin')) {
226
                $mine = CourseManager::get_course_list_of_user_as_course_admin(api_get_user_id());
227
                foreach ($mine as $c) {
228
                    $courseSelectList[(int) $c['real_id']] = clean_label((string) $c['title']); // decode
229
                }
230
            }
231
        }
232
    }
233
} catch (Exception $e) {
234
    error_log('[export_exercise_results] Error loading courses: '.$e->getMessage());
235
    $courseSelectList = [0 => get_lang('Error loading courses')];
236
}
237
238
$exerciseSelectList = [0 => get_lang('Select')];
239
if ($courseId > 0) {
240
    try {
241
        $courseEntity  = $courseRepo->find($courseId);
242
        $sessionEntity = $sessionId > 0 ? $sessionRepo->find($sessionId) : null;
243
244
        if ($courseEntity) {
245
            $qb = $cQuizRepo->findAllByCourse($courseEntity, $sessionEntity);
246
            $exerciseList = $qb->getQuery()->getResult();
247
248
            foreach ($exerciseList as $ex) {
249
                /** @var CQuiz $ex */
250
                $exerciseSelectList[(int) $ex->getIid()] = clean_label((string) $ex->getTitle()); // decode
251
            }
252
        }
253
    } catch (Exception $e) {
254
        error_log('[export_exercise_results] Error loading exercises: '.$e->getMessage());
255
        $exerciseSelectList = [0 => get_lang('Error loading tests')];
256
    }
257
}
258
259
// -----------------------------------------------------------------------------
260
// Head extras: visual polish + AJAX logic + overlay + iframe messaging
261
// -----------------------------------------------------------------------------
262
$htmlHeadXtra[] = "
263
<style>
264
/* Select2 polish for consistent height */
265
.select2-selection, .select2-selection__rendered { height: 38px !important; line-height: 38px !important; }
266
.select2-container .select2-selection--single .select2-selection__arrow { height: 38px !important; }
267
268
/* Tiny loading badges near labels */
269
.badge { display: inline-block; font-size: 12px; border-radius: 12px; padding: 4px 8px; background: #eef2ff; color: #374151; margin-left: 8px; }
270
.hidden { display: none !important; }
271
272
/* Export overlay shown only while exporting */
273
#export-overlay {
274
  position: fixed; inset: 0; z-index: 9999;
275
  background: rgba(15,23,42,0.55); backdrop-filter: blur(2px);
276
  display: none; align-items: center; justify-content: center;
277
}
278
#export-overlay .box {
279
  background: #fff; border-radius: 14px; padding: 22px 28px; min-width: 280px;
280
  box-shadow: 0 10px 30px rgba(0,0,0,.15); text-align: center;
281
}
282
#export-overlay .spinner {
283
  width: 36px; height: 36px; margin: 0 auto 12px auto; border-radius: 50%;
284
  border: 4px solid #e5e7eb; border-top-color: #4f46e5; animation: spin 0.9s linear infinite;
285
}
286
#export-overlay .label { font-weight: 600; color: #111827; margin-bottom: 2px; }
287
#export-overlay .hint  { font-size: 13px; color: #6b7280; }
288
@keyframes spin { to { transform: rotate(360deg);} }
289
</style>
290
<script>
291
$(function () {
292
    // Initialize Select2 if available
293
    if ($.fn && $.fn.select2) {
294
        $('.select2').select2({ width: '100%', placeholder: '".addslashes(get_lang('Select'))."', allowClear: true });
295
    }
296
297
    const \$session   = $('#session_id');
298
    const \$course    = $('#selected_course');
299
    const \$exercise  = $('#exerciseId');
300
301
    const \$loadingCourses   = $('<span id=\"loading-courses\" class=\"badge hidden\">".addslashes(get_lang('Loading'))."...</span>');
302
    const \$loadingExercises = $('<span id=\"loading-exercises\" class=\"badge hidden\">".addslashes(get_lang('Loading'))."...</span>');
303
304
    // Add small loading badges next to selects
305
    \$session.closest('.form-group').find('label').append($('<span/>'));
306
    \$course.closest('.form-group').find('label').append(\$loadingCourses);
307
    \$exercise.closest('.form-group').find('label').append(\$loadingExercises);
308
309
    // Utility: refill a select with [{id, text}]
310
    function refillSelect(\$select, items, opts) {
311
        const keepValue = opts && opts.keepValue === true;
312
        const prev = keepValue ? \$select.val() : null;
313
314
        // Wipe options
315
        \$select.empty();
316
317
        // Placeholder first option
318
        const placeholder = opts && opts.placeholder ? opts.placeholder : '".addslashes(get_lang('Select'))."';
319
        \$select.append(new Option(placeholder, '0', false, false));
320
321
        // Add items
322
        if (Array.isArray(items)) {
323
            for (const it of items) {
324
                \$select.append(new Option(it.text, it.id, false, false));
325
            }
326
        }
327
328
        // Re-apply Select2
329
        if (\$select.data('select2')) {
330
            \$select.trigger('change.select2');
331
        }
332
333
        // Try to preserve previous value if still exists
334
        if (keepValue && prev && \$select.find('option[value=\"'+prev+'\"]').length) {
335
            \$select.val(prev).trigger('change');
336
        } else {
337
            \$select.val('0').trigger('change');
338
        }
339
    }
340
341
    // Disable a select with friendly UX
342
    function disableSelect(\$select, disabled) {
343
        \$select.prop('disabled', !!disabled);
344
        if (\$select.data('select2')) { \$select.select2(); }
345
    }
346
347
    // Fetch courses via AJAX
348
    function loadCourses(sessionId) {
349
        \$loadingCourses.removeClass('hidden');
350
        disableSelect(\$course, true);
351
        disableSelect(\$exercise, true);
352
        refillSelect(\$exercise, [], { placeholder: '".addslashes(get_lang('First select a course'))."' });
353
354
        $.getJSON('".addslashes(api_get_self())."', { ajax: 'courses', session_id: sessionId })
355
            .done(function(resp) {
356
                if (resp && resp.ok) {
357
                    refillSelect(\$course, resp.options || [], { placeholder: '".addslashes(get_lang('Select'))."' });
358
                    disableSelect(\$course, false);
359
                } else {
360
                    refillSelect(\$course, [], { placeholder: '".addslashes(get_lang('Error loading courses'))."' });
361
                }
362
            })
363
            .fail(function() {
364
                refillSelect(\$course, [], { placeholder: '".addslashes(get_lang('Error loading courses'))."' });
365
            })
366
            .always(function() {
367
                \$loadingCourses.addClass('hidden');
368
            });
369
    }
370
371
    // Fetch exercises via AJAX
372
    function loadExercises(sessionId, courseId) {
373
        \$loadingExercises.removeClass('hidden');
374
        disableSelect(\$exercise, true);
375
376
        $.getJSON('".addslashes(api_get_self())."', { ajax: 'exercises', session_id: sessionId, selected_course: courseId })
377
            .done(function(resp) {
378
                if (resp && resp.ok) {
379
                    refillSelect(\$exercise, resp.options || [], { placeholder: '".addslashes(get_lang('Select'))."'} );
380
                    disableSelect(\$exercise, false);
381
                } else {
382
                    refillSelect(\$exercise, [], { placeholder: '".addslashes(get_lang('Error loading tests'))."' });
383
                }
384
            })
385
            .fail(function() {
386
                refillSelect(\$exercise, [], { placeholder: '".addslashes(get_lang('Error loading tests'))."' });
387
            })
388
            .always(function() {
389
                \$loadingExercises.addClass('hidden');
390
            });
391
    }
392
393
    // On session change → load courses and reset exercises
394
    \$session.on('change', function() {
395
        const sId = parseInt(\$session.val() || '0', 10) || 0;
396
        loadCourses(sId);
397
    });
398
399
    // On course change → load exercises
400
    \$course.on('change', function() {
401
        const sId = parseInt(\$session.val() || '0', 10) || 0;
402
        const cId = parseInt(\$course.val() || '0', 10) || 0;
403
        if (cId > 0) {
404
            loadExercises(sId, cId);
405
        } else {
406
            refillSelect(\$exercise, [], { placeholder: '".addslashes(get_lang('First select a course'))."' });
407
            disableSelect(\$exercise, true);
408
        }
409
    });
410
411
    // --- Export overlay + iframe target (no page reload) ---
412
    const \$overlay = $(
413
      '<div id=\"export-overlay\">' +
414
        '<div class=\"box\">' +
415
          '<div class=\"spinner\"></div>' +
416
          '<div class=\"label\">".addslashes(get_lang('Please wait this could take a while'))."</div>' +
417
          '<div class=\"hint\">".addslashes(get_lang('Generating file, do not close this tab'))."</div>' +
418
        '</div>' +
419
      '</div>'
420
    );
421
    $('body').append(\$overlay);
422
423
    const \$form = $('form[name=\"export_all_results_form\"]');
424
    \$form.attr('target', 'export_iframe'); // Send to hidden iframe to keep page intact
425
426
    // Ensure hidden token input exists
427
    if ($('input[name=\"download_token\"]').length === 0) {
428
        \$form.append('<input type=\"hidden\" name=\"download_token\" value=\"\" />');
429
    }
430
    const \$tokenInput = $('input[name=\"download_token\"]');
431
432
    // Cookie helpers
433
    function getCookie(name) {
434
        const value = ('; ' + document.cookie).split('; ' + name + '=');
435
        if (value.length === 2) return value.pop().split(';').shift();
436
        return null;
437
    }
438
    function deleteCookie(name) {
439
        document.cookie = name + '=; Max-Age=0; path=/';
440
    }
441
442
    // Listen for error/info messages coming from the iframe (server-side postMessage)
443
    window.addEventListener('message', function(ev){
444
        if (!ev || !ev.data) return;
445
        if (ev.data.type === 'export-status' && ev.data.message) {
446
            // Ensure overlay is closed and button is re-enabled even if no cookie arrives.
447
            $('#export-overlay').hide();
448
            \$form.find('button[type=\"submit\"]').prop('disabled', false);
449
            if (window.__exportCookieInterval) { clearInterval(window.__exportCookieInterval); window.__exportCookieInterval = null; }
450
            window.__exportSafetyTimer && clearTimeout(window.__exportSafetyTimer);
451
452
            alert(ev.data.message);
453
        }
454
    });
455
456
    // Show overlay on submit + disable button and start cookie polling
457
    \$form.on('submit', function() {
458
        // Generate a unique token per export
459
        const token = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
460
        \$tokenInput.val(token);
461
462
        $('#export-overlay').css('display','flex');
463
        $(this).find('button[type=\"submit\"]').prop('disabled', true);
464
465
        // Poll for server-set cookie to know the download has started
466
        window.__exportCookieInterval && clearInterval(window.__exportCookieInterval);
467
        window.__exportCookieInterval = setInterval(function() {
468
            var c = getCookie('download_token');
469
            if (c === token) {
470
                // Hide overlay and cleanup once cookie matches
471
                $('#export-overlay').hide();
472
                \$form.find('button[type=\"submit\"]').prop('disabled', false);
473
                clearInterval(window.__exportCookieInterval);
474
                window.__exportCookieInterval = null;
475
                deleteCookie('download_token');
476
                window.__exportSafetyTimer && clearTimeout(window.__exportSafetyTimer);
477
            }
478
        }, 400);
479
480
        // Safety timeout: hide overlay after 120s in case of unexpected failures
481
        window.__exportSafetyTimer && clearTimeout(window.__exportSafetyTimer);
482
        window.__exportSafetyTimer = setTimeout(function(){
483
          $('#export-overlay').hide();
484
          \$form.find('button[type=\"submit\"]').prop('disabled', false);
485
          if (window.__exportCookieInterval) { clearInterval(window.__exportCookieInterval); window.__exportCookieInterval = null; }
486
        }, 120000);
487
    });
488
489
    // Also hide overlay when iframe finishes (for error pages or non-download responses)
490
    $('#export_iframe').on('load', function() {
491
        $('#export-overlay').hide();
492
        \$form.find('button[type=\"submit\"]').prop('disabled', false);
493
        window.__exportSafetyTimer && clearTimeout(window.__exportSafetyTimer);
494
        if (window.__exportCookieInterval) { clearInterval(window.__exportCookieInterval); window.__exportCookieInterval = null; }
495
    });
496
497
    // First paint: if course is selected, ensure exercises are filled accurately via AJAX
498
    (function bootstrapAjaxFill() {
499
        const sId = parseInt(\$session.val() || '0', 10) || 0;
500
        const cId = parseInt(\$course.val() || '0', 10) || 0;
501
502
        if (sId > 0) {
503
            // Sync courses list with the selected session (keeps selected value if present)
504
            \$loadingCourses.removeClass('hidden');
505
            $.getJSON('".addslashes(api_get_self())."', { ajax: 'courses', session_id: sId })
506
                .done(function(resp) {
507
                    if (resp && resp.ok) {
508
                        refillSelect(\$course, resp.options || [], { keepValue: true });
509
                    }
510
                })
511
                .always(function(){ \$loadingCourses.addClass('hidden'); });
512
        }
513
        if (cId > 0) {
514
            loadExercises(sId, cId);
515
        } else {
516
            disableSelect(\$exercise, true);
517
        }
518
    })();
519
});
520
</script>
521
";
522
523
// -----------------------------------------------------------------------------
524
// Form (still POST; AJAX only handles dependent selects without reload)
525
// -----------------------------------------------------------------------------
526
$form = new FormValidator('export_all_results_form', 'POST');
527
$form->addHeader($toolName);
528
529
// Session
530
$form->addSelect(
531
    'session_id',
532
    get_lang('Session'),
533
    $sessionSelectList,
534
    [
535
        'id'    => 'session_id',
536
        'class' => 'select2 form-control',
537
    ]
538
)->setSelected($sessionId);
539
540
// Course
541
$form->addSelect(
542
    'selected_course',
543
    get_lang('Course'),
544
    $courseSelectList ?: [0 => get_lang('Select')],
545
    [
546
        'id'    => 'selected_course',
547
        'class' => 'select2 form-control',
548
    ]
549
)->setSelected($courseId);
550
551
// Exercise
552
if ($courseId === 0) {
553
    $form->addSelect(
554
        'exerciseId',
555
        get_lang('Test'),
556
        [0 => get_lang('First select a course')],
557
        [
558
            'id'       => 'exerciseId',
559
            'class'    => 'select2 form-control',
560
            'disabled' => true,
561
        ]
562
    );
563
} else {
564
    $form->addSelect(
565
        'exerciseId',
566
        get_lang('Test'),
567
        $exerciseSelectList ?: [0 => get_lang('Select')],
568
        [
569
            'id'    => 'exerciseId',
570
            'class' => 'select2 form-control',
571
        ]
572
    )->setSelected($exerciseId);
573
}
574
575
// Date filters
576
$form->addDateTimePicker('start_date', get_lang('Start date'));
577
$form->addDateTimePicker('end_date', get_lang('End date'));
578
579
// Validation
580
$form->addRule('start_date', get_lang('Invalid date'), 'datetime');
581
$form->addRule('end_date', get_lang('Invalid date'), 'datetime');
582
$form->addRule(['start_date','end_date'], get_lang('StartDateShouldBeBeforeEndDate'), 'date_compare', 'lte');
583
584
// Required (session can be 0)
585
$form->addRule('selected_course', get_lang('Required field'), 'required');
586
$form->addRule('exerciseId', get_lang('Required field'), 'required');
587
588
// Hidden download token (will be filled by JS just-in-time)
589
$form->addHidden('download_token', '');
590
591
// Export button
592
$form->addButtonExport(get_lang('Export'), 'submit');
593
594
// -----------------------------------------------------------------------------
595
// Submit
596
// -----------------------------------------------------------------------------
597
if ($form->validate()) {
598
    $values     = $form->getSubmitValues();
599
    $sessionId  = isset($values['session_id']) ? (int) $values['session_id'] : 0;
600
    $courseId   = isset($values['selected_course']) ? (int) $values['selected_course'] : 0;
601
    $exerciseId = isset($values['exerciseId']) ? (int) $values['exerciseId'] : 0;
602
603
    // Download token cookie handshake to reliably hide the overlay in JS
604
    $downloadToken = isset($values['download_token']) ? (string) $values['download_token'] : '';
605
    if ($downloadToken !== '') {
606
        // Set cookie just before starting the export; JS polls for this exact value
607
        // Use modern options when available; fall back to legacy signature if needed.
608
        if (PHP_VERSION_ID >= 70300) {
609
            setcookie(
610
                'download_token',
611
                $downloadToken,
612
                [
613
                    'expires'  => 0,
614
                    'path'     => '/',
615
                    'secure'   => !empty($_SERVER['HTTPS']),
616
                    'httponly' => false,
617
                    'samesite' => 'Lax',
618
                ]
619
            );
620
        } else {
621
            setcookie('download_token', $downloadToken, 0, '/');
622
        }
623
    }
624
625
    // Early validation error → notify parent (page) via postMessage; do not use Flash in iframe.
626
    if ($courseId === 0 || $exerciseId === 0) {
627
        iframe_post_message(get_lang('Required field'), false);
628
    } else {
629
        $filterDates = [
630
            'start_date' => !empty($values['start_date']) ? $values['start_date'] : '',
631
            'end_date'   => !empty($values['end_date'])   ? $values['end_date']   : '',
632
        ];
633
634
        try {
635
            // Keep existing behavior: sends ZIP download or returns false when nothing to export
636
            $result = ExerciseLib::exportExerciseAllResultsZip($sessionId, $courseId, $exerciseId, $filterDates);
637
638
            // If library signals "no results", inform parent page (alert) via postMessage.
639
            if ($result === false) {
640
                iframe_post_message(get_lang('No result found for export in this test.'), false);
641
            }
642
            // If the method streams and exits on success, we never reach here. That's fine.
643
        } catch (Exception $e) {
644
            error_log('[export_exercise_results] Export error: ' . $e->getMessage());
645
            iframe_post_message(sprintf(get_lang('Export failed: %s'), $e->getMessage()), false);
646
        }
647
    }
648
}
649
650
// -----------------------------------------------------------------------------
651
// Render
652
// -----------------------------------------------------------------------------
653
Display::display_header($toolName);
654
655
// Hidden iframe to capture the file download response (avoid page reload)
656
echo '<iframe id="export_iframe" name="export_iframe" class="hidden" style="width:0;height:0;border:0;"></iframe>';
657
658
// Do NOT show the static waiting message permanently; the overlay appears only on submit.
659
$form->display();
660
661
Display::display_footer();
662