Upgrade 1-11.38

This commit is contained in:
xesmyd
2026-03-30 14:10:30 +02:00
parent f2a7e6d1fc
commit ac648ef29d
24665 changed files with 69682 additions and 2205004 deletions
+348 -9
View File
@@ -13,6 +13,7 @@ use Exception;
*/
abstract class ActivityExport
{
public const DOCS_MODULE_ID = 0;
protected $course;
public function __construct($course)
@@ -27,15 +28,44 @@ abstract class ActivityExport
abstract public function export($activityId, $exportDir, $moduleId, $sectionId);
/**
* Get the section ID for a given activity ID.
* Get the section ID (learnpath source_id) for a given activity.
*/
public function getSectionIdForActivity(int $activityId, string $itemType): int
{
if (empty($this->course->resources[RESOURCE_LEARNPATH])) {
return 0;
}
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) {
if (empty($learnpath->items)) {
continue;
}
foreach ($learnpath->items as $item) {
$item['item_type'] = $item['item_type'] === 'student_publication' ? 'work' : $item['item_type'];
if ($item['item_type'] == $itemType && $item['path'] == $activityId) {
return $learnpath->source_id;
$normalizedType = $item['item_type'] === 'student_publication'
? 'work'
: $item['item_type'];
if ($normalizedType !== $itemType) {
continue;
}
// Classic case: LP stores the numeric id in "path"
if (ctype_digit((string) $item['path']) && (int) $item['path'] === $activityId) {
return (int) $learnpath->source_id;
}
// Fallback for documents when LP stores the path instead of the id
if ($itemType === RESOURCE_DOCUMENT) {
$doc = \DocumentManager::get_document_data_by_id($activityId, $this->course->code);
if (!empty($doc['path'])) {
$p = (string) $doc['path'];
foreach ([$p, 'document/'.$p, '/'.$p] as $candidate) {
if ((string) $item['path'] === $candidate) {
return (int) $learnpath->source_id;
}
}
}
}
}
}
@@ -116,27 +146,29 @@ abstract class ActivityExport
*/
protected function createInforefXml(array $references, string $directory): void
{
// Start the XML content
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<inforef>'.PHP_EOL;
// Add user references if provided
if (isset($references['users']) && is_array($references['users'])) {
$xmlContent .= ' <userref>'.PHP_EOL;
foreach ($references['users'] as $userId) {
$xmlContent .= ' <user>'.PHP_EOL;
$xmlContent .= ' <id>'.htmlspecialchars($userId).'</id>'.PHP_EOL;
$xmlContent .= ' <id>'.htmlspecialchars((string) $userId).'</id>'.PHP_EOL;
$xmlContent .= ' </user>'.PHP_EOL;
}
$xmlContent .= ' </userref>'.PHP_EOL;
}
// Add file references if provided
if (isset($references['files']) && is_array($references['files'])) {
$xmlContent .= ' <fileref>'.PHP_EOL;
foreach ($references['files'] as $file) {
$fileId = is_array($file) ? (int) ($file['id'] ?? 0) : (int) $file;
if ($fileId <= 0) {
continue;
}
$xmlContent .= ' <file>'.PHP_EOL;
$xmlContent .= ' <id>'.htmlspecialchars($file['id']).'</id>'.PHP_EOL;
$xmlContent .= ' <id>'.$fileId.'</id>'.PHP_EOL;
$xmlContent .= ' </file>'.PHP_EOL;
}
$xmlContent .= ' </fileref>'.PHP_EOL;
@@ -252,4 +284,311 @@ abstract class ActivityExport
$this->createXmlFile('calendar', $xmlContent, $destinationDir);
}
/**
* Creates a Moodle-safe activity name.
* - Strip HTML
* - Decode entities
* - Normalize whitespace
* - Truncate to a maximum length.
*/
protected function sanitizeMoodleActivityName(string $raw, int $maxLen = 255): string
{
$s = trim($raw);
if ($s === '') {
return '';
}
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$s = strip_tags($s);
$s = preg_replace('/\s+/u', ' ', $s);
$s = trim($s);
if ($s === '') {
return '';
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($s, 'UTF-8') > $maxLen) {
$s = mb_substr($s, 0, $maxLen, 'UTF-8');
}
} else {
if (strlen($s) > $maxLen) {
$s = substr($s, 0, $maxLen);
}
}
return $s;
}
/**
* Returns the title of the item in the LP if it exists, otherwise the fallback.
*/
protected function lpItemTitle(int $sectionId, string $itemType, int $resourceId, ?string $fallback): string
{
if (!isset($this->course->resources[RESOURCE_LEARNPATH])) {
return $fallback ?? '';
}
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $lp) {
if ((int) $lp->source_id !== $sectionId || empty($lp->items)) {
continue;
}
foreach ($lp->items as $it) {
$type = $it['item_type'] === 'student_publication' ? 'work' : $it['item_type'];
if ($type === $itemType && (int) $it['path'] === $resourceId) {
return $it['title'] ?? ($fallback ?? '');
}
}
}
return $fallback ?? '';
}
/**
* Extract embedded files from HTML content and normalize URLs to @@PLUGINFILE@@.
*
* Supported sources:
* - Course documents: /document/...
* - Platform assets: /main/default_course_document/... and /main/img/...
*
* @param callable $fileIdBuilder Receives the 1-based sequence and must return a unique file id.
*
* @return array{content:string, files:array<int,array<string,mixed>>}
*/
protected function extractEmbeddedFilesAndNormalizeContent(
string $html,
int $contextId,
string $component,
string $fileArea,
int $itemId,
callable $fileIdBuilder
): array {
if ($html === '') {
return [
'content' => '',
'files' => [],
];
}
$courseInfo = api_get_course_info($this->course->code);
$adminId = (int) (MoodleExport::getAdminUserData()['id'] ?? 1);
$fileExport = new FileExport($this->course);
$files = [];
$seenSources = [];
$sequence = 0;
$normalizedHtml = preg_replace_callback(
'#<img[^>]+src=["\'](?<url>[^"\']+)["\']#i',
function ($match) use (
$courseInfo,
$contextId,
$component,
$fileArea,
$itemId,
$fileIdBuilder,
$adminId,
$fileExport,
&$files,
&$seenSources,
&$sequence
) {
$src = (string) ($match['url'] ?? '');
$resolved = $this->resolveEmbeddedFileSource($src, $courseInfo);
if ($resolved === null) {
return $match[0];
}
$sourceKey = (string) $resolved['sourcekey'];
if (!isset($seenSources[$sourceKey])) {
$sequence++;
$fileId = (int) call_user_func($fileIdBuilder, $sequence);
$absolutePath = (string) $resolved['absolutepath'];
$filename = (string) $resolved['filename'];
$files[] = [
'id' => $fileId,
'contenthash' => is_file($absolutePath)
? sha1_file($absolutePath)
: hash('sha1', $filename),
'contextid' => $contextId,
'component' => $component,
'filearea' => $fileArea,
'itemid' => $itemId,
'filepath' => (string) $resolved['filepath'],
'documentpath' => (string) $resolved['documentpath'],
'absolutepath' => $absolutePath,
'filename' => $filename,
'userid' => $adminId,
'filesize' => is_file($absolutePath) ? (int) filesize($absolutePath) : 0,
'mimetype' => $fileExport->getMimeType($filename),
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => $filename,
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
$seenSources[$sourceKey] = true;
}
return str_replace($src, '@@PLUGINFILE@@'.(string) $resolved['pluginfilepath'], $match[0]);
},
$html
);
return [
'content' => (string) $normalizedHtml,
'files' => $files,
];
}
/**
* Resolve an embeddable source URL into export metadata.
*
* @return array<string,string>|null
*/
protected function resolveEmbeddedFileSource(string $src, array $courseInfo): ?array
{
$urlPath = $this->extractUrlPath($src);
if (preg_match('#/document(?P<path>/[^"\']+)#', $urlPath, $m)) {
$documentRelativePath = (string) $m['path'];
$docId = \DocumentManager::get_document_id($courseInfo, $documentRelativePath);
if (empty($docId)) {
return null;
}
$document = \DocumentManager::get_document_data_by_id((int) $docId, $this->course->code);
if (empty($document)) {
return null;
}
$documentPath = (string) ($document['path'] ?? '');
if ($documentPath === '') {
return null;
}
return [
'sourcekey' => 'document:'.$docId,
'filepath' => $this->buildPluginFileDirectoryFromChamiloDocumentPath($documentPath),
'pluginfilepath' => $this->buildPluginFilePathFromChamiloDocumentPath($documentRelativePath),
'documentpath' => 'document'.$documentPath,
'absolutepath' => $this->buildChamiloDocumentAbsolutePath($documentPath),
'filename' => basename($documentPath),
];
}
if (preg_match('#(?P<path>/main/(?:default_course_document|img)/[^"\']+)#', $urlPath, $m)) {
$assetPath = (string) $m['path'];
return [
'sourcekey' => 'static:'.$assetPath,
'filepath' => $this->buildPluginFileDirectoryFromStaticAssetPath($assetPath),
'pluginfilepath' => $this->buildPluginFilePathFromStaticAssetPath($assetPath),
'documentpath' => '',
'absolutepath' => $this->buildStaticAssetAbsolutePath($assetPath),
'filename' => basename($assetPath),
];
}
return null;
}
/**
* Extract the path part from a raw URL.
*/
protected function extractUrlPath(string $src): string
{
$decoded = html_entity_decode($src, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$path = (string) parse_url($decoded, PHP_URL_PATH);
return $path !== '' ? $path : $decoded;
}
/**
* Build the absolute path for one Chamilo course document.
*/
protected function buildChamiloDocumentAbsolutePath(string $documentPath): string
{
$normalizedPath = (string) preg_replace('#^/?document/#', '', str_replace('\\', '/', trim($documentPath)));
$normalizedPath = '/'.ltrim($normalizedPath, '/');
return rtrim((string) $this->course->path, '/').'/document'.$normalizedPath;
}
/**
* Build the absolute path for one static platform asset.
*/
protected function buildStaticAssetAbsolutePath(string $assetPath): string
{
return rtrim(api_get_path(SYS_PATH), '/').'/'.ltrim($assetPath, '/');
}
/**
* Build the pluginfile directory path from a Chamilo document path.
*/
protected function buildPluginFileDirectoryFromChamiloDocumentPath(string $documentPath): string
{
$relative = $this->stripChamiloDocumentPrefix($documentPath);
$relative = ltrim(str_replace('\\', '/', $relative), '/');
$dir = dirname($relative);
if ($dir === '.' || $dir === '/') {
return '/';
}
return '/'.trim($dir, '/').'/';
}
/**
* Build the pluginfile full path used in HTML content.
*/
protected function buildPluginFilePathFromChamiloDocumentPath(string $documentPath): string
{
$relative = $this->stripChamiloDocumentPrefix($documentPath);
$relative = ltrim(str_replace('\\', '/', $relative), '/');
return '/'.$relative;
}
/**
* Build the pluginfile directory path for one static platform asset.
*/
protected function buildPluginFileDirectoryFromStaticAssetPath(string $assetPath): string
{
$relative = ltrim(str_replace('\\', '/', trim($assetPath)), '/');
$dir = dirname($relative);
if ($dir === '.' || $dir === '/') {
return '/Static/';
}
return '/Static/'.trim($dir, '/').'/';
}
/**
* Build the pluginfile full path used in HTML content for one static platform asset.
*/
protected function buildPluginFilePathFromStaticAssetPath(string $assetPath): string
{
$relative = ltrim(str_replace('\\', '/', trim($assetPath)), '/');
return '/Static/'.$relative;
}
/**
* Remove the internal Chamilo "document/" prefix if present.
*/
protected function stripChamiloDocumentPrefix(string $path): string
{
$path = str_replace('\\', '/', trim($path));
$path = preg_replace('#^/?document/#', '', $path);
return (string) $path;
}
}