1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
/* For licensing terms, see /license.txt */ |
6
|
|
|
|
7
|
|
|
namespace Chamilo\CoreBundle\Helpers; |
8
|
|
|
|
9
|
|
|
use Chamilo\CoreBundle\Entity\AbstractResource; |
10
|
|
|
use Chamilo\CoreBundle\Entity\Asset; |
11
|
|
|
use Chamilo\CoreBundle\Framework\Container; |
12
|
|
|
use Chamilo\CoreBundle\Repository\ResourceRepository; |
13
|
|
|
use ChamiloSession as Session; |
14
|
|
|
use Database; |
15
|
|
|
use DateInterval; |
16
|
|
|
use DateTime; |
17
|
|
|
use DateTimeZone; |
18
|
|
|
use Display; |
19
|
|
|
use DocumentManager; |
20
|
|
|
use Event; |
21
|
|
|
use Exception; |
22
|
|
|
use ExtraFieldValue; |
23
|
|
|
use FormValidator; |
24
|
|
|
use LegalManager; |
25
|
|
|
use MessageManager; |
26
|
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile; |
27
|
|
|
use Template; |
28
|
|
|
use Throwable; |
29
|
|
|
use UserManager; |
30
|
|
|
|
31
|
|
|
use const ENT_HTML5; |
32
|
|
|
use const ENT_QUOTES; |
33
|
|
|
use const PHP_ROUND_HALF_UP; |
34
|
|
|
use const PHP_SAPI; |
35
|
|
|
use const PHP_URL_PATH; |
36
|
|
|
|
37
|
|
|
class ChamiloHelper |
38
|
|
|
{ |
39
|
|
|
public const COURSE_MANAGER = 1; |
40
|
|
|
public const SESSION_ADMIN = 3; |
41
|
|
|
public const DRH = 4; |
42
|
|
|
public const STUDENT = 5; |
43
|
|
|
public const ANONYMOUS = 6; |
44
|
|
|
|
45
|
|
|
private static array $configuration; |
46
|
|
|
|
47
|
|
|
public function setConfiguration(array $configuration): void |
48
|
|
|
{ |
49
|
|
|
self::$configuration = $configuration; |
50
|
|
|
} |
51
|
|
|
|
52
|
|
|
public static function getConfigurationArray(): array |
53
|
|
|
{ |
54
|
|
|
return self::$configuration; |
55
|
|
|
} |
56
|
|
|
|
57
|
|
|
public static function getConfigurationValue(string $variable): mixed |
58
|
|
|
{ |
59
|
|
|
$configuration = self::getConfigurationArray(); |
60
|
|
|
if (\array_key_exists($variable, $configuration)) { |
61
|
|
|
return $configuration[$variable]; |
62
|
|
|
} |
63
|
|
|
|
64
|
|
|
return false; |
65
|
|
|
} |
66
|
|
|
|
67
|
|
|
/** |
68
|
|
|
* Returns an array of resolutions that can be used for the conversion of documents to images. |
69
|
|
|
*/ |
70
|
|
|
public static function getDocumentConversionSizes(): array |
71
|
|
|
{ |
72
|
|
|
return [ |
73
|
|
|
'540x405' => '540x405 (3/4)', |
74
|
|
|
'640x480' => '640x480 (3/4)', |
75
|
|
|
'720x540' => '720x540 (3/4)', |
76
|
|
|
'800x600' => '800x600 (3/4)', |
77
|
|
|
'1024x576' => '1024x576 (16/9)', |
78
|
|
|
'1024x768' => '1000x750 (3/4)', |
79
|
|
|
'1280x720' => '1280x720 (16/9)', |
80
|
|
|
'1280x860' => '1280x960 (3/4)', |
81
|
|
|
'1400x1050' => '1400x1050 (3/4)', |
82
|
|
|
'1600x900' => '1600x900 (16/9)', |
83
|
|
|
]; |
84
|
|
|
} |
85
|
|
|
|
86
|
|
|
/** |
87
|
|
|
* Get the platform logo path. |
88
|
|
|
* |
89
|
|
|
* @deprecated |
90
|
|
|
* |
91
|
|
|
* @throws Exception |
92
|
|
|
*/ |
93
|
|
|
public static function getPlatformLogoPath( |
94
|
|
|
string $theme = '', |
95
|
|
|
bool $getSysPath = false, |
96
|
|
|
bool $forcedGetter = false |
97
|
|
|
): ?string { |
98
|
|
|
static $logoPath; |
99
|
|
|
|
100
|
|
|
// If call from CLI it should be reloaded. |
101
|
|
|
if ('cli' === PHP_SAPI) { |
102
|
|
|
$logoPath = null; |
103
|
|
|
} |
104
|
|
|
|
105
|
|
|
if (!isset($logoPath) || $forcedGetter) { |
106
|
|
|
$theme = empty($theme) ? api_get_visual_theme() : $theme; |
107
|
|
|
$accessUrlId = api_get_current_access_url_id(); |
108
|
|
|
if ('cli' === PHP_SAPI) { |
109
|
|
|
$accessUrl = api_get_configuration_value('access_url'); |
110
|
|
|
if (!empty($accessUrl)) { |
111
|
|
|
$accessUrlId = $accessUrl; |
112
|
|
|
} |
113
|
|
|
} |
114
|
|
|
$themeDir = Template::getThemeDir($theme); |
115
|
|
|
$customLogoPath = $themeDir.\sprintf('images/header-logo-custom%s.png', $accessUrlId); |
116
|
|
|
|
117
|
|
|
$svgIcons = api_get_setting('icons_mode_svg'); |
118
|
|
|
if ('true' === $svgIcons) { |
119
|
|
|
$customLogoPathSVG = substr($customLogoPath, 0, -3).'svg'; |
120
|
|
|
if (file_exists(api_get_path(SYS_PUBLIC_PATH).\sprintf('css/%s', $customLogoPathSVG))) { |
121
|
|
|
if ($getSysPath) { |
122
|
|
|
return api_get_path(SYS_PUBLIC_PATH).\sprintf('css/%s', $customLogoPathSVG); |
123
|
|
|
} |
124
|
|
|
|
125
|
|
|
return api_get_path(WEB_CSS_PATH).$customLogoPathSVG; |
126
|
|
|
} |
127
|
|
|
} |
128
|
|
|
if (file_exists(api_get_path(SYS_PUBLIC_PATH).\sprintf('css/%s', $customLogoPath))) { |
129
|
|
|
if ($getSysPath) { |
130
|
|
|
return api_get_path(SYS_PUBLIC_PATH).\sprintf('css/%s', $customLogoPath); |
131
|
|
|
} |
132
|
|
|
|
133
|
|
|
return api_get_path(WEB_CSS_PATH).$customLogoPath; |
134
|
|
|
} |
135
|
|
|
|
136
|
|
|
$originalLogoPath = $themeDir.'images/header-logo.png'; |
137
|
|
|
if ('true' === $svgIcons) { |
138
|
|
|
$originalLogoPathSVG = $themeDir.'images/header-logo.svg'; |
139
|
|
|
if (file_exists(api_get_path(SYS_CSS_PATH).$originalLogoPathSVG)) { |
140
|
|
|
if ($getSysPath) { |
141
|
|
|
return api_get_path(SYS_CSS_PATH).$originalLogoPathSVG; |
142
|
|
|
} |
143
|
|
|
|
144
|
|
|
return api_get_path(WEB_CSS_PATH).$originalLogoPathSVG; |
145
|
|
|
} |
146
|
|
|
} |
147
|
|
|
|
148
|
|
|
if (file_exists(api_get_path(SYS_CSS_PATH).$originalLogoPath)) { |
149
|
|
|
if ($getSysPath) { |
150
|
|
|
return api_get_path(SYS_CSS_PATH).$originalLogoPath; |
151
|
|
|
} |
152
|
|
|
|
153
|
|
|
return api_get_path(WEB_CSS_PATH).$originalLogoPath; |
154
|
|
|
} |
155
|
|
|
$logoPath = ''; |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
return $logoPath; |
159
|
|
|
} |
160
|
|
|
|
161
|
|
|
/** |
162
|
|
|
* Get the platform logo. |
163
|
|
|
* Return a <img> if the logo image exists. |
164
|
|
|
* Otherwise, return a <h2> with the institution name. |
165
|
|
|
* |
166
|
|
|
* @throws Exception |
167
|
|
|
*/ |
168
|
|
|
public static function getPlatformLogo( |
169
|
|
|
string $theme = '', |
170
|
|
|
array $imageAttributes = [], |
171
|
|
|
bool $getSysPath = false, |
172
|
|
|
bool $forcedGetter = false |
173
|
|
|
): string { |
174
|
|
|
$logoPath = Container::getThemeHelper()->getThemeAssetUrl('images/header-logo.svg'); |
175
|
|
|
|
176
|
|
|
if (empty($logoPath)) { |
177
|
|
|
$logoPath = Container::getThemeHelper()->getThemeAssetUrl('images/header-logo.png'); |
178
|
|
|
} |
179
|
|
|
|
180
|
|
|
$institution = api_get_setting('Institution'); |
181
|
|
|
$institutionUrl = api_get_setting('InstitutionUrl'); |
182
|
|
|
$siteName = api_get_setting('siteName'); |
183
|
|
|
|
184
|
|
|
if (null === $logoPath) { |
185
|
|
|
$headerLogo = Display::url($siteName, api_get_path(WEB_PATH).'index.php'); |
186
|
|
|
|
187
|
|
|
if (!empty($institutionUrl) && !empty($institution)) { |
188
|
|
|
$headerLogo .= ' - '.Display::url($institution, $institutionUrl); |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
$courseInfo = api_get_course_info(); |
192
|
|
|
if (isset($courseInfo['extLink']) && !empty($courseInfo['extLink']['name'])) { |
193
|
|
|
$headerLogo .= '<span class="extLinkSeparator"> - </span>'; |
194
|
|
|
|
195
|
|
|
if (!empty($courseInfo['extLink']['url'])) { |
196
|
|
|
$headerLogo .= Display::url( |
197
|
|
|
$courseInfo['extLink']['name'], |
198
|
|
|
$courseInfo['extLink']['url'], |
199
|
|
|
[ |
200
|
|
|
'class' => 'extLink', |
201
|
|
|
] |
202
|
|
|
); |
203
|
|
|
} elseif (!empty($courseInfo['extLink']['url'])) { |
204
|
|
|
$headerLogo .= $courseInfo['extLink']['url']; |
205
|
|
|
} |
206
|
|
|
} |
207
|
|
|
|
208
|
|
|
return Display::tag('h2', $headerLogo, [ |
209
|
|
|
'class' => 'text-left', |
210
|
|
|
]); |
211
|
|
|
} |
212
|
|
|
|
213
|
|
|
$image = Display::img($logoPath, $institution, $imageAttributes); |
214
|
|
|
|
215
|
|
|
return Display::url($image, api_get_path(WEB_PATH).'index.php'); |
216
|
|
|
} |
217
|
|
|
|
218
|
|
|
/** |
219
|
|
|
* Like strip_tags(), but leaves an additional space and removes only the given tags. |
220
|
|
|
* |
221
|
|
|
* @param array $tags Tags to be removed |
222
|
|
|
* |
223
|
|
|
* @return string The original string without the given tags |
224
|
|
|
*/ |
225
|
|
|
public static function stripGivenTags(string $string, array $tags): string |
226
|
|
|
{ |
227
|
|
|
foreach ($tags as $tag) { |
228
|
|
|
$string2 = preg_replace('#</\b'.$tag.'\b[^>]*>#i', ' ', $string); |
229
|
|
|
if ($string2 !== $string) { |
230
|
|
|
$string = preg_replace('/<\b'.$tag.'\b[^>]*>/i', ' ', $string2); |
231
|
|
|
} |
232
|
|
|
} |
233
|
|
|
|
234
|
|
|
return $string; |
235
|
|
|
} |
236
|
|
|
|
237
|
|
|
/** |
238
|
|
|
* Adds or Subtract a time in hh:mm:ss to a datetime. |
239
|
|
|
* |
240
|
|
|
* @param string $time Time to add or subtract in hh:mm:ss format |
241
|
|
|
* @param string $datetime Datetime to be modified as accepted by the Datetime class constructor |
242
|
|
|
* @param bool $operation True for Add, False to Subtract |
243
|
|
|
* |
244
|
|
|
* @throws Exception |
245
|
|
|
*/ |
246
|
|
|
public static function addOrSubTimeToDateTime( |
247
|
|
|
string $time, |
248
|
|
|
string $datetime = 'now', |
249
|
|
|
bool $operation = true |
250
|
|
|
): string { |
251
|
|
|
$date = new DateTime($datetime); |
252
|
|
|
$hours = 0; |
253
|
|
|
$minutes = 0; |
254
|
|
|
$seconds = 0; |
255
|
|
|
sscanf($time, '%d:%d:%d', $hours, $minutes, $seconds); |
256
|
|
|
$timeSeconds = isset($seconds) ? $hours * 3600 + $minutes * 60 + $seconds : $hours * 60 + $minutes; |
257
|
|
|
if ($operation) { |
258
|
|
|
$date->add(new DateInterval('PT'.$timeSeconds.'S')); |
259
|
|
|
} else { |
260
|
|
|
$date->sub(new DateInterval('PT'.$timeSeconds.'S')); |
261
|
|
|
} |
262
|
|
|
|
263
|
|
|
return $date->format('Y-m-d H:i:s'); |
264
|
|
|
} |
265
|
|
|
|
266
|
|
|
/** |
267
|
|
|
* Returns the course id (integer) for the given course directory or the current ID if no directory is defined. |
268
|
|
|
* |
269
|
|
|
* @param string|null $directory The course directory/path that appears in the URL |
270
|
|
|
* |
271
|
|
|
* @throws Exception |
272
|
|
|
*/ |
273
|
|
|
public static function getCourseIdByDirectory(?string $directory = null): int |
274
|
|
|
{ |
275
|
|
|
if (!empty($directory)) { |
276
|
|
|
$directory = Database::escape_string($directory); |
277
|
|
|
$row = Database::select( |
278
|
|
|
'id', |
279
|
|
|
Database::get_main_table(TABLE_MAIN_COURSE), |
280
|
|
|
[ |
281
|
|
|
'where' => [ |
282
|
|
|
'directory = ?' => [$directory], |
283
|
|
|
], |
284
|
|
|
], |
285
|
|
|
'first' |
286
|
|
|
); |
287
|
|
|
|
288
|
|
|
if (\is_array($row) && isset($row['id'])) { |
289
|
|
|
return $row['id']; |
290
|
|
|
} |
291
|
|
|
|
292
|
|
|
return 0; |
293
|
|
|
} |
294
|
|
|
|
295
|
|
|
return (int) Session::read('_real_cid', 0); |
296
|
|
|
} |
297
|
|
|
|
298
|
|
|
/** |
299
|
|
|
* Check if the current HTTP request is by AJAX. |
300
|
|
|
*/ |
301
|
|
|
public static function isAjaxRequest(): bool |
302
|
|
|
{ |
303
|
|
|
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? null; |
304
|
|
|
|
305
|
|
|
return 'XMLHttpRequest' === $requestedWith; |
306
|
|
|
} |
307
|
|
|
|
308
|
|
|
/** |
309
|
|
|
* Get a variable name for language file from a text. |
310
|
|
|
*/ |
311
|
|
|
public static function getLanguageVar(string $text, string $prefix = ''): string |
312
|
|
|
{ |
313
|
|
|
$text = api_replace_dangerous_char($text); |
314
|
|
|
$text = str_replace(['-', ' ', '.'], '_', $text); |
315
|
|
|
$text = preg_replace('/_+/', '_', $text); |
316
|
|
|
// $text = str_replace('_', '', $text); |
317
|
|
|
$text = api_underscore_to_camel_case($text); |
318
|
|
|
|
319
|
|
|
return $prefix.$text; |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
/** |
323
|
|
|
* Get the stylesheet path for HTML blocks created with CKEditor. |
324
|
|
|
*/ |
325
|
|
|
public static function getEditorBlockStylePath(): string |
326
|
|
|
{ |
327
|
|
|
$visualTheme = api_get_visual_theme(); |
328
|
|
|
|
329
|
|
|
$cssFile = api_get_path(SYS_CSS_PATH).\sprintf('themes/%s/editor_content.css', $visualTheme); |
330
|
|
|
|
331
|
|
|
if (is_file($cssFile)) { |
332
|
|
|
return api_get_path(WEB_CSS_PATH).\sprintf('themes/%s/editor_content.css', $visualTheme); |
333
|
|
|
} |
334
|
|
|
|
335
|
|
|
return api_get_path(WEB_CSS_PATH).'editor_content.css'; |
336
|
|
|
} |
337
|
|
|
|
338
|
|
|
/** |
339
|
|
|
* Get a list of colors from the palette at main/palette/pchart/default.color |
340
|
|
|
* and return it as an array of strings. |
341
|
|
|
* |
342
|
|
|
* @param bool $decimalOpacity Whether to return the opacity as 0..100 or 0..1 |
343
|
|
|
* @param bool $wrapInRGBA Whether to return it as 1,1,1,100 or rgba(1,1,1,100) |
344
|
|
|
* @param int|null $fillUpTo If the number of colors is smaller than this number, generate more colors |
345
|
|
|
* |
346
|
|
|
* @return array An array of string colors |
347
|
|
|
*/ |
348
|
|
|
public static function getColorPalette( |
349
|
|
|
bool $decimalOpacity = false, |
350
|
|
|
bool $wrapInRGBA = false, |
351
|
|
|
?int $fillUpTo = null |
352
|
|
|
): array { |
353
|
|
|
// Get the common colors from the palette used for pchart |
354
|
|
|
$paletteFile = api_get_path(SYS_CODE_PATH).'palettes/pchart/default.color'; |
355
|
|
|
$palette = file($paletteFile); |
356
|
|
|
if ($decimalOpacity) { |
357
|
|
|
// Because the pchart palette has transparency as integer values |
358
|
|
|
// (0..100) and chartjs uses percentage (0.0..1.0), we need to divide |
359
|
|
|
// the last value by 100, which is a bit overboard for just one chart |
360
|
|
|
foreach ($palette as $index => $color) { |
361
|
|
|
$components = explode(',', trim($color)); |
362
|
|
|
$components[3] = round((int) $components[3] / 100, 1, PHP_ROUND_HALF_UP); |
363
|
|
|
$palette[$index] = implode(',', $components); |
364
|
|
|
} |
365
|
|
|
} |
366
|
|
|
if ($wrapInRGBA) { |
367
|
|
|
foreach ($palette as $index => $color) { |
368
|
|
|
$color = trim($color); |
369
|
|
|
$palette[$index] = 'rgba('.$color.')'; |
370
|
|
|
} |
371
|
|
|
} |
372
|
|
|
// If we want more colors, loop through existing colors |
373
|
|
|
$count = \count($palette); |
374
|
|
|
if (isset($fillUpTo) && $fillUpTo > $count) { |
375
|
|
|
for ($i = $count; $i < $fillUpTo; $i++) { |
376
|
|
|
$palette[$i] = $palette[$i % $count]; |
377
|
|
|
} |
378
|
|
|
} |
379
|
|
|
|
380
|
|
|
return $palette; |
381
|
|
|
} |
382
|
|
|
|
383
|
|
|
/** |
384
|
|
|
* Get the local time for the midnight. |
385
|
|
|
* |
386
|
|
|
* @param null|string $utcTime Optional. The time to ve converted. |
387
|
|
|
* See api_get_local_time. |
388
|
|
|
* |
389
|
|
|
* @throws Exception |
390
|
|
|
*/ |
391
|
|
|
public static function getServerMidnightTime(?string $utcTime = null): DateTime |
392
|
|
|
{ |
393
|
|
|
$localTime = api_get_local_time($utcTime); |
394
|
|
|
$localTimeZone = api_get_timezone(); |
395
|
|
|
|
396
|
|
|
$localMidnight = new DateTime($localTime, new DateTimeZone($localTimeZone)); |
397
|
|
|
$localMidnight->modify('midnight'); |
398
|
|
|
|
399
|
|
|
return $localMidnight; |
400
|
|
|
} |
401
|
|
|
|
402
|
|
|
/** |
403
|
|
|
* Get JavaScript code necessary to load quiz markers-rolls in medialement's Markers Rolls plugin. |
404
|
|
|
*/ |
405
|
|
|
public static function getQuizMarkersRollsJS(): string |
406
|
|
|
{ |
407
|
|
|
$webCodePath = api_get_path(WEB_CODE_PATH); |
408
|
|
|
$cidReq = api_get_cidreq(true, true, 'embeddable'); |
409
|
|
|
$colorPalette = self::getColorPalette(false, true); |
410
|
|
|
|
411
|
|
|
return " |
412
|
|
|
var \$originalNode = $(originalNode), |
413
|
|
|
qMarkersRolls = \$originalNode.data('q-markersrolls') || [], |
414
|
|
|
qMarkersColor = \$originalNode.data('q-markersrolls-color') || '$colorPalette[0]'; |
415
|
|
|
|
416
|
|
|
if (0 == qMarkersRolls.length) { |
417
|
|
|
return; |
418
|
|
|
} |
419
|
|
|
|
420
|
|
|
instance.options.markersRollsColor = qMarkersColor; |
421
|
|
|
instance.options.markersRollsWidth = 2; |
422
|
|
|
instance.options.markersRolls = {}; |
423
|
|
|
|
424
|
|
|
qMarkersRolls.forEach(function (qMarkerRoll) { |
425
|
|
|
var url = '{$webCodePath}exercise/exercise_submit.php?$cidReq&' |
426
|
|
|
+ $.param({ |
427
|
|
|
exerciseId: qMarkerRoll[1], |
428
|
|
|
learnpath_id: 0, |
429
|
|
|
learnpath_item_id: 0, |
430
|
|
|
learnpath_item_view_id: 0 |
431
|
|
|
}); |
432
|
|
|
|
433
|
|
|
instance.options.markersRolls[qMarkerRoll[0]] = url; |
434
|
|
|
}); |
435
|
|
|
|
436
|
|
|
instance.buildmarkersrolls(instance, instance.controls, instance.layers, instance.media); |
437
|
|
|
"; |
438
|
|
|
} |
439
|
|
|
|
440
|
|
|
/** |
441
|
|
|
* Performs a redirection to the specified URL. |
442
|
|
|
* |
443
|
|
|
* This method sends a direct HTTP Location header to the client, |
444
|
|
|
* causing the browser to navigate to the specified URL. It should be |
445
|
|
|
* used with caution and only in scenarios where Symfony's standard |
446
|
|
|
* response handling is not applicable. The method terminates script |
447
|
|
|
* execution after sending the header. |
448
|
|
|
*/ |
449
|
|
|
public static function redirectTo(string $url): void |
450
|
|
|
{ |
451
|
|
|
if (!empty($url)) { |
452
|
|
|
header("Location: $url"); |
453
|
|
|
|
454
|
|
|
exit; |
455
|
|
|
} |
456
|
|
|
} |
457
|
|
|
|
458
|
|
|
/** |
459
|
|
|
* Checks if the current user has accepted the Terms & Conditions. |
460
|
|
|
*/ |
461
|
|
|
public static function userHasAcceptedTerms(): bool |
462
|
|
|
{ |
463
|
|
|
$termRegistered = Session::read('term_and_condition'); |
464
|
|
|
|
465
|
|
|
return isset($termRegistered['user_id']); |
466
|
|
|
} |
467
|
|
|
|
468
|
|
|
/** |
469
|
|
|
* Redirects to the Terms and Conditions page. |
470
|
|
|
*/ |
471
|
|
|
public static function redirectToTermsAndConditions(): void |
472
|
|
|
{ |
473
|
|
|
$url = self::getTermsAndConditionsUrl(); |
474
|
|
|
self::redirectTo($url); |
475
|
|
|
} |
476
|
|
|
|
477
|
|
|
/** |
478
|
|
|
* Returns the URL of the Terms and Conditions page. |
479
|
|
|
*/ |
480
|
|
|
public static function getTermsAndConditionsUrl(): string |
481
|
|
|
{ |
482
|
|
|
return api_get_path(WEB_PATH).'main/auth/tc.php'; |
483
|
|
|
} |
484
|
|
|
|
485
|
|
|
/** |
486
|
|
|
* Returns the URL of the Registration page. |
487
|
|
|
*/ |
488
|
|
|
public static function getRegistrationUrl(): string |
489
|
|
|
{ |
490
|
|
|
return api_get_path(WEB_PATH).'main/auth/registration.php'; |
491
|
|
|
} |
492
|
|
|
|
493
|
|
|
/** |
494
|
|
|
* Adds legal terms acceptance fields into a registration form. |
495
|
|
|
*/ |
496
|
|
|
public static function addLegalTermsFields(FormValidator $form, bool $userAlreadyRegisteredShowTerms): void |
497
|
|
|
{ |
498
|
|
|
if ('true' !== api_get_setting('allow_terms_conditions') || $userAlreadyRegisteredShowTerms) { |
499
|
|
|
return; |
500
|
|
|
} |
501
|
|
|
|
502
|
|
|
// Check if T&C should be shown during registration |
503
|
|
|
$loadMode = api_get_setting('workflows.load_term_conditions_section'); |
504
|
|
|
|
505
|
|
|
if ('course' === $loadMode) { |
506
|
|
|
// skip adding terms on registration page |
507
|
|
|
return; |
508
|
|
|
} |
509
|
|
|
|
510
|
|
|
$languageIso = api_get_language_isocode(); |
511
|
|
|
$languageId = api_get_language_id($languageIso); |
512
|
|
|
|
513
|
|
|
$termPreview = LegalManager::get_last_condition($languageId); |
514
|
|
|
|
515
|
|
|
if (!$termPreview) { |
|
|
|
|
516
|
|
|
// Do NOT load fallback terms in another language |
517
|
|
|
return; |
518
|
|
|
} |
519
|
|
|
|
520
|
|
|
if (!$termPreview) { |
|
|
|
|
521
|
|
|
return; |
522
|
|
|
} |
523
|
|
|
|
524
|
|
|
// hidden inputs to track version/language |
525
|
|
|
$form->addElement('hidden', 'legal_accept_type', $termPreview['version'].':'.$termPreview['language_id']); |
526
|
|
|
$form->addElement('hidden', 'legal_info', $termPreview['id'].':'.$termPreview['language_id']); |
527
|
|
|
|
528
|
|
|
if (1 == $termPreview['type']) { |
529
|
|
|
// simple checkbox linking out to full T&C |
530
|
|
|
$form->addElement( |
531
|
|
|
'checkbox', |
532
|
|
|
'legal_accept', |
533
|
|
|
null, |
534
|
|
|
'I have read and agree to the <a href="tc.php" target="_blank">Terms and Conditions</a>' |
535
|
|
|
); |
536
|
|
|
$form->addRule('legal_accept', 'This field is required', 'required'); |
537
|
|
|
} else { |
538
|
|
|
// full inline T&C panel with scroll |
539
|
|
|
$preview = LegalManager::show_last_condition($termPreview); |
540
|
|
|
$form->addHtml( |
541
|
|
|
'<div style=" |
542
|
|
|
background-color: #f3f4f6; |
543
|
|
|
border: 1px solid #d1d5db; |
544
|
|
|
padding: 1rem; |
545
|
|
|
max-height: 16rem; |
546
|
|
|
overflow-y: auto; |
547
|
|
|
border-radius: 0.375rem; |
548
|
|
|
margin-bottom: 1rem; |
549
|
|
|
">' |
550
|
|
|
.$preview. |
551
|
|
|
'</div>' |
552
|
|
|
); |
553
|
|
|
|
554
|
|
|
// any extra labels |
555
|
|
|
$extra = new ExtraFieldValue('terms_and_condition'); |
556
|
|
|
$values = $extra->getAllValuesByItem($termPreview['id']); |
557
|
|
|
foreach ($values as $value) { |
558
|
|
|
if (!empty($value['field_value'])) { |
559
|
|
|
$form->addLabel($value['display_text'], $value['field_value']); |
560
|
|
|
} |
561
|
|
|
} |
562
|
|
|
|
563
|
|
|
// acceptance checkbox |
564
|
|
|
$form->addElement( |
565
|
|
|
'checkbox', |
566
|
|
|
'legal_accept', |
567
|
|
|
null, |
568
|
|
|
'I have read and agree to the Terms and Conditions' |
569
|
|
|
); |
570
|
|
|
$form->addRule('legal_accept', 'This field is required', 'required'); |
571
|
|
|
} |
572
|
|
|
} |
573
|
|
|
|
574
|
|
|
/** |
575
|
|
|
* Persists the user's acceptance of the terms & conditions. |
576
|
|
|
* |
577
|
|
|
* @param string $legalAcceptType version:language_id |
578
|
|
|
*/ |
579
|
|
|
public static function saveUserTermsAcceptance(int $userId, string $legalAcceptType): void |
580
|
|
|
{ |
581
|
|
|
// Split and build the stored value** |
582
|
|
|
[$version, $languageId] = explode(':', $legalAcceptType); |
583
|
|
|
$timestamp = time(); |
584
|
|
|
$toSave = (int) $version.':'.(int) $languageId.':'.$timestamp; |
585
|
|
|
|
586
|
|
|
// Save in extra-field** |
587
|
|
|
UserManager::update_extra_field_value($userId, 'legal_accept', $toSave); |
588
|
|
|
|
589
|
|
|
// Log event |
590
|
|
|
Event::addEvent( |
|
|
|
|
591
|
|
|
LOG_TERM_CONDITION_ACCEPTED, |
592
|
|
|
LOG_USER_OBJECT, |
593
|
|
|
api_get_user_info($userId), |
594
|
|
|
api_get_utc_datetime() |
595
|
|
|
); |
596
|
|
|
|
597
|
|
|
$bossList = UserManager::getStudentBossList($userId); |
598
|
|
|
if (!empty($bossList)) { |
599
|
|
|
$bossIds = array_column($bossList, 'boss_id'); |
600
|
|
|
$current = api_get_user_info($userId); |
601
|
|
|
$dateStr = api_get_local_time($timestamp); |
602
|
|
|
|
603
|
|
|
foreach ($bossIds as $bossId) { |
604
|
|
|
$subject = \sprintf(get_lang('User %s signed the agreement.'), $current['complete_name']); |
605
|
|
|
$content = \sprintf(get_lang('User %s signed the agreement the %s.'), $current['complete_name'], $dateStr); |
606
|
|
|
MessageManager::send_message_simple($bossId, $subject, $content, $userId); |
607
|
|
|
} |
608
|
|
|
} |
609
|
|
|
} |
610
|
|
|
|
611
|
|
|
/** |
612
|
|
|
* Displays the Terms and Conditions page. |
613
|
|
|
*/ |
614
|
|
|
public static function displayLegalTermsPage(string $returnUrl = '/home', bool $canAccept = true, string $infoMessage = ''): void |
615
|
|
|
{ |
616
|
|
|
$iso = api_get_language_isocode(); |
617
|
|
|
$langId = api_get_language_id($iso); |
618
|
|
|
$term = LegalManager::get_last_condition($langId); |
619
|
|
|
|
620
|
|
|
if (!$term) { |
|
|
|
|
621
|
|
|
// No T&C for current language → show a message |
622
|
|
|
Display::display_header(get_lang('Terms and Conditions')); |
623
|
|
|
echo '<div class="max-w-3xl mx-auto text-gray-90 text-lg text-center">' |
624
|
|
|
.get_lang('No terms and conditions available for this language.') |
625
|
|
|
.'</div>'; |
626
|
|
|
Display::display_footer(); |
627
|
|
|
|
628
|
|
|
exit; |
|
|
|
|
629
|
|
|
} |
630
|
|
|
|
631
|
|
|
Display::display_header(get_lang('Terms and Conditions')); |
632
|
|
|
|
633
|
|
|
if (!empty($term['content'])) { |
634
|
|
|
echo '<div class="max-w-3xl mx-auto bg-white shadow p-8 rounded">'; |
635
|
|
|
echo '<h1 class="text-2xl font-bold text-primary mb-6">'.get_lang('Terms and Conditions').'</h1>'; |
636
|
|
|
|
637
|
|
|
if (!empty($infoMessage)) { |
638
|
|
|
echo '<div class="mb-4">'.$infoMessage.'</div>'; |
639
|
|
|
} |
640
|
|
|
|
641
|
|
|
echo '<div class="prose prose-sm max-w-none mb-6">'.$term['content'].'</div>'; |
642
|
|
|
|
643
|
|
|
$extra = new ExtraFieldValue('terms_and_condition'); |
644
|
|
|
foreach ($extra->getAllValuesByItem($term['id']) as $field) { |
645
|
|
|
if (!empty($field['field_value'])) { |
646
|
|
|
echo '<div class="mb-4">'; |
647
|
|
|
echo '<h3 class="text-lg font-semibold text-primary">'.$field['display_text'].'</h3>'; |
648
|
|
|
echo '<p class="text-gray-90 mt-1">'.$field['field_value'].'</p>'; |
649
|
|
|
echo '</div>'; |
650
|
|
|
} |
651
|
|
|
} |
652
|
|
|
|
653
|
|
|
echo '<form method="post" action="tc.php?return='.urlencode($returnUrl).'" class="space-y-6">'; |
654
|
|
|
echo '<input type="hidden" name="legal_accept_type" value="'.$term['version'].':'.$term['language_id'].'">'; |
655
|
|
|
echo '<input type="hidden" name="return" value="'.htmlspecialchars($returnUrl).'">'; |
656
|
|
|
|
657
|
|
|
if ($canAccept) { |
658
|
|
|
$hide = 'true' === api_get_setting('registration.hide_legal_accept_checkbox'); |
659
|
|
|
if ($hide) { |
660
|
|
|
echo '<input type="hidden" name="legal_accept" value="1">'; |
661
|
|
|
} else { |
662
|
|
|
echo '<label class="flex items-start space-x-2">'; |
663
|
|
|
echo '<input type="checkbox" name="legal_accept" value="1" required class="rounded border-gray-300 text-primary focus:ring-primary">'; |
664
|
|
|
echo '<span class="text-gray-90 text-sm">'.get_lang('I have read and agree to the').' '; |
665
|
|
|
echo '<a href="tc.php?preview=1" target="_blank" class="text-primary hover:underline">'.get_lang('Terms and Conditions').'</a>'; |
666
|
|
|
echo '</span>'; |
667
|
|
|
echo '</label>'; |
668
|
|
|
} |
669
|
|
|
|
670
|
|
|
echo '<div><button type="submit" class="inline-block bg-primary text-white font-semibold px-6 py-3 rounded hover:opacity-90 transition">'.get_lang('Accept Terms and Conditions').'</button></div>'; |
671
|
|
|
} else { |
672
|
|
|
echo '<div><button type="button" class="inline-block bg-gray-400 text-white font-semibold px-6 py-3 rounded cursor-not-allowed" disabled>'.get_lang('Accept Terms and Conditions').'</button></div>'; |
673
|
|
|
} |
674
|
|
|
|
675
|
|
|
echo '</form>'; |
676
|
|
|
echo '</div>'; |
677
|
|
|
} else { |
678
|
|
|
echo '<div class="text-center text-gray-90 text-lg">'.get_lang('Coming soon...').'</div>'; |
679
|
|
|
} |
680
|
|
|
|
681
|
|
|
Display::display_footer(); |
682
|
|
|
|
683
|
|
|
exit; |
|
|
|
|
684
|
|
|
} |
685
|
|
|
|
686
|
|
|
/** |
687
|
|
|
* Try to add a legacy file to a Resource using the repository's addFile() API. |
688
|
|
|
* Falls back to attachLegacyFileToResource() if addFile() is not available. |
689
|
|
|
* |
690
|
|
|
* Returns true on success, false otherwise. Logs in English. |
691
|
|
|
*/ |
692
|
|
|
public static function addLegacyFileToResource( |
693
|
|
|
string $filePath, |
694
|
|
|
ResourceRepository $repo, |
695
|
|
|
AbstractResource $resource, |
696
|
|
|
string $fileName = '', |
697
|
|
|
string $description = '' |
698
|
|
|
): bool { |
699
|
|
|
$class = $resource::class; |
700
|
|
|
$basename = basename($filePath); |
701
|
|
|
|
702
|
|
|
if (!self::legacyFileUsable($filePath)) { |
703
|
|
|
error_log("LEGACY_FILE: Cannot attach to {$class} – file not found or unreadable: {$basename}"); |
704
|
|
|
|
705
|
|
|
return false; |
706
|
|
|
} |
707
|
|
|
|
708
|
|
|
// If the repository doesn't expose addFile(), use the Asset flow. |
709
|
|
|
if (!method_exists($repo, 'addFile')) { |
710
|
|
|
error_log('LEGACY_FILE: Repository '.$repo::class.' has no addFile(), falling back to Asset flow'); |
711
|
|
|
|
712
|
|
|
return self::attachLegacyFileToResource($filePath, $resource, $fileName); |
713
|
|
|
} |
714
|
|
|
|
715
|
|
|
try { |
716
|
|
|
$mimeType = self::legacyDetectMime($filePath); |
717
|
|
|
$finalName = '' !== $fileName ? $fileName : $basename; |
718
|
|
|
|
719
|
|
|
// UploadedFile in "test mode" (last arg true) avoids PHP upload checks. |
720
|
|
|
$uploaded = new UploadedFile($filePath, $finalName, $mimeType, null, true); |
721
|
|
|
$repo->addFile($resource, $uploaded, $description); |
722
|
|
|
|
723
|
|
|
return true; |
724
|
|
|
} catch (Throwable $e) { |
725
|
|
|
error_log('LEGACY_FILE EXCEPTION (addFile): '.$e->getMessage()); |
726
|
|
|
|
727
|
|
|
return false; |
728
|
|
|
} |
729
|
|
|
} |
730
|
|
|
|
731
|
|
|
/** |
732
|
|
|
* Create an Asset for a legacy file and attach it to the resource's node. |
733
|
|
|
* Generic path that works for any AbstractResource with a ResourceNode. |
734
|
|
|
* |
735
|
|
|
* Returns true on success, false otherwise. Logs in English. |
736
|
|
|
*/ |
737
|
|
|
public static function attachLegacyFileToResource( |
738
|
|
|
string $filePath, |
739
|
|
|
AbstractResource $resource, |
740
|
|
|
string $fileName = '' |
741
|
|
|
): bool { |
742
|
|
|
$class = $resource::class; |
743
|
|
|
$basename = basename($filePath); |
744
|
|
|
|
745
|
|
|
if (!self::legacyFileUsable($filePath)) { |
746
|
|
|
error_log("LEGACY_FILE: Cannot attach Asset to {$class} – file not found or unreadable: {$basename}"); |
747
|
|
|
|
748
|
|
|
return false; |
749
|
|
|
} |
750
|
|
|
|
751
|
|
|
if (!method_exists($resource, 'getResourceNode') || null === $resource->getResourceNode()) { |
752
|
|
|
error_log("LEGACY_FILE: Resource has no ResourceNode – cannot attach Asset (class: {$class})"); |
753
|
|
|
|
754
|
|
|
return false; |
755
|
|
|
} |
756
|
|
|
|
757
|
|
|
try { |
758
|
|
|
$assetRepo = Container::getAssetRepository(); |
759
|
|
|
|
760
|
|
|
// Prefer a dedicated helper if available. |
761
|
|
|
if (method_exists($assetRepo, 'createFromLocalPath')) { |
762
|
|
|
$asset = $assetRepo->createFromLocalPath( |
763
|
|
|
$filePath, |
764
|
|
|
'' !== $fileName ? $fileName : $basename |
765
|
|
|
); |
766
|
|
|
} else { |
767
|
|
|
// Fallback: simulate an upload-like array for createFromRequest(). |
768
|
|
|
$mimeType = self::legacyDetectMime($filePath); |
769
|
|
|
$fakeUpload = [ |
770
|
|
|
'tmp_name' => $filePath, |
771
|
|
|
'name' => '' !== $fileName ? $fileName : $basename, |
772
|
|
|
'type' => $mimeType, |
773
|
|
|
'size' => @filesize($filePath) ?: null, |
774
|
|
|
'error' => 0, |
775
|
|
|
]; |
776
|
|
|
|
777
|
|
|
$asset = (new Asset()) |
778
|
|
|
->setTitle($fakeUpload['name']) |
779
|
|
|
->setCompressed(false) |
780
|
|
|
; |
781
|
|
|
|
782
|
|
|
// AssetRepository::createFromRequest(Asset $asset, array $uploadLike) |
783
|
|
|
$assetRepo->createFromRequest($asset, $fakeUpload); |
784
|
|
|
} |
785
|
|
|
|
786
|
|
|
// Attach to the resource's node. |
787
|
|
|
if (method_exists($assetRepo, 'attachToNode')) { |
788
|
|
|
$assetRepo->attachToNode($asset, $resource->getResourceNode()); |
789
|
|
|
|
790
|
|
|
return true; |
791
|
|
|
} |
792
|
|
|
|
793
|
|
|
// If the resource repository exposes a direct helper: |
794
|
|
|
$repo = self::guessResourceRepository($resource); |
795
|
|
|
if ($repo && method_exists($repo, 'attachAssetToResource')) { |
796
|
|
|
$repo->attachAssetToResource($resource, $asset); |
797
|
|
|
|
798
|
|
|
return true; |
799
|
|
|
} |
800
|
|
|
|
801
|
|
|
error_log('LEGACY_FILE: No method to attach Asset to node (missing attachToNode/attachAssetToResource)'); |
802
|
|
|
|
803
|
|
|
return false; |
804
|
|
|
} catch (Throwable $e) { |
805
|
|
|
error_log('LEGACY_FILE EXCEPTION (Asset attach): '.$e->getMessage()); |
806
|
|
|
|
807
|
|
|
return false; |
808
|
|
|
} |
809
|
|
|
} |
810
|
|
|
|
811
|
|
|
private static function legacyFileUsable(string $filePath): bool |
812
|
|
|
{ |
813
|
|
|
return is_file($filePath) && is_readable($filePath); |
814
|
|
|
} |
815
|
|
|
|
816
|
|
|
private static function legacyDetectMime(string $filePath): string |
817
|
|
|
{ |
818
|
|
|
$mime = @mime_content_type($filePath); |
819
|
|
|
|
820
|
|
|
return $mime ?: 'application/octet-stream'; |
821
|
|
|
} |
822
|
|
|
|
823
|
|
|
/** |
824
|
|
|
* Best-effort guess to find the resource repository via Doctrine. |
825
|
|
|
* Returns null if the repo is not a ResourceRepository. |
826
|
|
|
*/ |
827
|
|
|
private static function guessResourceRepository(AbstractResource $resource): ?ResourceRepository |
828
|
|
|
{ |
829
|
|
|
try { |
830
|
|
|
$em = Database::getManager(); |
831
|
|
|
$repo = $em->getRepository($resource::class); |
832
|
|
|
|
833
|
|
|
return $repo instanceof ResourceRepository ? $repo : null; |
834
|
|
|
} catch (Throwable $e) { |
835
|
|
|
return null; |
836
|
|
|
} |
837
|
|
|
} |
838
|
|
|
|
839
|
|
|
/** |
840
|
|
|
* Scan HTML for legacy /courses/<dir>/document/... references found in a ZIP, |
841
|
|
|
* ensure those files are created as Documents, and return URL maps to rewrite the HTML. |
842
|
|
|
* |
843
|
|
|
* Returns: ['byRel' => [ "document/..." => "public-url" ], |
844
|
|
|
* 'byBase'=> [ "file.ext" => "public-url" ] ] |
845
|
|
|
* |
846
|
|
|
* @param mixed $docRepo |
847
|
|
|
* @param mixed $courseEntity |
848
|
|
|
* @param mixed $session |
849
|
|
|
* @param mixed $group |
850
|
|
|
*/ |
851
|
|
|
public static function buildUrlMapForHtmlFromPackage( |
852
|
|
|
string $html, |
853
|
|
|
string $courseDir, |
854
|
|
|
string $srcRoot, |
855
|
|
|
array &$folders, |
856
|
|
|
callable $ensureFolder, |
857
|
|
|
$docRepo, |
858
|
|
|
$courseEntity, |
859
|
|
|
$session, |
860
|
|
|
$group, |
861
|
|
|
int $session_id, |
862
|
|
|
int $file_option, |
863
|
|
|
?callable $dbg = null |
864
|
|
|
): array { |
865
|
|
|
$byRel = []; |
866
|
|
|
$byBase = []; |
867
|
|
|
|
868
|
|
|
$DBG = $dbg ?: static function ($m, $c = []): void { /* no-op */ }; |
869
|
|
|
|
870
|
|
|
// src|href pointing to …/courses/<dir>/document/... (host optional) |
871
|
|
|
$depRegex = '/(?P<attr>src|href)\s*=\s*["\'](?P<full>(?:(?P<scheme>https?:)?\/\/[^"\']+)?(?P<path>\/courses\/[^\/]+\/document\/[^"\']+\.[a-z0-9]{1,8}))["\']/i'; |
872
|
|
|
|
873
|
|
|
if (!preg_match_all($depRegex, $html, $mm) || empty($mm['full'])) { |
874
|
|
|
return ['byRel' => $byRel, 'byBase' => $byBase]; |
875
|
|
|
} |
876
|
|
|
|
877
|
|
|
// Normalize a full URL to a "document/..." relative path inside the package |
878
|
|
|
$toRel = static function (string $full) use ($courseDir): string { |
879
|
|
|
$urlPath = parse_url(html_entity_decode($full, ENT_QUOTES | ENT_HTML5), PHP_URL_PATH) ?: $full; |
880
|
|
|
$urlPath = preg_replace('#^/courses/([^/]+)/#i', '/courses/'.$courseDir.'/', $urlPath); |
881
|
|
|
$rel = preg_replace('#^/courses/'.preg_quote($courseDir, '#').'/#i', '', $urlPath) ?: $urlPath; |
882
|
|
|
|
883
|
|
|
return ltrim($rel, '/'); // "document/..." |
884
|
|
|
}; |
885
|
|
|
|
886
|
|
|
foreach ($mm['full'] as $fullUrl) { |
887
|
|
|
$rel = $toRel($fullUrl); // e.g. "document/img.png" |
888
|
|
|
if (!str_starts_with($rel, 'document/')) { |
889
|
|
|
continue; |
890
|
|
|
} // STRICT: only /document/* |
891
|
|
|
if (isset($byRel[$rel])) { |
892
|
|
|
continue; |
893
|
|
|
} |
894
|
|
|
|
895
|
|
|
$basename = basename(parse_url($fullUrl, PHP_URL_PATH) ?: $fullUrl); |
896
|
|
|
$byBase[$basename] = $byBase[$basename] ?? null; |
897
|
|
|
|
898
|
|
|
$parentRelPath = '/'.trim(\dirname('/'.$rel), '/'); // "/document" or "/document/foo" |
899
|
|
|
$depTitle = basename($rel); |
900
|
|
|
$depAbs = rtrim($srcRoot, '/').'/'.$rel; |
901
|
|
|
|
902
|
|
|
// Do NOT create a top-level "/document" root |
903
|
|
|
$parentId = 0; |
904
|
|
|
if ('/document' !== $parentRelPath) { |
905
|
|
|
$parentId = $folders[$parentRelPath] ?? 0; |
906
|
|
|
if (!$parentId) { |
907
|
|
|
$parentId = $ensureFolder($parentRelPath); |
908
|
|
|
$folders[$parentRelPath] = $parentId; |
909
|
|
|
$DBG('helper.ensureFolder', ['parentRelPath' => $parentRelPath, 'parentId' => $parentId]); |
910
|
|
|
} |
911
|
|
|
} else { |
912
|
|
|
$parentRelPath = '/'; |
913
|
|
|
} |
914
|
|
|
|
915
|
|
|
if (!is_file($depAbs) || !is_readable($depAbs)) { |
916
|
|
|
$DBG('helper.dep.missing', ['rel' => $rel, 'abs' => $depAbs]); |
917
|
|
|
|
918
|
|
|
continue; |
919
|
|
|
} |
920
|
|
|
|
921
|
|
|
// Collision check under parent |
922
|
|
|
$parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; |
923
|
|
|
$findExisting = function ($t) use ($docRepo, $parentRes, $courseEntity, $session, $group) { |
924
|
|
|
$e = $docRepo->findCourseResourceByTitle($t, $parentRes->getResourceNode(), $courseEntity, $session, $group); |
925
|
|
|
|
926
|
|
|
return $e && method_exists($e, 'getIid') ? $e->getIid() : null; |
927
|
|
|
}; |
928
|
|
|
|
929
|
|
|
$finalTitle = $depTitle; |
930
|
|
|
$existsIid = $findExisting($finalTitle); |
931
|
|
|
if ($existsIid) { |
932
|
|
|
$FILE_SKIP = \defined('FILE_SKIP') ? FILE_SKIP : 2; |
933
|
|
|
if ($file_option === $FILE_SKIP) { |
934
|
|
|
$existingDoc = $docRepo->find($existsIid); |
935
|
|
|
if ($existingDoc) { |
936
|
|
|
$url = $docRepo->getResourceFileUrl($existingDoc); |
937
|
|
|
if ($url) { |
938
|
|
|
$byRel[$rel] = $url; |
939
|
|
|
$byBase[$basename] = $byBase[$basename] ?: $url; |
940
|
|
|
$DBG('helper.dep.reuse', ['rel' => $rel, 'iid' => $existsIid, 'url' => $url]); |
941
|
|
|
} |
942
|
|
|
} |
943
|
|
|
|
944
|
|
|
continue; |
945
|
|
|
} |
946
|
|
|
// Rename on collision |
947
|
|
|
$pi = pathinfo($depTitle); |
948
|
|
|
$name = $pi['filename'] ?? $depTitle; |
949
|
|
|
$ext2 = isset($pi['extension']) && '' !== $pi['extension'] ? '.'.$pi['extension'] : ''; |
950
|
|
|
$i = 1; |
951
|
|
|
while ($findExisting($finalTitle)) { |
952
|
|
|
$finalTitle = $name.'_'.$i.$ext2; |
953
|
|
|
$i++; |
954
|
|
|
} |
955
|
|
|
} |
956
|
|
|
|
957
|
|
|
// Create the non-HTML dependency from the package |
958
|
|
|
try { |
959
|
|
|
$entity = DocumentManager::addDocument( |
960
|
|
|
['real_id' => $courseEntity->getId(), 'code' => method_exists($courseEntity, 'getCode') ? $courseEntity->getCode() : null], |
961
|
|
|
$parentRelPath, // metadata path (no "/document" root) |
962
|
|
|
'file', |
963
|
|
|
(int) (@filesize($depAbs) ?: 0), |
964
|
|
|
$finalTitle, |
965
|
|
|
null, |
966
|
|
|
0, |
967
|
|
|
null, |
968
|
|
|
0, |
969
|
|
|
(int) $session_id, |
970
|
|
|
0, |
971
|
|
|
false, |
972
|
|
|
'', |
973
|
|
|
$parentId, |
974
|
|
|
$depAbs |
975
|
|
|
); |
976
|
|
|
$iid = method_exists($entity, 'getIid') ? $entity->getIid() : 0; |
977
|
|
|
$url = $docRepo->getResourceFileUrl($entity); |
978
|
|
|
|
979
|
|
|
$DBG('helper.dep.created', ['rel' => $rel, 'iid' => $iid, 'url' => $url]); |
980
|
|
|
|
981
|
|
|
if ($url) { |
982
|
|
|
$byRel[$rel] = $url; |
983
|
|
|
$byBase[$basename] = $byBase[$basename] ?: $url; |
984
|
|
|
} |
985
|
|
|
} catch (Throwable $e) { |
986
|
|
|
$DBG('helper.dep.error', ['rel' => $rel, 'err' => $e->getMessage()]); |
987
|
|
|
} |
988
|
|
|
} |
989
|
|
|
|
990
|
|
|
$byBase = array_filter($byBase); |
991
|
|
|
|
992
|
|
|
return ['byRel' => $byRel, 'byBase' => $byBase]; |
993
|
|
|
} |
994
|
|
|
|
995
|
|
|
/** |
996
|
|
|
* Rewrite src|href that point to /courses/<dir>/document/... using: |
997
|
|
|
* - exact match by relative path ("document/...") via $urlMapByRel |
998
|
|
|
* - basename fallback ("file.ext") via $urlMapByBase |
999
|
|
|
* |
1000
|
|
|
* Returns: ['html'=>..., 'replaced'=>N, 'misses'=>M] |
1001
|
|
|
*/ |
1002
|
|
|
public static function rewriteLegacyCourseUrlsWithMap( |
1003
|
|
|
string $html, |
1004
|
|
|
string $courseDir, |
1005
|
|
|
array $urlMapByRel, |
1006
|
|
|
array $urlMapByBase |
1007
|
|
|
): array { |
1008
|
|
|
$replaced = 0; |
1009
|
|
|
$misses = 0; |
1010
|
|
|
|
1011
|
|
|
$pattern = '/(?P<attr>src|href)\s*=\s*["\'](?P<full>(?:(?P<scheme>https?:)?\/\/[^"\']+)?(?P<path>\/courses\/(?P<dir>[^\/]+)\/document\/[^"\']+\.[a-z0-9]{1,8}))["\']/i'; |
1012
|
|
|
|
1013
|
|
|
$html = preg_replace_callback($pattern, function ($m) use ($courseDir, $urlMapByRel, $urlMapByBase, &$replaced, &$misses) { |
1014
|
|
|
$attr = $m['attr']; |
1015
|
|
|
$fullUrl = html_entity_decode($m['full'], ENT_QUOTES | ENT_HTML5); |
1016
|
|
|
$path = $m['path']; // /courses/<dir>/document/... |
1017
|
|
|
$matchDir = $m['dir']; |
1018
|
|
|
|
1019
|
|
|
// Normalize to current course directory |
1020
|
|
|
$effectivePath = $path; |
1021
|
|
|
if (0 !== strcasecmp($matchDir, $courseDir)) { |
1022
|
|
|
$effectivePath = preg_replace('#^/courses/'.preg_quote($matchDir, '#').'/#i', '/courses/'.$courseDir.'/', $path) ?: $path; |
1023
|
|
|
} |
1024
|
|
|
|
1025
|
|
|
// "document/...." |
1026
|
|
|
$relInPackage = preg_replace('#^/courses/'.preg_quote($courseDir, '#').'/#i', '', $effectivePath) ?: $effectivePath; |
1027
|
|
|
$relInPackage = ltrim($relInPackage, '/'); // document/... |
1028
|
|
|
|
1029
|
|
|
// 1) exact rel match |
1030
|
|
|
if (isset($urlMapByRel[$relInPackage])) { |
1031
|
|
|
$newUrl = $urlMapByRel[$relInPackage]; |
1032
|
|
|
$replaced++; |
1033
|
|
|
|
1034
|
|
|
return $attr.'="'.htmlspecialchars($newUrl, ENT_QUOTES | ENT_HTML5).'"'; |
1035
|
|
|
} |
1036
|
|
|
|
1037
|
|
|
// 2) basename fallback |
1038
|
|
|
$base = basename(parse_url($effectivePath, PHP_URL_PATH) ?: $effectivePath); |
1039
|
|
|
if (isset($urlMapByBase[$base])) { |
1040
|
|
|
$newUrl = $urlMapByBase[$base]; |
1041
|
|
|
$replaced++; |
1042
|
|
|
|
1043
|
|
|
return $attr.'="'.htmlspecialchars($newUrl, ENT_QUOTES | ENT_HTML5).'"'; |
1044
|
|
|
} |
1045
|
|
|
|
1046
|
|
|
// Not found → keep original |
1047
|
|
|
$misses++; |
1048
|
|
|
|
1049
|
|
|
return $m[0]; |
1050
|
|
|
}, $html); |
1051
|
|
|
|
1052
|
|
|
return ['html' => $html, 'replaced' => $replaced, 'misses' => $misses]; |
1053
|
|
|
} |
1054
|
|
|
} |
1055
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.