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;
}
}
+24 -12
View File
@@ -55,8 +55,12 @@ class CourseExport
*/
private function createCourseXml(string $destinationDir): void
{
$courseId = $this->courseInfo['real_id'] ?? 0;
$contextId = $this->courseInfo['real_id'] ?? 1;
$courseId = (int) ($this->courseInfo['real_id'] ?? 0);
$contextId = (int) MoodleExport::getBackupCourseContextId();
if ($contextId <= 0) {
$contextId = 700000000 + max(1, $courseId);
}
$shortname = $this->courseInfo['code'] ?? 'Unknown Course';
$fullname = $this->courseInfo['title'] ?? 'Unknown Fullname';
$showgrades = $this->courseInfo['showgrades'] ?? 0;
@@ -153,15 +157,24 @@ class CourseExport
$questionCategories = [];
foreach ($this->activities as $activity) {
if ($activity['modulename'] === 'quiz') {
$quizExport = new QuizExport($this->course);
$quizData = $quizExport->getData($activity['id'], $activity['sectionid']);
foreach ($quizData['questions'] as $question) {
$categoryId = $question['questioncategoryid'];
if (!in_array($categoryId, $questionCategories, true)) {
$questionCategories[] = $categoryId;
}
}
if (($activity['modulename'] ?? '') !== 'quiz') {
continue;
}
$quizExport = new QuizExport($this->course);
$quizData = $quizExport->getData(
(int) ($activity['id'] ?? 0),
(int) ($activity['sectionid'] ?? 0),
(int) ($activity['moduleid'] ?? 0)
);
if (empty($quizData)) {
continue;
}
$categoryId = (int) ($quizData['question_category_id'] ?? 0);
if ($categoryId > 0 && !in_array($categoryId, $questionCategories, true)) {
$questionCategories[] = $categoryId;
}
}
@@ -175,7 +188,6 @@ class CourseExport
$xmlContent .= ' </question_categoryref>'.PHP_EOL;
}
// Add role references
$xmlContent .= ' <roleref>'.PHP_EOL;
$xmlContent .= ' <role>'.PHP_EOL;
$xmlContent .= ' <id>5</id>'.PHP_EOL;
+113 -95
View File
@@ -38,20 +38,17 @@ class FileExport
mkdir($filesDir, api_get_permissions_for_new_directories(), true);
}
// Create placeholder index.html
$this->createPlaceholderFile($filesDir);
// Export each file
foreach ($filesData['files'] as $file) {
$this->copyFileToExportDir($file, $filesDir);
}
// Create files.xml in the export directory
$this->createFilesXml($filesData, $exportDir);
}
/**
* Get file data from course resources. This is for testing purposes.
* Get file data from course resources.
*/
public function getFilesData(): array
{
@@ -99,6 +96,17 @@ class FileExport
return $filesData;
}
/**
* Get MIME type based on the file extension.
*/
public function getMimeType($filePath): string
{
$extension = strtolower((string) pathinfo((string) $filePath, PATHINFO_EXTENSION));
$mimeTypes = $this->getMimeTypes();
return $mimeTypes[$extension] ?? 'application/octet-stream';
}
/**
* Create a placeholder index.html file to prevent an empty directory.
*/
@@ -113,27 +121,38 @@ class FileExport
*/
private function copyFileToExportDir(array $file, string $filesDir): void
{
if ($file['filepath'] === '.') {
if (($file['filepath'] ?? '') === '.') {
return;
}
$contenthash = (string) ($file['contenthash'] ?? '');
if ($contenthash === '') {
return;
}
$contenthash = $file['contenthash'];
$subDir = substr($contenthash, 0, 2);
$filePath = $this->course->path.$file['documentpath'];
$exportSubDir = $filesDir.'/'.$subDir;
// Ensure the subdirectory exists
if (!is_dir($exportSubDir)) {
mkdir($exportSubDir, api_get_permissions_for_new_directories(), true);
}
// Copy the file to the export directory
$destinationFile = $exportSubDir.'/'.$contenthash;
if (file_exists($filePath)) {
copy($filePath, $destinationFile);
$filePath = '';
if (!empty($file['absolutepath'])) {
$filePath = (string) $file['absolutepath'];
} else {
throw new Exception("File {$filePath} not found.");
$filePath = $this->course->path.($file['documentpath'] ?? '');
}
if (is_file($filePath)) {
copy($filePath, $destinationFile);
return;
}
throw new Exception("File {$filePath} not found.");
}
/**
@@ -152,28 +171,56 @@ class FileExport
file_put_contents($destinationDir.'/files.xml', $xmlContent);
}
/**
* Ensure mandatory file fields exist for Moodle restore.
*/
private function normalizeFileEntry(array $file): array
{
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
if (!isset($file['itemid']) || $file['itemid'] === null || (is_string($file['itemid']) && trim($file['itemid']) === '')) {
$file['itemid'] = 0;
} else {
$file['itemid'] = (int) $file['itemid'];
}
if (!isset($file['userid']) || $file['userid'] === null || (is_string($file['userid']) && trim($file['userid']) === '')) {
$file['userid'] = $adminId;
} else {
$file['userid'] = (int) $file['userid'];
}
return $file;
}
/**
* Create an XML entry for a file.
*/
private function createFileXmlEntry(array $file): string
{
$file = $this->normalizeFileEntry($file);
$itemId = (int) $file['itemid'];
$userId = (int) $file['userid'];
return ' <file id="'.$file['id'].'">'.PHP_EOL.
' <contenthash>'.htmlspecialchars($file['contenthash']).'</contenthash>'.PHP_EOL.
' <contenthash>'.htmlspecialchars((string) $file['contenthash']).'</contenthash>'.PHP_EOL.
' <contextid>'.$file['contextid'].'</contextid>'.PHP_EOL.
' <component>'.htmlspecialchars($file['component']).'</component>'.PHP_EOL.
' <filearea>'.htmlspecialchars($file['filearea']).'</filearea>'.PHP_EOL.
' <itemid>0</itemid>'.PHP_EOL.
' <filepath>'.htmlspecialchars($file['filepath']).'</filepath>'.PHP_EOL.
' <filename>'.htmlspecialchars($file['filename']).'</filename>'.PHP_EOL.
' <userid>'.$file['userid'].'</userid>'.PHP_EOL.
' <component>'.htmlspecialchars((string) $file['component']).'</component>'.PHP_EOL.
' <filearea>'.htmlspecialchars((string) $file['filearea']).'</filearea>'.PHP_EOL.
' <itemid>'.$itemId.'</itemid>'.PHP_EOL.
' <filepath>'.htmlspecialchars((string) $file['filepath']).'</filepath>'.PHP_EOL.
' <filename>'.htmlspecialchars((string) $file['filename']).'</filename>'.PHP_EOL.
' <userid>'.$userId.'</userid>'.PHP_EOL.
' <filesize>'.$file['filesize'].'</filesize>'.PHP_EOL.
' <mimetype>'.htmlspecialchars($file['mimetype']).'</mimetype>'.PHP_EOL.
' <mimetype>'.htmlspecialchars((string) $file['mimetype']).'</mimetype>'.PHP_EOL.
' <status>'.$file['status'].'</status>'.PHP_EOL.
' <timecreated>'.$file['timecreated'].'</timecreated>'.PHP_EOL.
' <timemodified>'.$file['timemodified'].'</timemodified>'.PHP_EOL.
' <source>'.htmlspecialchars($file['source']).'</source>'.PHP_EOL.
' <author>'.htmlspecialchars($file['author']).'</author>'.PHP_EOL.
' <license>'.htmlspecialchars($file['license']).'</license>'.PHP_EOL.
' <source>'.htmlspecialchars((string) $file['source']).'</source>'.PHP_EOL.
' <author>'.htmlspecialchars((string) $file['author']).'</author>'.PHP_EOL.
' <license>'.htmlspecialchars((string) $file['license']).'</license>'.PHP_EOL.
' <sortorder>0</sortorder>'.PHP_EOL.
' <repositorytype>$@NULL@$</repositorytype>'.PHP_EOL.
' <repositoryid>$@NULL@$</repositoryid>'.PHP_EOL.
@@ -186,41 +233,48 @@ class FileExport
*/
private function processDocument(array $filesData, object $document): array
{
if (
$document->file_type === 'file' &&
isset($this->course->used_page_doc_ids) &&
in_array($document->source_id, $this->course->used_page_doc_ids)
) {
if ($document->file_type !== 'file') {
return $filesData;
}
if (
$document->file_type === 'file' &&
pathinfo($document->path, PATHINFO_EXTENSION) === 'html' &&
substr_count($document->path, '/') === 1
) {
return $filesData;
}
$fileData = $this->getFileData($document);
$filepath = $this->buildMoodleFilepathFromChamiloPath((string) $document->path);
if ($document->file_type === 'file') {
$extension = pathinfo($document->path, PATHINFO_EXTENSION);
if (!in_array(strtolower($extension), ['html', 'htm'])) {
$fileData = $this->getFileData($document);
$fileData['filepath'] = '/Documents/';
$fileData['contextid'] = 0;
$fileData['component'] = 'mod_folder';
$filesData['files'][] = $fileData;
}
} elseif ($document->file_type === 'folder') {
$folderFiles = \DocumentManager::getAllDocumentsByParentId($this->course->info, $document->source_id);
foreach ($folderFiles as $file) {
$filesData['files'][] = $this->getFolderFileData($file, (int) $document->source_id, '/Documents/'.dirname($file['path']).'/');
}
}
$fileData['filepath'] = $filepath;
$fileData['contextid'] = ActivityExport::DOCS_MODULE_ID;
$fileData['component'] = 'mod_folder';
$fileData['filearea'] = 'content';
$fileData['itemid'] = ActivityExport::DOCS_MODULE_ID;
$filesData['files'][] = $fileData;
return $filesData;
}
/**
* Build a Moodle filepath from a Chamilo document path.
*/
private function buildMoodleFilepathFromChamiloPath(string $documentPath): string
{
$normalizedPath = $this->stripChamiloDocumentPrefix($documentPath);
$normalizedPath = ltrim(str_replace('\\', '/', $normalizedPath), '/');
$relDir = dirname($normalizedPath);
return $this->ensureTrailingSlash($relDir === '.' ? '/' : '/'.$relDir.'/');
}
/**
* Remove the internal Chamilo document prefix from a path.
*/
private function stripChamiloDocumentPrefix(string $path): string
{
$path = str_replace('\\', '/', trim($path));
$path = preg_replace('#^/?document/#', '', $path);
return $path;
}
/**
* Get file data for a single document.
*/
@@ -253,40 +307,6 @@ class FileExport
];
}
/**
* Get file data for files inside a folder.
*/
private function getFolderFileData(array $file, int $sourceId, string $parentPath = '/Documents/'): array
{
$adminData = MoodleExport::getAdminUserData();
$adminId = $adminData['id'];
$contenthash = hash('sha1', basename($file['path']));
$mimetype = $this->getMimeType($file['path']);
$filename = basename($file['path']);
$filepath = $this->ensureTrailingSlash($parentPath);
return [
'id' => $file['id'],
'contenthash' => $contenthash,
'contextid' => $sourceId,
'component' => 'mod_folder',
'filearea' => 'content',
'itemid' => (int) $file['id'],
'filepath' => $filepath,
'documentpath' => 'document/'.$file['path'],
'filename' => $filename,
'userid' => $adminId,
'filesize' => $file['size'],
'mimetype' => $mimetype,
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => $file['title'],
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
/**
* Ensure the directory path has a trailing slash.
*/
@@ -301,17 +321,6 @@ class FileExport
return rtrim($path, '/').'/';
}
/**
* Get MIME type based on the file extension.
*/
public function getMimeType($filePath): string
{
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
$mimeTypes = $this->getMimeTypes();
return $mimeTypes[$extension] ?? 'application/octet-stream';
}
/**
* Get an array of file extensions and their corresponding MIME types.
*/
@@ -323,8 +332,17 @@ class FileExport
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'html' => 'text/html',
'htm' => 'text/html',
'txt' => 'text/plain',
'css' => 'text/css',
'js' => 'application/javascript',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
+6 -13
View File
@@ -44,12 +44,12 @@ class FolderExport extends ActivityExport
*/
public function getData(int $folderId, int $sectionId): ?array
{
if ($folderId === 0) {
if ($folderId === 0 || $folderId === ActivityExport::DOCS_MODULE_ID) {
return [
'id' => 0,
'moduleid' => 0,
'id' => ActivityExport::DOCS_MODULE_ID,
'moduleid' => ActivityExport::DOCS_MODULE_ID,
'modulename' => 'folder',
'contextid' => 0,
'contextid' => ActivityExport::DOCS_MODULE_ID,
'name' => 'Documents',
'sectionid' => $sectionId,
'timemodified' => time(),
@@ -98,17 +98,10 @@ class FolderExport extends ActivityExport
private function getFilesForFolder(int $folderId): array
{
$files = [];
if ($folderId === 0) {
if ($folderId === ActivityExport::DOCS_MODULE_ID) {
foreach ($this->course->resources[RESOURCE_DOCUMENT] as $doc) {
if ($doc->file_type === 'file') {
$files[] = [
'id' => (int) $doc->source_id,
'contenthash' => hash('sha1', basename($doc->path)),
'filename' => basename($doc->path),
'filepath' => '/Documents/',
'filesize' => (int) $doc->size,
'mimetype' => $this->getMimeType($doc->path),
];
$files[] = ['id' => (int) $doc->source_id];
}
}
}
+149 -59
View File
@@ -11,6 +11,8 @@ namespace moodleexport;
*/
class ForumExport extends ActivityExport
{
private static int $embeddedFileGlobalSeq = 0;
/**
* Export all forum data into a single Moodle forum activity.
*
@@ -21,13 +23,18 @@ class ForumExport extends ActivityExport
*/
public function export($activityId, $exportDir, $moduleId, $sectionId): void
{
// Prepare the directory where the forum export will be saved
$forumDir = $this->prepareActivityDirectory($exportDir, 'forum', $moduleId);
$effectiveModuleId = (int) $moduleId;
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve forum data
$forumData = $this->getData($activityId, $sectionId);
$forumDir = $this->prepareActivityDirectory($exportDir, 'forum', $effectiveModuleId);
$forumData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($forumData)) {
return;
}
// Generate XML files for the forum
$this->createForumXml($forumData, $forumDir);
$this->createModuleXml($forumData, $forumDir);
$this->createGradesXml($forumData, $forumDir);
@@ -43,50 +50,123 @@ class ForumExport extends ActivityExport
/**
* Get all forum data from the course.
*/
public function getData(int $forumId, int $sectionId): ?array
public function getData(int $forumId, int $sectionId, ?int $moduleId = null): ?array
{
$forum = $this->course->resources['forum'][$forumId]->obj;
$adminData = MoodleExport::getAdminUserData();
$adminId = $adminData['id'];
$threads = [];
foreach ($this->course->resources['thread'] as $threadId => $thread) {
if ($thread->obj->forum_id == $forumId) {
// Get the posts for each thread
$posts = [];
foreach ($this->course->resources['post'] as $postId => $post) {
if ($post->obj->thread_id == $threadId) {
$posts[] = [
'id' => $post->obj->post_id,
'userid' => $adminId,
'message' => $post->obj->post_text,
'created' => strtotime($post->obj->post_date),
'modified' => strtotime($post->obj->post_date),
];
}
}
$threads[] = [
'id' => $thread->obj->thread_id,
'title' => $thread->obj->thread_title,
'userid' => $adminId,
'timemodified' => strtotime($thread->obj->thread_date),
'usermodified' => $adminId,
'posts' => $posts,
];
}
if (empty($this->course->resources[RESOURCE_FORUM][$forumId])) {
return null;
}
$fileIds = [];
$forum = $this->course->resources[RESOURCE_FORUM][$forumId]->obj;
$effectiveModuleId = (int) ($moduleId ?? $forumId);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = $forumId;
}
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
$name = (string) ($forum->forum_title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_FORUM, $forumId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$descriptionResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($forum->forum_comment ?? ''),
$effectiveModuleId,
'mod_forum',
'intro',
0,
fn (int $sequence): int => $this->buildForumEmbeddedFileId()
);
$forumFiles = $descriptionResult['files'];
$threads = [];
$threadResources = $this->course->resources['thread'] ?? [];
$postResources = $this->course->resources['post'] ?? [];
foreach ($threadResources as $threadId => $thread) {
if ((int) ($thread->obj->forum_id ?? 0) !== $forumId) {
continue;
}
$threadPosts = [];
foreach ($postResources as $post) {
if ((int) ($post->obj->thread_id ?? 0) !== (int) $threadId) {
continue;
}
$postId = (int) ($post->obj->post_id ?? 0);
$postDate = strtotime((string) ($post->obj->post_date ?? 'now'));
if ($postDate === false) {
$postDate = time();
}
$messageResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($post->obj->post_text ?? ''),
$effectiveModuleId,
'mod_forum',
'post',
$postId,
fn (int $sequence): int => $this->buildForumEmbeddedFileId()
);
if (!empty($messageResult['files'])) {
$forumFiles = array_merge($forumFiles, $messageResult['files']);
}
$threadPosts[] = [
'id' => $postId,
'userid' => $adminId,
'message' => $messageResult['content'],
'created' => $postDate,
'modified' => $postDate,
];
}
usort($threadPosts, static function (array $a, array $b): int {
return ((int) $a['created']) <=> ((int) $b['created']);
});
$firstPostId = 0;
foreach ($threadPosts as $index => &$postData) {
if ($index === 0) {
$firstPostId = (int) $postData['id'];
$postData['parent'] = 0;
} else {
$postData['parent'] = $firstPostId;
}
$postData['mailed'] = 0;
$postData['subject'] = (string) ($thread->obj->thread_title ?? get_lang('Forum'));
}
unset($postData);
$threadDate = strtotime((string) ($thread->obj->thread_date ?? 'now'));
if ($threadDate === false) {
$threadDate = time();
}
$threads[] = [
'id' => (int) ($thread->obj->thread_id ?? 0),
'title' => (string) ($thread->obj->thread_title ?? ''),
'firstpost' => $firstPostId,
'userid' => $adminId,
'timemodified' => $threadDate,
'usermodified' => $adminId,
'posts' => $threadPosts,
];
}
return [
'id' => $forumId,
'moduleid' => $forumId,
'moduleid' => $effectiveModuleId,
'modulename' => 'forum',
'contextid' => $this->course->info['real_id'],
'name' => $forum->forum_title,
'description' => $forum->forum_comment,
'contextid' => $effectiveModuleId,
'name' => $name,
'description' => $descriptionResult['content'],
'timecreated' => time(),
'timemodified' => time(),
'sectionid' => $sectionId,
@@ -94,7 +174,7 @@ class ForumExport extends ActivityExport
'userid' => $adminId,
'threads' => $threads,
'users' => [$adminId],
'files' => $fileIds,
'files' => $forumFiles,
];
}
@@ -107,8 +187,8 @@ class ForumExport extends ActivityExport
$xmlContent .= '<activity id="'.$forumData['id'].'" moduleid="'.$forumData['moduleid'].'" modulename="'.$forumData['modulename'].'" contextid="'.$forumData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <forum id="'.$forumData['id'].'">'.PHP_EOL;
$xmlContent .= ' <type>general</type>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($forumData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($forumData['description']).'</intro>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $forumData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro><![CDATA['.(string) $forumData['description'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <duedate>0</duedate>'.PHP_EOL;
$xmlContent .= ' <cutoffdate>0</cutoffdate>'.PHP_EOL;
@@ -132,13 +212,12 @@ class ForumExport extends ActivityExport
$xmlContent .= ' <displaywordcount>0</displaywordcount>'.PHP_EOL;
$xmlContent .= ' <lockdiscussionafter>0</lockdiscussionafter>'.PHP_EOL;
$xmlContent .= ' <grade_forum>0</grade_forum>'.PHP_EOL;
// Add forum threads
$xmlContent .= ' <discussions>'.PHP_EOL;
foreach ($forumData['threads'] as $thread) {
$xmlContent .= ' <discussion id="'.$thread['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($thread['title']).'</name>'.PHP_EOL;
$xmlContent .= ' <firstpost>'.$thread['firstpost'].'</firstpost>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $thread['title']).'</name>'.PHP_EOL;
$xmlContent .= ' <firstpost>'.(int) ($thread['firstpost'] ?? 0).'</firstpost>'.PHP_EOL;
$xmlContent .= ' <userid>'.$thread['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <groupid>-1</groupid>'.PHP_EOL;
$xmlContent .= ' <assessed>0</assessed>'.PHP_EOL;
@@ -148,18 +227,17 @@ class ForumExport extends ActivityExport
$xmlContent .= ' <timeend>0</timeend>'.PHP_EOL;
$xmlContent .= ' <pinned>0</pinned>'.PHP_EOL;
$xmlContent .= ' <timelocked>0</timelocked>'.PHP_EOL;
// Add forum posts to the thread
$xmlContent .= ' <posts>'.PHP_EOL;
foreach ($thread['posts'] as $post) {
$xmlContent .= ' <post id="'.$post['id'].'">'.PHP_EOL;
$xmlContent .= ' <parent>'.$post['parent'].'</parent>'.PHP_EOL;
$xmlContent .= ' <parent>'.(int) ($post['parent'] ?? 0).'</parent>'.PHP_EOL;
$xmlContent .= ' <userid>'.$post['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <created>'.$post['created'].'</created>'.PHP_EOL;
$xmlContent .= ' <modified>'.$post['modified'].'</modified>'.PHP_EOL;
$xmlContent .= ' <mailed>'.$post['mailed'].'</mailed>'.PHP_EOL;
$xmlContent .= ' <subject>'.htmlspecialchars($post['subject']).'</subject>'.PHP_EOL;
$xmlContent .= ' <message>'.htmlspecialchars($post['message']).'</message>'.PHP_EOL;
$xmlContent .= ' <mailed>'.(int) ($post['mailed'] ?? 0).'</mailed>'.PHP_EOL;
$xmlContent .= ' <subject>'.htmlspecialchars((string) ($post['subject'] ?? '')).'</subject>'.PHP_EOL;
$xmlContent .= ' <message><![CDATA['.(string) ($post['message'] ?? '').']]></message>'.PHP_EOL;
$xmlContent .= ' <messageformat>1</messageformat>'.PHP_EOL;
$xmlContent .= ' <messagetrust>0</messagetrust>'.PHP_EOL;
$xmlContent .= ' <attachment></attachment>'.PHP_EOL;
@@ -170,19 +248,31 @@ class ForumExport extends ActivityExport
$xmlContent .= ' </ratings>'.PHP_EOL;
$xmlContent .= ' </post>'.PHP_EOL;
}
$xmlContent .= ' </posts>'.PHP_EOL;
$xmlContent .= ' <discussion_subs>'.PHP_EOL;
$xmlContent .= ' <discussion_sub id="'.$thread['id'].'">'.PHP_EOL;
$xmlContent .= ' <userid>'.$thread['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <preference>'.$thread['timemodified'].'</preference>'.PHP_EOL;
$xmlContent .= ' </discussion_sub>'.PHP_EOL;
$xmlContent .= ' <discussion_sub id="'.$thread['id'].'">'.PHP_EOL;
$xmlContent .= ' <userid>'.$thread['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <preference>'.$thread['timemodified'].'</preference>'.PHP_EOL;
$xmlContent .= ' </discussion_sub>'.PHP_EOL;
$xmlContent .= ' </discussion_subs>'.PHP_EOL;
$xmlContent .= ' </discussion>'.PHP_EOL;
}
$xmlContent .= ' </discussions>'.PHP_EOL;
$xmlContent .= ' </forum>'.PHP_EOL;
$xmlContent .= '</activity>';
$this->createXmlFile('forum', $xmlContent, $forumDir);
}
/**
* Build a stable embedded file id for forum files.
*/
private function buildForumEmbeddedFileId(): int
{
self::$embeddedFileGlobalSeq++;
return 1400000000 + self::$embeddedFileGlobalSeq;
}
}
+63 -19
View File
@@ -11,6 +11,8 @@ namespace moodleexport;
*/
class GlossaryExport extends ActivityExport
{
private static int $embeddedFileGlobalSeq = 0;
/**
* Export all glossary terms into a single Moodle glossary.
*
@@ -21,13 +23,18 @@ class GlossaryExport extends ActivityExport
*/
public function export($activityId, $exportDir, $moduleId, $sectionId): void
{
// Prepare the directory where the glossary export will be saved
$glossaryDir = $this->prepareActivityDirectory($exportDir, 'glossary', $moduleId);
$effectiveModuleId = (int) $moduleId;
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve glossary data
$glossaryData = $this->getData($activityId, $sectionId);
$glossaryDir = $this->prepareActivityDirectory($exportDir, 'glossary', $effectiveModuleId);
$glossaryData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($glossaryData)) {
return;
}
// Generate XML files for the glossary
$this->createGlossaryXml($glossaryData, $glossaryDir);
$this->createModuleXml($glossaryData, $glossaryDir);
$this->createGradesXml($glossaryData, $glossaryDir);
@@ -43,30 +50,57 @@ class GlossaryExport extends ActivityExport
/**
* Get all terms from the course and group them into a single glossary.
*/
public function getData(int $glossaryId, int $sectionId): ?array
public function getData(int $glossaryId, int $sectionId, ?int $moduleId = null): ?array
{
if (empty($this->course->resources['glossary'])) {
return null;
}
$adminData = MoodleExport::getAdminUserData();
$adminId = $adminData['id'];
$adminId = (int) ($adminData['id'] ?? 1);
$effectiveModuleId = (int) ($moduleId ?? $glossaryId);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = $glossaryId;
}
$glossaryEntries = [];
$glossaryFiles = [];
foreach ($this->course->resources['glossary'] as $glossary) {
$entryId = (int) ($glossary->glossary_id ?? 0);
$definitionResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($glossary->description ?? ''),
$effectiveModuleId,
'mod_glossary',
'entry',
$entryId,
fn (int $sequence): int => $this->buildGlossaryEmbeddedFileId()
);
if (!empty($definitionResult['files'])) {
$glossaryFiles = array_merge($glossaryFiles, $definitionResult['files']);
}
$glossaryEntries[] = [
'id' => $glossary->glossary_id,
'id' => $entryId,
'userid' => $adminId,
'concept' => $glossary->name,
'definition' => $glossary->description,
'concept' => $this->sanitizeMoodleActivityName((string) ($glossary->name ?? ''), 255),
'definition' => $definitionResult['content'],
'timecreated' => time(),
'timemodified' => time(),
];
}
// Return the glossary data with all terms included
$glossaryName = $this->sanitizeMoodleActivityName((string) get_lang('Glossary'), 255);
return [
'id' => $glossaryId,
'moduleid' => $glossaryId,
'moduleid' => $effectiveModuleId,
'modulename' => 'glossary',
'contextid' => $this->course->info['real_id'],
'name' => get_lang('Glossary'),
'contextid' => $effectiveModuleId,
'name' => $glossaryName,
'description' => '',
'timecreated' => time(),
'timemodified' => time(),
@@ -75,7 +109,7 @@ class GlossaryExport extends ActivityExport
'userid' => $adminId,
'entries' => $glossaryEntries,
'users' => [$adminId],
'files' => [],
'files' => $glossaryFiles,
];
}
@@ -87,7 +121,7 @@ class GlossaryExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$glossaryData['id'].'" moduleid="'.$glossaryData['moduleid'].'" modulename="'.$glossaryData['modulename'].'" contextid="'.$glossaryData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <glossary id="'.$glossaryData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($glossaryData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $glossaryData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <allowduplicatedentries>0</allowduplicatedentries>'.PHP_EOL;
@@ -114,12 +148,11 @@ class GlossaryExport extends ActivityExport
$xmlContent .= ' <completionentries>0</completionentries>'.PHP_EOL;
$xmlContent .= ' <entries>'.PHP_EOL;
// Add glossary terms (entries)
foreach ($glossaryData['entries'] as $entry) {
$xmlContent .= ' <entry id="'.$entry['id'].'">'.PHP_EOL;
$xmlContent .= ' <userid>'.$entry['userid'].'</userid>'.PHP_EOL;
$xmlContent .= ' <concept>'.htmlspecialchars($entry['concept']).'</concept>'.PHP_EOL;
$xmlContent .= ' <definition><![CDATA['.$entry['definition'].']]></definition>'.PHP_EOL;
$xmlContent .= ' <concept>'.htmlspecialchars((string) $entry['concept']).'</concept>'.PHP_EOL;
$xmlContent .= ' <definition><![CDATA['.(string) $entry['definition'].']]></definition>'.PHP_EOL;
$xmlContent .= ' <definitionformat>1</definitionformat>'.PHP_EOL;
$xmlContent .= ' <definitiontrust>0</definitiontrust>'.PHP_EOL;
$xmlContent .= ' <attachment></attachment>'.PHP_EOL;
@@ -135,6 +168,7 @@ class GlossaryExport extends ActivityExport
$xmlContent .= ' </ratings>'.PHP_EOL;
$xmlContent .= ' </entry>'.PHP_EOL;
}
$xmlContent .= ' </entries>'.PHP_EOL;
$xmlContent .= ' <entriestags></entriestags>'.PHP_EOL;
$xmlContent .= ' <categories></categories>'.PHP_EOL;
@@ -143,4 +177,14 @@ class GlossaryExport extends ActivityExport
$this->createXmlFile('glossary', $xmlContent, $glossaryDir);
}
/**
* Build a stable embedded file id for glossary files.
*/
private function buildGlossaryEmbeddedFileId(): int
{
self::$embeddedFileGlobalSeq++;
return 1500000000 + self::$embeddedFileGlobalSeq;
}
}
File diff suppressed because it is too large Load Diff
+110 -104
View File
@@ -11,6 +11,9 @@ namespace moodleexport;
*/
class PageExport extends ActivityExport
{
public const INTRO_PAGE_MODULE_ID = 910000000;
private const CONTENT_REVISION = 1;
/**
* Export a page to the specified directory.
*
@@ -21,13 +24,21 @@ class PageExport extends ActivityExport
*/
public function export($activityId, $exportDir, $moduleId, $sectionId): void
{
// Prepare the directory where the page export will be saved
$pageDir = $this->prepareActivityDirectory($exportDir, 'page', $moduleId);
$effectiveModuleId = (int) $moduleId;
if ($effectiveModuleId <= 0 && (int) $activityId === 0) {
$effectiveModuleId = self::INTRO_PAGE_MODULE_ID;
}
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve page data
$pageData = $this->getData($activityId, $sectionId);
$pageDir = $this->prepareActivityDirectory($exportDir, 'page', $effectiveModuleId);
$pageData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($pageData)) {
return;
}
// Generate XML files
$this->createPageXml($pageData, $pageDir);
$this->createModuleXml($pageData, $pageDir);
$this->createGradesXml($pageData, $pageDir);
@@ -42,91 +53,87 @@ class PageExport extends ActivityExport
/**
* Get page data dynamically from the course.
*/
public function getData(int $pageId, int $sectionId): ?array
public function getData(int $pageId, int $sectionId, ?int $moduleId = null): ?array
{
$contextid = $this->course->info['real_id'];
if ($pageId === 0) {
$introText = trim($this->course->resources[RESOURCE_TOOL_INTRO]['course_homepage']->intro_text ?? '');
if (!empty($introText)) {
$files = [];
$resources = \DocumentManager::get_resources_from_source_html($introText);
$courseInfo = api_get_course_info($this->course->code);
$adminId = MoodleExport::getAdminUserData()['id'];
foreach ($resources as [$src]) {
if (preg_match('#/document(/[^"\']+)#', $src, $matches)) {
$path = $matches[1];
$docId = \DocumentManager::get_document_id($courseInfo, $path);
if ($docId) {
$this->course->used_page_doc_ids[] = $docId;
$document = \DocumentManager::get_document_data_by_id($docId, $this->course->code);
if ($document) {
$contenthash = hash('sha1', basename($document['path']));
$mimetype = (new FileExport($this->course))->getMimeType($document['path']);
$files[] = [
'id' => $document['id'],
'contenthash' => $contenthash,
'contextid' => $contextid,
'component' => 'mod_page',
'filearea' => 'content',
'itemid' => 1,
'filepath' => '/Documents/',
'documentpath' => 'document' . $document['path'],
'filename' => basename($document['path']),
'userid' => $adminId,
'filesize' => $document['size'],
'mimetype' => $mimetype,
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => $document['title'],
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
}
}
}
return [
'id' => 0,
'moduleid' => 0,
'modulename' => 'page',
'contextid' => $contextid,
'name' => get_lang('Introduction'),
'intro' => '',
'content' => $this->normalizeContent($introText),
'sectionid' => $sectionId,
'sectionnumber' => 1,
'display' => 0,
'timemodified' => time(),
'users' => [],
'files' => $files,
];
$introText = trim((string) ($this->course->resources[RESOURCE_TOOL_INTRO]['course_homepage']->intro_text ?? ''));
if ($introText === '') {
return null;
}
$effectiveModuleId = (int) ($moduleId ?? self::INTRO_PAGE_MODULE_ID);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = self::INTRO_PAGE_MODULE_ID;
}
$introResult = $this->extractEmbeddedFilesAndNormalizeContent(
$introText,
$effectiveModuleId,
'mod_page',
'content',
0,
fn (int $sequence): int => $this->buildPageFileId($effectiveModuleId, $effectiveModuleId, $sequence, true)
);
return [
'id' => $effectiveModuleId,
'moduleid' => $effectiveModuleId,
'modulename' => 'page',
'contextid' => $effectiveModuleId,
'name' => $this->sanitizeMoodleActivityName((string) get_lang('Introduction'), 255),
'intro' => '',
'content' => $introResult['content'],
'sectionid' => $sectionId,
'sectionnumber' => 1,
'display' => 5,
'timemodified' => time(),
'users' => [],
'files' => $introResult['files'],
];
}
$pageResources = $this->course->resources[RESOURCE_DOCUMENT] ?? [];
foreach ($pageResources as $page) {
if ($page->source_id == $pageId) {
return [
'id' => $page->source_id,
'moduleid' => $page->source_id,
'modulename' => 'page',
'contextid' => $contextid,
'name' => $page->title,
'intro' => $page->comment ?? '',
'content' => $this->normalizeContent($this->getPageContent($page)),
'sectionid' => $sectionId,
'sectionnumber' => 1,
'display' => 0,
'timemodified' => time(),
'users' => [],
'files' => [],
];
if ((int) $page->source_id !== $pageId) {
continue;
}
$effectiveModuleId = (int) ($moduleId ?? $page->source_id);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $page->source_id;
}
$pageName = (string) ($page->title ?? '');
if ($sectionId > 0) {
$pageName = $this->lpItemTitle($sectionId, RESOURCE_DOCUMENT, $pageId, $pageName);
}
$pageName = $this->sanitizeMoodleActivityName($pageName, 255);
$rawContent = $this->getPageContent($page);
$pageResult = $this->extractEmbeddedFilesAndNormalizeContent(
$rawContent,
$effectiveModuleId,
'mod_page',
'content',
0,
fn (int $sequence): int => $this->buildPageFileId($effectiveModuleId, $effectiveModuleId, $sequence, false)
);
return [
'id' => (int) $page->source_id,
'moduleid' => $effectiveModuleId,
'modulename' => 'page',
'contextid' => $effectiveModuleId,
'name' => $pageName,
'intro' => (string) ($page->comment ?? ''),
'content' => $pageResult['content'],
'sectionid' => $sectionId,
'sectionnumber' => 1,
'display' => 5,
'timemodified' => time(),
'users' => [],
'files' => $pageResult['files'],
];
}
return null;
@@ -140,15 +147,15 @@ class PageExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$pageData['id'].'" moduleid="'.$pageData['moduleid'].'" modulename="page" contextid="'.$pageData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <page id="'.$pageData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($pageData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($pageData['intro']).'</intro>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $pageData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro><![CDATA['.(string) $pageData['intro'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <content>'.htmlspecialchars($pageData['content']).'</content>'.PHP_EOL;
$xmlContent .= ' <content><![CDATA['.(string) $pageData['content'].']]></content>'.PHP_EOL;
$xmlContent .= ' <contentformat>1</contentformat>'.PHP_EOL;
$xmlContent .= ' <legacyfiles>0</legacyfiles>'.PHP_EOL;
$xmlContent .= ' <display>5</display>'.PHP_EOL;
$xmlContent .= ' <display>'.(int) ($pageData['display'] ?? 5).'</display>'.PHP_EOL;
$xmlContent .= ' <displayoptions>a:3:{s:12:"printheading";s:1:"1";s:10:"printintro";s:1:"0";s:17:"printlastmodified";s:1:"1";}</displayoptions>'.PHP_EOL;
$xmlContent .= ' <revision>1</revision>'.PHP_EOL;
$xmlContent .= ' <revision>'.self::CONTENT_REVISION.'</revision>'.PHP_EOL;
$xmlContent .= ' <timemodified>'.$pageData['timemodified'].'</timemodified>'.PHP_EOL;
$xmlContent .= ' </page>'.PHP_EOL;
$xmlContent .= '</activity>';
@@ -156,22 +163,16 @@ class PageExport extends ActivityExport
$this->createXmlFile('page', $xmlContent, $pageDir);
}
private function normalizeContent(string $html): string
/**
* Build a unique files.xml id for page embedded files.
*/
private function buildPageFileId(int $moduleId, int $contextId, int $sequence, bool $isIntro): int
{
return preg_replace_callback(
'#<img[^>]+src=["\'](?<url>[^"\']+)["\']#i',
function ($match) {
$src = $match['url'];
if ($isIntro) {
return 1150000000 + max(0, $contextId) + max(1, $sequence);
}
if (preg_match('#/courses/[^/]+/document/(.+)$#', $src, $parts)) {
$filename = basename($parts[1]);
return str_replace($src, '@@PLUGINFILE@@/Documents/' . $filename, $match[0]);
}
return $match[0];
},
$html
);
return 1100000000 + max(0, $moduleId) + max(1, $sequence);
}
/**
@@ -179,10 +180,15 @@ class PageExport extends ActivityExport
*/
private function getPageContent(object $page): string
{
if ($page->file_type === 'file') {
return file_get_contents($this->course->path.$page->path);
if (($page->file_type ?? '') !== 'file') {
return '';
}
return '';
$absolutePath = $this->course->path.$page->path;
if (!is_file($absolutePath)) {
return '';
}
return (string) file_get_contents($absolutePath);
}
}
+493 -147
View File
@@ -14,6 +14,8 @@ use FillBlanks;
*/
class QuizExport extends ActivityExport
{
private static $embeddedFileGlobalSeq = 0;
/**
* Export a quiz to the specified directory.
*
@@ -24,13 +26,13 @@ class QuizExport extends ActivityExport
*/
public function export($activityId, $exportDir, $moduleId, $sectionId): void
{
// Prepare the directory where the quiz export will be saved
$quizDir = $this->prepareActivityDirectory($exportDir, 'quiz', $moduleId);
$quizDir = $this->prepareActivityDirectory($exportDir, 'quiz', (int) $moduleId);
$quizData = $this->getData((int) $activityId, (int) $sectionId, (int) $moduleId);
// Retrieve quiz data
$quizData = $this->getData($activityId, $sectionId);
if (empty($quizData)) {
return;
}
// Generate XML files
$this->createQuizXml($quizData, $quizDir);
$this->createModuleXml($quizData, $quizDir);
$this->createGradesXml($quizData, $quizDir);
@@ -44,71 +46,188 @@ class QuizExport extends ActivityExport
$this->createCalendarXml($quizData, $quizDir);
}
/**
* Moodle requires a valid question behaviour.
*/
private function sanitizePreferredBehaviour($raw): string
{
$behaviour = trim((string) $raw);
$allowed = [
'deferredfeedback',
'adaptive',
'adaptivenopenalty',
'immediatefeedback',
'interactive',
'manualgraded',
'deferredcbm',
'immediatecbm',
'interactivecountback',
];
if ($behaviour === '' || !in_array($behaviour, $allowed, true)) {
return 'deferredfeedback';
}
return $behaviour;
}
/**
* Normalize an integer-like value.
*/
private function sanitizeInt($raw, int $default): int
{
if ($raw === null) {
return $default;
}
if (is_string($raw) && trim($raw) === '') {
return $default;
}
return is_numeric($raw) ? (int) $raw : $default;
}
/**
* Parse a float or return null when missing.
*/
private function parseNullableFloat($raw): ?float
{
if ($raw === null) {
return null;
}
if (is_string($raw) && trim($raw) === '') {
return null;
}
return is_numeric($raw) ? (float) $raw : null;
}
/**
* Moodle quiz navmethod accepted values.
*/
private function sanitizeNavMethod($raw): string
{
$v = trim((string) $raw);
return in_array($v, ['free', 'sequential'], true) ? $v : 'free';
}
/**
* Build a stable question category id per exported quiz occurrence.
*/
private function buildQuizQuestionCategoryId(int $moduleId): int
{
return 1000000000 + max(1, $moduleId);
}
/**
* Retrieves the quiz data.
*/
public function getData(int $quizId, int $sectionId): array
public function getData(int $quizId, int $sectionId, ?int $moduleId = null): array
{
$quizResources = $this->course->resources[RESOURCE_QUIZ];
$quizResources = $this->course->resources[RESOURCE_QUIZ] ?? [];
foreach ($quizResources as $quiz) {
if ($quiz->obj->iid == -1) {
continue;
}
if ($quiz->obj->iid == $quizId) {
$contextid = $quiz->obj->c_id;
return [
'id' => $quiz->obj->iid,
'name' => $quiz->obj->title,
'intro' => $quiz->obj->description,
'timeopen' => $quiz->obj->start_time ?? 0,
'timeclose' => $quiz->obj->end_time ?? 0,
'timelimit' => $quiz->obj->timelimit ?? 0,
'grademethod' => $quiz->obj->grademethod ?? 1,
'decimalpoints' => $quiz->obj->decimalpoints ?? 2,
'sumgrades' => $quiz->obj->sumgrades ?? 0,
'grade' => $quiz->obj->grade ?? 0,
'questionsperpage' => $quiz->obj->questionsperpage ?? 1,
'preferredbehaviour' => $quiz->obj->preferredbehaviour ?? 'deferredfeedback',
'shuffleanswers' => $quiz->obj->shuffleanswers ?? 1,
'questions' => $this->getQuestionsForQuiz($quizId),
'feedbacks' => $this->getFeedbacksForQuiz($quizId),
'sectionid' => $sectionId,
'moduleid' => $quiz->obj->iid ?? 0,
'modulename' => 'quiz',
'contextid' => $contextid,
'overduehandling' => $quiz->obj->overduehandling ?? 'autosubmit',
'graceperiod' => $quiz->obj->graceperiod ?? 0,
'canredoquestions' => $quiz->obj->canredoquestions ?? 0,
'attempts_number' => $quiz->obj->attempts_number ?? 0,
'attemptonlast' => $quiz->obj->attemptonlast ?? 0,
'questiondecimalpoints' => $quiz->obj->questiondecimalpoints ?? 2,
'reviewattempt' => $quiz->obj->reviewattempt ?? 0,
'reviewcorrectness' => $quiz->obj->reviewcorrectness ?? 0,
'reviewmarks' => $quiz->obj->reviewmarks ?? 0,
'reviewspecificfeedback' => $quiz->obj->reviewspecificfeedback ?? 0,
'reviewgeneralfeedback' => $quiz->obj->reviewgeneralfeedback ?? 0,
'reviewrightanswer' => $quiz->obj->reviewrightanswer ?? 0,
'reviewoverallfeedback' => $quiz->obj->reviewoverallfeedback ?? 0,
'timecreated' => $quiz->obj->insert_date ?? time(),
'timemodified' => $quiz->obj->lastedit_date ?? time(),
'password' => $quiz->obj->password ?? '',
'subnet' => $quiz->obj->subnet ?? '',
'browsersecurity' => $quiz->obj->browsersecurity ?? '-',
'delay1' => $quiz->obj->delay1 ?? 0,
'delay2' => $quiz->obj->delay2 ?? 0,
'showuserpicture' => $quiz->obj->showuserpicture ?? 0,
'showblocks' => $quiz->obj->showblocks ?? 0,
'completionattemptsexhausted' => $quiz->obj->completionattemptsexhausted ?? 0,
'completionpass' => $quiz->obj->completionpass ?? 0,
'completionminattempts' => $quiz->obj->completionminattempts ?? 0,
'allowofflineattempts' => $quiz->obj->allowofflineattempts ?? 0,
'users' => [],
'files' => [],
];
if ((int) $quiz->obj->iid !== $quizId) {
continue;
}
$courseCode = $this->course->info['code'] ?? ($this->course->code ?? '');
$courseInfo = api_get_course_info($courseCode);
$courseRealId = (int) ($courseInfo['real_id'] ?? 0);
$contextid = $this->buildQuestionBankContextId($courseRealId);
$effectiveModuleId = (int) ($moduleId ?? ($quiz->obj->iid ?? 0));
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) ($quiz->obj->iid ?? 0);
}
$questionCategoryId = $this->buildQuizQuestionCategoryId($effectiveModuleId);
$name = (string) ($quiz->obj->title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_QUIZ, $quizId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
$quizFiles = [];
$questions = $this->getQuestionsForQuiz(
$quizId,
$questionCategoryId,
$contextid,
$courseInfo,
$adminId,
$quizFiles
);
$computedSumgrades = 0.0;
foreach ($questions as $q) {
$computedSumgrades += (float) ($q['maxmark'] ?? 0.0);
}
$rawSumgrades = $this->parseNullableFloat($quiz->obj->sumgrades ?? null);
$rawGrade = $this->parseNullableFloat($quiz->obj->grade ?? null);
$sumgrades = $rawSumgrades !== null ? $rawSumgrades : $computedSumgrades;
$grade = $rawGrade !== null ? $rawGrade : ($sumgrades > 0 ? $sumgrades : 0.0);
return [
'id' => (int) ($quiz->obj->iid ?? 0),
'name' => $name,
'intro' => (string) ($quiz->obj->description ?? ''),
'timeopen' => $this->sanitizeInt($quiz->obj->start_time ?? null, 0),
'timeclose' => $this->sanitizeInt($quiz->obj->end_time ?? null, 0),
'timelimit' => $this->sanitizeInt($quiz->obj->timelimit ?? null, 0),
'grademethod' => $this->sanitizeInt($quiz->obj->grademethod ?? null, 1),
'decimalpoints' => $this->sanitizeInt($quiz->obj->decimalpoints ?? null, 2),
'sumgrades' => $sumgrades,
'grade' => $grade,
'questionsperpage' => $this->sanitizeInt($quiz->obj->questionsperpage ?? null, 1),
'preferredbehaviour' => $this->sanitizePreferredBehaviour($quiz->obj->preferredbehaviour ?? null),
'shuffleanswers' => $this->sanitizeInt($quiz->obj->shuffleanswers ?? null, 1),
'navmethod' => $this->sanitizeNavMethod($quiz->obj->navmethod ?? null),
'question_category_id' => $questionCategoryId,
'questions' => $questions,
'files' => $quizFiles,
'feedbacks' => $this->getFeedbacksForQuiz($quizId),
'sectionid' => $sectionId,
'moduleid' => $effectiveModuleId,
'modulename' => 'quiz',
'contextid' => $contextid,
'courseid' => $courseRealId,
'overduehandling' => (string) ($quiz->obj->overduehandling ?? 'autosubmit'),
'graceperiod' => $this->sanitizeInt($quiz->obj->graceperiod ?? null, 0),
'canredoquestions' => $this->sanitizeInt($quiz->obj->canredoquestions ?? null, 0),
'attempts_number' => $this->sanitizeInt($quiz->obj->attempts_number ?? null, 0),
'attemptonlast' => $this->sanitizeInt($quiz->obj->attemptonlast ?? null, 0),
'questiondecimalpoints' => $this->sanitizeInt($quiz->obj->questiondecimalpoints ?? null, 2),
'reviewattempt' => $this->sanitizeInt($quiz->obj->reviewattempt ?? null, 0),
'reviewcorrectness' => $this->sanitizeInt($quiz->obj->reviewcorrectness ?? null, 0),
'reviewmarks' => $this->sanitizeInt($quiz->obj->reviewmarks ?? null, 0),
'reviewspecificfeedback' => $this->sanitizeInt($quiz->obj->reviewspecificfeedback ?? null, 0),
'reviewgeneralfeedback' => $this->sanitizeInt($quiz->obj->reviewgeneralfeedback ?? null, 0),
'reviewrightanswer' => $this->sanitizeInt($quiz->obj->reviewrightanswer ?? null, 0),
'reviewoverallfeedback' => $this->sanitizeInt($quiz->obj->reviewoverallfeedback ?? null, 0),
'timecreated' => $this->sanitizeInt($quiz->obj->insert_date ?? null, time()),
'timemodified' => $this->sanitizeInt($quiz->obj->lastedit_date ?? null, time()),
'password' => (string) ($quiz->obj->password ?? ''),
'subnet' => (string) ($quiz->obj->subnet ?? ''),
'browsersecurity' => (string) ($quiz->obj->browsersecurity ?? '-'),
'delay1' => $this->sanitizeInt($quiz->obj->delay1 ?? null, 0),
'delay2' => $this->sanitizeInt($quiz->obj->delay2 ?? null, 0),
'showuserpicture' => $this->sanitizeInt($quiz->obj->showuserpicture ?? null, 0),
'showblocks' => $this->sanitizeInt($quiz->obj->showblocks ?? null, 0),
'completionattemptsexhausted' => $this->sanitizeInt($quiz->obj->completionattemptsexhausted ?? null, 0),
'completionpass' => $this->sanitizeInt($quiz->obj->completionpass ?? null, 0),
'completionminattempts' => $this->sanitizeInt($quiz->obj->completionminattempts ?? null, 0),
'allowofflineattempts' => $this->sanitizeInt($quiz->obj->allowofflineattempts ?? null, 0),
'users' => [],
];
}
return [];
@@ -119,16 +238,19 @@ class QuizExport extends ActivityExport
*/
public function exportQuestion(array $question): string
{
$questionText = (string) ($question['questiontext'] ?? 'No question text');
$questionName = $this->sanitizeQuestionName($questionText, 255);
$xmlContent = ' <question id="'.($question['id'] ?? '0').'">'.PHP_EOL;
$xmlContent .= ' <parent>0</parent>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($question['questiontext'] ?? 'No question text').'</name>'.PHP_EOL;
$xmlContent .= ' <questiontext>'.htmlspecialchars($question['questiontext'] ?? 'No question text').'</questiontext>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($questionName).'</name>'.PHP_EOL;
$xmlContent .= ' <questiontext>'.htmlspecialchars($questionText).'</questiontext>'.PHP_EOL;
$xmlContent .= ' <questiontextformat>1</questiontextformat>'.PHP_EOL;
$xmlContent .= ' <generalfeedback></generalfeedback>'.PHP_EOL;
$xmlContent .= ' <generalfeedbackformat>1</generalfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <defaultmark>'.($question['maxmark'] ?? '0').'</defaultmark>'.PHP_EOL;
$xmlContent .= ' <penalty>0.3333333</penalty>'.PHP_EOL;
$xmlContent .= ' <qtype>'.htmlspecialchars(str_replace('_nosingle', '', $question['qtype']) ?? 'unknown').'</qtype>'.PHP_EOL;
$xmlContent .= ' <qtype>'.htmlspecialchars((string) str_replace('_nosingle', '', $question['qtype'] ?? 'unknown')).'</qtype>'.PHP_EOL;
$xmlContent .= ' <length>1</length>'.PHP_EOL;
$xmlContent .= ' <stamp>moodle+'.time().'+QUESTIONSTAMP</stamp>'.PHP_EOL;
$xmlContent .= ' <version>moodle+'.time().'+VERSIONSTAMP</version>'.PHP_EOL;
@@ -138,7 +260,6 @@ class QuizExport extends ActivityExport
$xmlContent .= ' <createdby>2</createdby>'.PHP_EOL;
$xmlContent .= ' <modifiedby>2</modifiedby>'.PHP_EOL;
// Add question type-specific content
switch ($question['qtype']) {
case 'multichoice':
$xmlContent .= $this->exportMultichoiceQuestion($question);
@@ -165,24 +286,58 @@ class QuizExport extends ActivityExport
/**
* Retrieves the questions for a specific quiz.
*/
private function getQuestionsForQuiz(int $quizId): array
{
private function getQuestionsForQuiz(
int $quizId,
int $questionCategoryId,
int $contextId,
array $courseInfo,
int $adminId,
array &$quizFiles
): array {
$questions = [];
$quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? [];
foreach ($quizResources as $questionId => $questionData) {
if (in_array($questionId, $this->course->resources[RESOURCE_QUIZ][$quizId]->obj->question_ids)) {
$categoryId = $questionData->question_category ?? 0;
$categoryId = $categoryId > 0 ? $categoryId : $this->getDefaultCategoryId();
$questions[] = [
'id' => $questionData->source_id,
'questiontext' => $questionData->question,
'qtype' => $this->mapQuestionType($questionData->quiz_type),
'questioncategoryid' => $categoryId,
'answers' => $this->getAnswersForQuestion($questionData->source_id),
'maxmark' => $questionData->ponderation ?? 1,
];
if (!in_array($questionId, $this->course->resources[RESOURCE_QUIZ][$quizId]->obj->question_ids)) {
continue;
}
$qRes = $this->extractQuestionEmbeddedFilesAndNormalizeContent(
(string) ($questionData->question ?? ''),
$contextId,
'questiontext',
(int) $questionData->source_id,
'',
$courseInfo,
$adminId
);
if (!empty($qRes['files'])) {
$quizFiles = array_merge($quizFiles, $qRes['files']);
}
$answers = $this->getAnswersForQuestion(
(int) $questionData->source_id,
$contextId,
$courseInfo,
$adminId,
$quizFiles
);
$mappedType = $this->mapQuestionType((string) $questionData->quiz_type);
if ($mappedType === 'unknown') {
continue;
}
$questions[] = [
'id' => (int) $questionData->source_id,
'questiontext' => $qRes['content'],
'qtype' => $mappedType,
'questioncategoryid' => $questionCategoryId,
'answers' => $answers,
'maxmark' => $questionData->ponderation ?? 1,
];
}
return $questions;
@@ -194,36 +349,69 @@ class QuizExport extends ActivityExport
private function mapQuestionType(string $quizType): string
{
switch ($quizType) {
case UNIQUE_ANSWER: return 'multichoice';
case MULTIPLE_ANSWER: return 'multichoice_nosingle';
case FILL_IN_BLANKS: return 'match';
case FREE_ANSWER: return 'shortanswer';
case CALCULATED_ANSWER: return 'calculated';
case UPLOAD_ANSWER: return 'fileupload';
default: return 'unknown';
case UNIQUE_ANSWER:
return 'multichoice';
case MULTIPLE_ANSWER:
return 'multichoice_nosingle';
case FILL_IN_BLANKS:
return 'match';
case FREE_ANSWER:
return 'shortanswer';
case CALCULATED_ANSWER:
return 'calculated';
case UPLOAD_ANSWER:
return 'fileupload';
default:
return 'unknown';
}
}
/**
* Retrieves the answers for a specific question ID.
*/
private function getAnswersForQuestion(int $questionId): array
{
static $globalCounter = 0;
private function getAnswersForQuestion(
int $questionId,
int $contextId,
array $courseInfo,
int $adminId,
array &$collectedFiles
): array {
$answers = [];
$quizResources = $this->course->resources[RESOURCE_QUIZQUESTION] ?? [];
foreach ($quizResources as $questionData) {
if ($questionData->source_id == $questionId) {
foreach ($questionData->answers as $answer) {
$globalCounter++;
$answers[] = [
'id' => $questionId * 1000 + $globalCounter,
'text' => $answer['answer'],
'fraction' => $answer['correct'] == '1' ? 100 : 0,
'feedback' => $answer['comment'],
];
if ((int) $questionData->source_id !== $questionId) {
continue;
}
$answerIndex = 0;
foreach ($questionData->answers as $answer) {
$answerIndex++;
$answerId = $this->buildStableQuestionAnswerId($questionId, $answerIndex);
$rawText = (string) ($answer['answer'] ?? '');
$res = $this->extractQuestionEmbeddedFilesAndNormalizeContent(
$rawText,
$contextId,
'answer',
$answerId,
'',
$courseInfo,
$adminId
);
if (!empty($res['files'])) {
$collectedFiles = array_merge($collectedFiles, $res['files']);
}
$answers[] = [
'id' => $answerId,
'text' => $res['content'],
'fraction' => (($answer['correct'] ?? '0') == '1') ? 100 : 0,
'feedback' => (string) ($answer['comment'] ?? ''),
];
}
}
@@ -239,9 +427,9 @@ class QuizExport extends ActivityExport
$quizResources = $this->course->resources[RESOURCE_QUIZ] ?? [];
foreach ($quizResources as $quiz) {
if ($quiz->obj->iid == $quizId) {
if ((int) $quiz->obj->iid === $quizId) {
$feedbacks[] = [
'feedbacktext' => $quiz->obj->description ?? '',
'feedbacktext' => (string) ($quiz->obj->description ?? ''),
'mingrade' => 0.00000,
'maxgrade' => $quiz->obj->grade ?? 10.00000,
];
@@ -251,36 +439,42 @@ class QuizExport extends ActivityExport
return $feedbacks;
}
private function getDefaultCategoryId(): int
{
return 1;
}
/**
* Creates the quiz.xml file.
*/
private function createQuizXml(array $quizData, string $destinationDir): void
{
$preferredBehaviour = trim((string) ($quizData['preferredbehaviour'] ?? ''));
if ($preferredBehaviour === '') {
$preferredBehaviour = 'deferredfeedback';
}
$gradeMethod = (int) ($quizData['grademethod'] ?? 1);
$decimalPoints = (int) ($quizData['decimalpoints'] ?? 2);
$questionsPerPage = (int) ($quizData['questionsperpage'] ?? 1);
$navMethod = (string) ($quizData['navmethod'] ?? 'free');
$shuffleAnswers = (int) ($quizData['shuffleanswers'] ?? 1);
$sumGrades = (float) ($quizData['sumgrades'] ?? 0.0);
$grade = (float) ($quizData['grade'] ?? 0.0);
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$quizData['id'].'" moduleid="'.$quizData['moduleid'].'" modulename="quiz" contextid="'.$quizData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <quiz id="'.$quizData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($quizData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($quizData['intro']).'</intro>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $quizData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars((string) $quizData['intro']).'</intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <timeopen>'.($quizData['timeopen'] ?? 0).'</timeopen>'.PHP_EOL;
$xmlContent .= ' <timeclose>'.($quizData['timeclose'] ?? 0).'</timeclose>'.PHP_EOL;
$xmlContent .= ' <timelimit>'.($quizData['timelimit'] ?? 0).'</timelimit>'.PHP_EOL;
$xmlContent .= ' <overduehandling>'.($quizData['overduehandling'] ?? 'autosubmit').'</overduehandling>'.PHP_EOL;
$xmlContent .= ' <graceperiod>'.($quizData['graceperiod'] ?? 0).'</graceperiod>'.PHP_EOL;
$xmlContent .= ' <preferredbehaviour>'.htmlspecialchars($quizData['preferredbehaviour']).'</preferredbehaviour>'.PHP_EOL;
$xmlContent .= ' <preferredbehaviour>'.htmlspecialchars($preferredBehaviour).'</preferredbehaviour>'.PHP_EOL;
$xmlContent .= ' <canredoquestions>'.($quizData['canredoquestions'] ?? 0).'</canredoquestions>'.PHP_EOL;
$xmlContent .= ' <attempts_number>'.($quizData['attempts_number'] ?? 0).'</attempts_number>'.PHP_EOL;
$xmlContent .= ' <attemptonlast>'.($quizData['attemptonlast'] ?? 0).'</attemptonlast>'.PHP_EOL;
$xmlContent .= ' <grademethod>'.$quizData['grademethod'].'</grademethod>'.PHP_EOL;
$xmlContent .= ' <decimalpoints>'.$quizData['decimalpoints'].'</decimalpoints>'.PHP_EOL;
$xmlContent .= ' <grademethod>'.$gradeMethod.'</grademethod>'.PHP_EOL;
$xmlContent .= ' <decimalpoints>'.$decimalPoints.'</decimalpoints>'.PHP_EOL;
$xmlContent .= ' <questiondecimalpoints>'.($quizData['questiondecimalpoints'] ?? -1).'</questiondecimalpoints>'.PHP_EOL;
// Review options
$xmlContent .= ' <reviewattempt>'.($quizData['reviewattempt'] ?? 69888).'</reviewattempt>'.PHP_EOL;
$xmlContent .= ' <reviewcorrectness>'.($quizData['reviewcorrectness'] ?? 4352).'</reviewcorrectness>'.PHP_EOL;
$xmlContent .= ' <reviewmarks>'.($quizData['reviewmarks'] ?? 4352).'</reviewmarks>'.PHP_EOL;
@@ -288,36 +482,26 @@ class QuizExport extends ActivityExport
$xmlContent .= ' <reviewgeneralfeedback>'.($quizData['reviewgeneralfeedback'] ?? 4352).'</reviewgeneralfeedback>'.PHP_EOL;
$xmlContent .= ' <reviewrightanswer>'.($quizData['reviewrightanswer'] ?? 4352).'</reviewrightanswer>'.PHP_EOL;
$xmlContent .= ' <reviewoverallfeedback>'.($quizData['reviewoverallfeedback'] ?? 4352).'</reviewoverallfeedback>'.PHP_EOL;
// Navigation and presentation settings
$xmlContent .= ' <questionsperpage>'.$quizData['questionsperpage'].'</questionsperpage>'.PHP_EOL;
$xmlContent .= ' <navmethod>'.htmlspecialchars($quizData['navmethod']).'</navmethod>'.PHP_EOL;
$xmlContent .= ' <shuffleanswers>'.$quizData['shuffleanswers'].'</shuffleanswers>'.PHP_EOL;
$xmlContent .= ' <sumgrades>'.$quizData['sumgrades'].'</sumgrades>'.PHP_EOL;
$xmlContent .= ' <grade>'.$quizData['grade'].'</grade>'.PHP_EOL;
// Timing and security
$xmlContent .= ' <questionsperpage>'.$questionsPerPage.'</questionsperpage>'.PHP_EOL;
$xmlContent .= ' <navmethod>'.htmlspecialchars($navMethod).'</navmethod>'.PHP_EOL;
$xmlContent .= ' <shuffleanswers>'.$shuffleAnswers.'</shuffleanswers>'.PHP_EOL;
$xmlContent .= ' <sumgrades>'.$sumGrades.'</sumgrades>'.PHP_EOL;
$xmlContent .= ' <grade>'.$grade.'</grade>'.PHP_EOL;
$xmlContent .= ' <timecreated>'.($quizData['timecreated'] ?? time()).'</timecreated>'.PHP_EOL;
$xmlContent .= ' <timemodified>'.($quizData['timemodified'] ?? time()).'</timemodified>'.PHP_EOL;
$xmlContent .= ' <password>'.(isset($quizData['password']) ? htmlspecialchars($quizData['password']) : '').'</password>'.PHP_EOL;
$xmlContent .= ' <subnet>'.(isset($quizData['subnet']) ? htmlspecialchars($quizData['subnet']) : '').'</subnet>'.PHP_EOL;
$xmlContent .= ' <browsersecurity>'.(isset($quizData['browsersecurity']) ? htmlspecialchars($quizData['browsersecurity']) : '-').'</browsersecurity>'.PHP_EOL;
$xmlContent .= ' <password>'.(isset($quizData['password']) ? htmlspecialchars((string) $quizData['password']) : '').'</password>'.PHP_EOL;
$xmlContent .= ' <subnet>'.(isset($quizData['subnet']) ? htmlspecialchars((string) $quizData['subnet']) : '').'</subnet>'.PHP_EOL;
$xmlContent .= ' <browsersecurity>'.(isset($quizData['browsersecurity']) ? htmlspecialchars((string) $quizData['browsersecurity']) : '-').'</browsersecurity>'.PHP_EOL;
$xmlContent .= ' <delay1>'.($quizData['delay1'] ?? 0).'</delay1>'.PHP_EOL;
$xmlContent .= ' <delay2>'.($quizData['delay2'] ?? 0).'</delay2>'.PHP_EOL;
// Additional options
$xmlContent .= ' <showuserpicture>'.($quizData['showuserpicture'] ?? 0).'</showuserpicture>'.PHP_EOL;
$xmlContent .= ' <showblocks>'.($quizData['showblocks'] ?? 0).'</showblocks>'.PHP_EOL;
$xmlContent .= ' <completionattemptsexhausted>'.($quizData['completionattemptsexhausted'] ?? 0).'</completionattemptsexhausted>'.PHP_EOL;
$xmlContent .= ' <completionpass>'.($quizData['completionpass'] ?? 0).'</completionpass>'.PHP_EOL;
$xmlContent .= ' <completionminattempts>'.($quizData['completionminattempts'] ?? 0).'</completionminattempts>'.PHP_EOL;
$xmlContent .= ' <allowofflineattempts>'.($quizData['allowofflineattempts'] ?? 0).'</allowofflineattempts>'.PHP_EOL;
// Subplugin, if applicable
$xmlContent .= ' <subplugin_quizaccess_seb_quiz>'.PHP_EOL;
$xmlContent .= ' </subplugin_quizaccess_seb_quiz>'.PHP_EOL;
// Add question instances
$xmlContent .= ' <question_instances>'.PHP_EOL;
$slotIndex = 1;
foreach ($quizData['questions'] as $question) {
@@ -333,37 +517,28 @@ class QuizExport extends ActivityExport
$slotIndex++;
}
$xmlContent .= ' </question_instances>'.PHP_EOL;
// Quiz sections
$xmlContent .= ' <sections>'.PHP_EOL;
$xmlContent .= ' <section id="'.$quizData['id'].'">'.PHP_EOL;
$xmlContent .= ' <firstslot>1</firstslot>'.PHP_EOL;
$xmlContent .= ' <shufflequestions>0</shufflequestions>'.PHP_EOL;
$xmlContent .= ' </section>'.PHP_EOL;
$xmlContent .= ' </sections>'.PHP_EOL;
// Add feedbacks
$xmlContent .= ' <feedbacks>'.PHP_EOL;
foreach ($quizData['feedbacks'] as $feedback) {
$xmlContent .= ' <feedback id="'.$quizData['id'].'">'.PHP_EOL;
$xmlContent .= ' <feedbacktext>'.htmlspecialchars($feedback['feedbacktext']).'</feedbacktext>'.PHP_EOL;
$xmlContent .= ' <feedbacktext>'.htmlspecialchars((string) $feedback['feedbacktext']).'</feedbacktext>'.PHP_EOL;
$xmlContent .= ' <feedbacktextformat>1</feedbacktextformat>'.PHP_EOL;
$xmlContent .= ' <mingrade>'.$feedback['mingrade'].'</mingrade>'.PHP_EOL;
$xmlContent .= ' <maxgrade>'.$feedback['maxgrade'].'</maxgrade>'.PHP_EOL;
$xmlContent .= ' </feedback>'.PHP_EOL;
}
$xmlContent .= ' </feedbacks>'.PHP_EOL;
// Complete with placeholders for attempts and grades
$xmlContent .= ' <overrides>'.PHP_EOL.' </overrides>'.PHP_EOL;
$xmlContent .= ' <grades>'.PHP_EOL.' </grades>'.PHP_EOL;
$xmlContent .= ' <attempts>'.PHP_EOL.' </attempts>'.PHP_EOL;
// Close the activity tag
$xmlContent .= ' </quiz>'.PHP_EOL;
$xmlContent .= '</activity>'.PHP_EOL;
// Save the XML file
$xmlFile = $destinationDir.'/quiz.xml';
if (file_put_contents($xmlFile, $xmlContent) === false) {
throw new Exception(get_lang('ErrorCreatingQuizXml'));
@@ -404,10 +579,7 @@ class QuizExport extends ActivityExport
*/
private function exportMultichoiceNosingleQuestion(array $question): string
{
// Similar structure to exportMultichoiceQuestion, but with single=0
$xmlContent = str_replace('<single>1</single>', '<single>0</single>', $this->exportMultichoiceQuestion($question));
return $xmlContent;
return str_replace('<single>1</single>', '<single>0</single>', $this->exportMultichoiceQuestion($question));
}
/**
@@ -457,13 +629,13 @@ class QuizExport extends ActivityExport
private function exportMatchQuestion(array $question): string
{
$xmlContent = ' <plugin_qtype_match_question>'.PHP_EOL;
$xmlContent .= ' <matchoptions id="'.htmlspecialchars($question['id'] ?? '0').'">'.PHP_EOL;
$xmlContent .= ' <matchoptions id="'.htmlspecialchars((string) ($question['id'] ?? '0')).'">'.PHP_EOL;
$xmlContent .= ' <shuffleanswers>1</shuffleanswers>'.PHP_EOL;
$xmlContent .= ' <correctfeedback>'.htmlspecialchars($question['correctfeedback'] ?? '').'</correctfeedback>'.PHP_EOL;
$xmlContent .= ' <correctfeedback>'.htmlspecialchars((string) ($question['correctfeedback'] ?? '')).'</correctfeedback>'.PHP_EOL;
$xmlContent .= ' <correctfeedbackformat>0</correctfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <partiallycorrectfeedback>'.htmlspecialchars($question['partiallycorrectfeedback'] ?? '').'</partiallycorrectfeedback>'.PHP_EOL;
$xmlContent .= ' <partiallycorrectfeedback>'.htmlspecialchars((string) ($question['partiallycorrectfeedback'] ?? '')).'</partiallycorrectfeedback>'.PHP_EOL;
$xmlContent .= ' <partiallycorrectfeedbackformat>0</partiallycorrectfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <incorrectfeedback>'.htmlspecialchars($question['incorrectfeedback'] ?? '').'</incorrectfeedback>'.PHP_EOL;
$xmlContent .= ' <incorrectfeedback>'.htmlspecialchars((string) ($question['incorrectfeedback'] ?? '')).'</incorrectfeedback>'.PHP_EOL;
$xmlContent .= ' <incorrectfeedbackformat>0</incorrectfeedbackformat>'.PHP_EOL;
$xmlContent .= ' <shownumcorrect>0</shownumcorrect>'.PHP_EOL;
$xmlContent .= ' </matchoptions>'.PHP_EOL;
@@ -496,11 +668,185 @@ class QuizExport extends ActivityExport
private function exportAnswer(array $answer): string
{
return ' <answer id="'.($answer['id'] ?? '0').'">'.PHP_EOL.
' <answertext>'.htmlspecialchars($answer['text'] ?? 'No answer text').'</answertext>'.PHP_EOL.
' <answertext>'.htmlspecialchars((string) ($answer['text'] ?? 'No answer text')).'</answertext>'.PHP_EOL.
' <answerformat>1</answerformat>'.PHP_EOL.
' <fraction>'.($answer['fraction'] ?? '0').'</fraction>'.PHP_EOL.
' <feedback>'.htmlspecialchars($answer['feedback'] ?? '').'</feedback>'.PHP_EOL.
' <feedback>'.htmlspecialchars((string) ($answer['feedback'] ?? '')).'</feedback>'.PHP_EOL.
' <feedbackformat>1</feedbackformat>'.PHP_EOL.
' </answer>'.PHP_EOL;
}
private function ensureTrailingSlash(string $path): string
{
if ($path === '' || $path === '.' || $path === '/') {
return '/';
}
$path = preg_replace('#/+#', '/', $path);
return rtrim($path, '/').'/';
}
private function buildQuestionEmbeddedFileId(int $baseId, int $sequence): int
{
self::$embeddedFileGlobalSeq++;
return 1300000000 + self::$embeddedFileGlobalSeq;
}
/**
* Extract embedded /document/* images from HTML and rewrite src to @@PLUGINFILE@@.
*
* @return array{content:string, files:array<int,array<string,mixed>>}
*/
private function extractQuestionEmbeddedFilesAndNormalizeContent(
string $html,
int $contextId,
string $fileArea,
int $itemId,
string $prefix,
array $courseInfo,
int $adminId
): array {
if ($html === '') {
return ['content' => '', 'files' => []];
}
$fileExport = new FileExport($this->course);
$files = [];
$seenDocIds = [];
$sequence = 0;
$normalizedHtml = preg_replace_callback(
'#<img[^>]+src=["\'](?<url>[^"\']+)["\']#i',
function ($match) use (
$contextId,
$fileArea,
$itemId,
$courseInfo,
$adminId,
$fileExport,
&$files,
&$seenDocIds,
&$sequence
) {
$src = (string) ($match['url'] ?? '');
if (!preg_match('#/document(?P<path>/[^"\']+)#', $src, $m)) {
return $match[0];
}
$docRelPath = (string) $m['path'];
$docId = \DocumentManager::get_document_id($courseInfo, $docRelPath);
if (empty($docId)) {
return $match[0];
}
$docId = (int) $docId;
if (!isset($seenDocIds[$docId])) {
$doc = \DocumentManager::get_document_data_by_id($docId, $courseInfo['code']);
if (!empty($doc) && !empty($doc['path'])) {
$docPath = (string) $doc['path'];
$rel = ltrim(str_replace('\\', '/', $docPath), '/');
$dir = dirname($rel);
$dir = ($dir === '.' ? '' : $dir.'/');
$filepath = '/'.$dir;
$filepath = $this->ensureTrailingSlash($filepath);
$filename = basename($rel);
$absolutePath = $this->course->path.'document'.$docPath;
$sequence++;
$fileId = $this->buildQuestionEmbeddedFileId($itemId, $sequence);
$files[] = [
'id' => $fileId,
'contenthash' => is_file($absolutePath) ? sha1_file($absolutePath) : hash('sha1', $filename),
'contextid' => $contextId,
'component' => 'question',
'filearea' => $fileArea,
'itemid' => $itemId,
'filepath' => $filepath,
'documentpath' => 'document'.$docPath,
'filename' => $filename,
'userid' => $adminId,
'filesize' => (int) ($doc['size'] ?? 0),
'mimetype' => $fileExport->getMimeType($docPath),
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => (string) ($doc['title'] ?? $filename),
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
$seenDocIds[$docId] = true;
}
$relHtml = ltrim($docRelPath, '/');
$pluginPath = '@@PLUGINFILE@@/'.$relHtml;
return str_replace($src, $pluginPath, $match[0]);
},
$html
);
return [
'content' => (string) $normalizedHtml,
'files' => $files,
];
}
/**
* Build a deterministic answer id for one question answer.
*/
private function buildStableQuestionAnswerId(int $questionId, int $answerIndex): int
{
return ($questionId * 1000) + $answerIndex;
}
/**
* Build a stable question-bank course context id for backup purposes.
*/
private function buildQuestionBankContextId(int $courseId): int
{
$contextId = MoodleExport::getBackupCourseContextId();
if ($contextId > 0) {
return $contextId;
}
return 700000000 + max(1, $courseId);
}
/**
* Build a clean question name from the question text.
*/
private function sanitizeQuestionName(string $raw, int $maxLen = 255): string
{
$text = trim($raw);
if ($text === '') {
return 'Question';
}
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = strip_tags($text);
$text = preg_replace('/\s+/u', ' ', $text);
$text = trim($text);
if ($text === '') {
return 'Question';
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($text, 'UTF-8') > $maxLen) {
$text = mb_substr($text, 0, $maxLen, 'UTF-8');
}
} elseif (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
}
return $text;
}
}
+148 -19
View File
@@ -21,13 +21,13 @@ class ResourceExport extends ActivityExport
*/
public function export($activityId, $exportDir, $moduleId, $sectionId): void
{
// Prepare the directory where the resource export will be saved
$resourceDir = $this->prepareActivityDirectory($exportDir, 'resource', $moduleId);
$resourceDir = $this->prepareActivityDirectory($exportDir, 'resource', (int) $moduleId);
$resourceData = $this->getData((int) $activityId, (int) $sectionId, (int) $moduleId);
// Retrieve resource data
$resourceData = $this->getData($activityId, $sectionId);
if (empty($resourceData)) {
return;
}
// Generate XML files
$this->createResourceXml($resourceData, $resourceDir);
$this->createModuleXml($resourceData, $resourceDir);
$this->createGradesXml($resourceData, $resourceDir);
@@ -42,22 +42,48 @@ class ResourceExport extends ActivityExport
/**
* Get resource data dynamically from the course.
*/
public function getData(int $resourceId, int $sectionId): array
public function getData(int $resourceId, int $sectionId, ?int $moduleId = null): array
{
if (empty($this->course->resources[RESOURCE_DOCUMENT][$resourceId])) {
return [];
}
$resource = $this->course->resources[RESOURCE_DOCUMENT][$resourceId];
$name = (string) ($resource->title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_DOCUMENT, $resourceId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$effectiveModuleId = (int) ($moduleId ?? $resource->source_id);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $resource->source_id;
}
$resourceFile = $this->buildResourceFileEntry($resource, $effectiveModuleId);
$introResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($resource->comment ?? ''),
$effectiveModuleId,
'mod_resource',
'intro',
0,
fn (int $sequence): int => $this->buildResourceIntroFileId($effectiveModuleId, $sequence)
);
return [
'id' => $resourceId,
'moduleid' => $resource->source_id,
'moduleid' => $effectiveModuleId,
'modulename' => 'resource',
'contextid' => $resource->source_id,
'name' => $resource->title,
'intro' => $resource->comment ?? '',
'contextid' => $effectiveModuleId,
'name' => $name,
'intro' => $introResult['content'],
'sectionid' => $sectionId,
'sectionnumber' => 1,
'timemodified' => time(),
'users' => [],
'files' => [],
'files' => array_merge([$resourceFile], $introResult['files']),
];
}
@@ -72,18 +98,121 @@ class ResourceExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<inforef>'.PHP_EOL;
$xmlContent .= ' <fileref>'.PHP_EOL;
$xmlContent .= ' <file>'.PHP_EOL;
$xmlContent .= ' <id>'.htmlspecialchars($references['id']).'</id>'.PHP_EOL;
$xmlContent .= ' </file>'.PHP_EOL;
$xmlContent .= ' </fileref>'.PHP_EOL;
if (!empty($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>'.$fileId.'</id>'.PHP_EOL;
$xmlContent .= ' </file>'.PHP_EOL;
}
$xmlContent .= ' </fileref>'.PHP_EOL;
}
$xmlContent .= '</inforef>'.PHP_EOL;
// Save the XML content to the directory
$this->createXmlFile('inforef', $xmlContent, $directory);
}
/**
* Build the files.xml entry for a resource activity file.
*/
private function buildResourceFileEntry(object $resource, int $moduleId): array
{
$adminData = MoodleExport::getAdminUserData();
$adminId = (int) ($adminData['id'] ?? 1);
$documentPath = (string) $resource->path;
$absolutePath = $this->course->path.$documentPath;
$filename = basename($documentPath);
$contenthash = is_file($absolutePath)
? sha1_file($absolutePath)
: hash('sha1', $filename);
return [
'id' => $this->buildResourceFileId($moduleId, (int) $resource->source_id),
'contenthash' => $contenthash,
'contextid' => $moduleId,
'component' => 'mod_resource',
'filearea' => 'content',
'itemid' => 0,
'filepath' => '/',
'documentpath' => $documentPath,
'filename' => $filename,
'userid' => $adminId,
'filesize' => (int) ($resource->size ?? 0),
'mimetype' => $this->guessMimeType($documentPath),
'status' => 0,
'timecreated' => time() - 3600,
'timemodified' => time(),
'source' => (string) ($resource->title ?? $filename),
'author' => 'Unknown',
'license' => 'allrightsreserved',
];
}
/**
* Build a stable file id for mod_resource main file entries.
*/
private function buildResourceFileId(int $moduleId, int $resourceId): int
{
$base = $moduleId > 0 ? $moduleId : $resourceId;
return 1000000000 + $base;
}
/**
* Build a stable file id for embedded intro files in mod_resource.
*/
private function buildResourceIntroFileId(int $moduleId, int $sequence): int
{
return 1250000000 + max(0, $moduleId) + max(1, $sequence);
}
/**
* Guess MIME type from file extension.
*/
private function guessMimeType(string $filePath): string
{
$ext = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'html' => 'text/html',
'htm' => 'text/html',
'txt' => 'text/plain',
'css' => 'text/css',
'js' => 'application/javascript',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'zip' => 'application/zip',
'rar' => 'application/x-rar-compressed',
];
return $map[$ext] ?? 'application/octet-stream';
}
/**
* Create the XML file for the resource.
*/
@@ -92,8 +221,8 @@ class ResourceExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$resourceData['id'].'" moduleid="'.$resourceData['moduleid'].'" modulename="resource" contextid="'.$resourceData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <resource id="'.$resourceData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($resourceData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro>'.htmlspecialchars($resourceData['intro']).'</intro>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $resourceData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro><![CDATA['.(string) $resourceData['intro'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <tobemigrated>0</tobemigrated>'.PHP_EOL;
$xmlContent .= ' <legacyfiles>0</legacyfiles>'.PHP_EOL;
+161 -55
View File
@@ -15,15 +15,17 @@ use Exception;
class SectionExport
{
private $course;
private $activitiesBySection = [];
/**
* Constructor to initialize the course object.
*
* @param object $course The course object to be exported.
*/
public function __construct($course)
public function __construct($course, array $activitiesBySection = [])
{
$this->course = $course;
$this->activitiesBySection = $activitiesBySection;
}
/**
@@ -47,8 +49,8 @@ class SectionExport
$sectionData = [
'id' => 0,
'number' => 0,
'name' => get_lang('General'),
'summary' => get_lang('GeneralResourcesCourse'),
'name' => $this->sanitizeText((string) get_lang('General')),
'summary' => (string) get_lang('GeneralResourcesCourse'),
'sequence' => 0,
'visible' => 1,
'timemodified' => time(),
@@ -68,7 +70,6 @@ class SectionExport
{
$generalItems = [];
// List of resource types and their corresponding ID keys
$resourceTypes = [
RESOURCE_DOCUMENT => 'source_id',
RESOURCE_QUIZ => 'source_id',
@@ -85,8 +86,9 @@ class SectionExport
foreach ($this->course->resources[$resourceType] as $id => $resource) {
if (!$this->isItemInLearnpath($resource, $resourceType)) {
$title = $resourceType === RESOURCE_WORK
? ($resource->params['title'] ?? '')
: ($resource->title ?? $resource->name);
? (string) ($resource->params['title'] ?? '')
: (string) ($resource->title ?? $resource->name ?? '');
$generalItems[] = [
'id' => $resource->$idKey,
'item_type' => $resourceType,
@@ -106,6 +108,10 @@ class SectionExport
*/
public function getActivitiesForGeneral(): array
{
if (isset($this->activitiesBySection[0]) && is_array($this->activitiesBySection[0])) {
return $this->activitiesBySection[0];
}
$generalLearnpath = (object) [
'items' => $this->getGeneralItems(),
'source_id' => 0,
@@ -113,7 +119,7 @@ class SectionExport
$activities = $this->getActivitiesForSection($generalLearnpath, true);
if (!in_array('folder', array_column($activities, 'modulename'))) {
if (!in_array('folder', array_column($activities, 'modulename'), true)) {
$activities[] = [
'id' => 0,
'moduleid' => 0,
@@ -132,7 +138,7 @@ class SectionExport
public function getLearnpathById(int $sectionId): ?object
{
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) {
if ($learnpath->source_id == $sectionId) {
if ((int) $learnpath->source_id === $sectionId) {
return $learnpath;
}
}
@@ -145,14 +151,16 @@ class SectionExport
*/
public function getSectionData(object $learnpath): array
{
$sectionId = (int) $learnpath->source_id;
return [
'id' => $learnpath->source_id,
'number' => $learnpath->display_order,
'name' => $learnpath->name,
'summary' => $learnpath->description,
'sequence' => $learnpath->source_id,
'visible' => $learnpath->visibility,
'timemodified' => strtotime($learnpath->modified_on),
'id' => $sectionId,
'number' => (int) ($learnpath->display_order ?? 0),
'name' => $this->sanitizeText((string) ($learnpath->name ?? '')),
'summary' => (string) ($learnpath->description ?? ''),
'sequence' => $sectionId,
'visible' => 1,
'timemodified' => !empty($learnpath->modified_on) ? (int) strtotime((string) $learnpath->modified_on) : time(),
'activities' => $this->getActivitiesForSection($learnpath),
];
}
@@ -162,9 +170,13 @@ class SectionExport
*/
public function getActivitiesForSection(object $learnpath, bool $isGeneral = false): array
{
$activities = [];
$sectionId = $isGeneral ? 0 : $learnpath->source_id;
$sectionId = $isGeneral ? 0 : (int) $learnpath->source_id;
if (isset($this->activitiesBySection[$sectionId]) && is_array($this->activitiesBySection[$sectionId])) {
return $this->activitiesBySection[$sectionId];
}
$activities = [];
foreach ($learnpath->items as $item) {
$this->addActivityToList($item, $sectionId, $activities);
}
@@ -195,22 +207,76 @@ class SectionExport
$exportClass = new $exportClasses[$moduleName]($this->course);
$exportClass->export($activity['id'], $exportDir, $activity['moduleid'], $sectionId);
} else {
throw new \Exception("Export for module '$moduleName' is not supported.");
throw new Exception("Export for module '$moduleName' is not supported.");
}
}
}
/**
* Normalize resource / LP item types for reliable comparison.
*/
private function normalizeItemTypeForLpComparison(string $type): string
{
switch ($type) {
case 'student_publication':
case 'work':
return 'work';
case 'link':
case 'url':
return 'link';
case 'survey':
case 'feedback':
return 'survey';
default:
return $type;
}
}
/**
* Check if an item is associated with any learnpath.
*/
private function isItemInLearnpath(object $item, string $type): bool
{
if (!empty($this->course->resources[RESOURCE_LEARNPATH])) {
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) {
if (!empty($learnpath->items)) {
foreach ($learnpath->items as $learnpathItem) {
if ($learnpathItem['item_type'] === $type && $learnpathItem['path'] == $item->source_id) {
return true;
if (empty($this->course->resources[RESOURCE_LEARNPATH])) {
return false;
}
$normalizedType = $this->normalizeItemTypeForLpComparison($type);
$itemSourceId = isset($item->source_id) ? (string) $item->source_id : '';
foreach ($this->course->resources[RESOURCE_LEARNPATH] as $learnpath) {
if (empty($learnpath->items)) {
continue;
}
foreach ($learnpath->items as $learnpathItem) {
$lpType = isset($learnpathItem['item_type'])
? $this->normalizeItemTypeForLpComparison((string) $learnpathItem['item_type'])
: '';
if ($lpType !== $normalizedType) {
continue;
}
$lpPath = isset($learnpathItem['path']) ? (string) $learnpathItem['path'] : '';
if ($itemSourceId !== '' && $lpPath === $itemSourceId) {
return true;
}
if ($normalizedType === 'document' && $itemSourceId !== '' && ctype_digit($itemSourceId)) {
$doc = \DocumentManager::get_document_data_by_id((int) $itemSourceId, $this->course->code);
if (!empty($doc['path'])) {
$docPath = (string) $doc['path'];
$candidates = [$docPath, 'document/'.$docPath, '/'.$docPath];
foreach ($candidates as $candidate) {
if ($lpPath === $candidate) {
return true;
}
}
}
}
@@ -249,14 +315,14 @@ class SectionExport
'feedback' => FeedbackExport::class,
];
if ($item['id'] == 'course_homepage') {
if (($item['id'] ?? null) === 'course_homepage') {
$item['item_type'] = 'page';
$item['path'] = 0;
}
$itemType = $item['item_type'] === 'link' ? 'url' :
($item['item_type'] === 'work' || $item['item_type'] === 'student_publication' ? 'assign' :
($item['item_type'] === 'survey' ? 'feedback' : $item['item_type']));
$itemType = ($item['item_type'] ?? '') === 'link' ? 'url'
: ((($item['item_type'] ?? '') === 'work' || ($item['item_type'] ?? '') === 'student_publication') ? 'assign'
: ((($item['item_type'] ?? '') === 'survey') ? 'feedback' : ($item['item_type'] ?? '')));
switch ($itemType) {
case 'quiz':
@@ -266,41 +332,48 @@ class SectionExport
case 'forum':
case 'feedback':
case 'page':
$activityId = $itemType === 'glossary' ? 1 : (int) $item['path'];
$activityId = $itemType === 'glossary' ? 1 : (int) ($item['path'] ?? 0);
$exportClass = $activityClassMap[$itemType];
$exportInstance = new $exportClass($this->course);
$activityData = $exportInstance->getData($activityId, $sectionId);
break;
case 'document':
$documentId = (int) $item['path'];
$documentId = (int) ($item['path'] ?? 0);
$document = \DocumentManager::get_document_data_by_id($documentId, $this->course->code);
if ($document) {
$isRoot = substr_count($document['path'], '/') === 1;
$documentType = $this->getDocumentType($document['filetype'], $document['path']);
if ($documentType === 'page' && $isRoot) {
$activityClass = $activityClassMap['page'];
$exportInstance = new $activityClass($this->course);
$activityData = $exportInstance->getData($item['path'], $sectionId);
}
elseif ($sectionId > 0 && $documentType && isset($activityClassMap[$documentType])) {
$documentType = $this->getDocumentType((string) $document['filetype'], (string) $document['path']);
if ($sectionId > 0 && $documentType && isset($activityClassMap[$documentType])) {
$activityClass = $activityClassMap[$documentType];
$exportInstance = new $activityClass($this->course);
$activityData = $exportInstance->getData($item['path'], $sectionId);
$activityData = $exportInstance->getData($documentId, $sectionId);
} elseif ($sectionId === 0 && $documentType === 'page') {
$activityClass = $activityClassMap['page'];
$exportInstance = new $activityClass($this->course);
$activityData = $exportInstance->getData($documentId, $sectionId);
}
}
break;
}
// Add the activity to the list if the data exists
if (!empty($activityData) && $sectionId > 0) {
$lpItemId = isset($item['id']) ? (int) $item['id'] : 0;
$modName = (string) ($activityData['modulename'] ?? '');
if ($lpItemId > 0 && !in_array($modName, ['folder', 'glossary'], true)) {
$activityData['moduleid'] = 900000000 + $lpItemId;
}
}
if ($activityData) {
$activities[] = [
'id' => $activityData['id'],
'moduleid' => $activityData['moduleid'],
'type' => $item['item_type'],
'modulename' => $activityData['modulename'],
'name' => $activityData['name'],
'name' => $this->sanitizeText((string) ($activityData['name'] ?? '')),
];
}
}
@@ -310,13 +383,15 @@ class SectionExport
*/
private function getDocumentType(string $filetype, string $path): ?string
{
if ('html' === pathinfo($path, PATHINFO_EXTENSION)) {
$ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
if ($ext === 'html' || $ext === 'htm') {
return 'page';
} elseif ('file' === $filetype) {
}
if ($filetype === 'file') {
return 'resource';
} /*elseif ('folder' === $filetype) {
return 'folder';
}*/
}
return null;
}
@@ -326,19 +401,20 @@ class SectionExport
*/
private function createSectionXml(array $sectionData, string $destinationDir): void
{
$sequence = implode(',', array_column($sectionData['activities'], 'moduleid'));
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<section id="'.$sectionData['id'].'">'.PHP_EOL;
$xmlContent .= ' <number>'.$sectionData['number'].'</number>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($sectionData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <summary>'.htmlspecialchars($sectionData['summary']).'</summary>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $sectionData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <summary>'.htmlspecialchars((string) $sectionData['summary']).'</summary>'.PHP_EOL;
$xmlContent .= ' <summaryformat>1</summaryformat>'.PHP_EOL;
$xmlContent .= ' <sequence>'.implode(',', array_column($sectionData['activities'], 'moduleid')).'</sequence>'.PHP_EOL;
$xmlContent .= ' <visible>'.$sectionData['visible'].'</visible>'.PHP_EOL;
$xmlContent .= ' <sequence>'.$sequence.'</sequence>'.PHP_EOL;
$xmlContent .= ' <visible>1</visible>'.PHP_EOL;
$xmlContent .= ' <timemodified>'.$sectionData['timemodified'].'</timemodified>'.PHP_EOL;
$xmlContent .= '</section>'.PHP_EOL;
$xmlFile = $destinationDir.'/section.xml';
file_put_contents($xmlFile, $xmlContent);
file_put_contents($destinationDir.'/section.xml', $xmlContent);
}
/**
@@ -350,12 +426,42 @@ class SectionExport
$xmlContent .= '<inforef>'.PHP_EOL;
foreach ($sectionData['activities'] as $activity) {
$xmlContent .= ' <activity id="'.$activity['id'].'">'.htmlspecialchars($activity['name']).'</activity>'.PHP_EOL;
$refId = $activity['moduleid'] ?? $activity['id'];
$xmlContent .= ' <activity id="'.$refId.'">'.htmlspecialchars((string) ($activity['name'] ?? '')).'</activity>'.PHP_EOL;
}
$xmlContent .= '</inforef>'.PHP_EOL;
$xmlFile = $destinationDir.'/inforef.xml';
file_put_contents($xmlFile, $xmlContent);
file_put_contents($destinationDir.'/inforef.xml', $xmlContent);
}
/**
* Sanitize section or activity titles for Moodle.
*/
private function sanitizeText(string $raw, int $maxLen = 255): string
{
$text = trim($raw);
if ($text === '') {
return '';
}
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = strip_tags($text);
$text = preg_replace('/\s+/u', ' ', $text);
$text = trim($text);
if ($text === '') {
return '';
}
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($text, 'UTF-8') > $maxLen) {
$text = mb_substr($text, 0, $maxLen, 'UTF-8');
}
} elseif (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
}
return $text;
}
}
+55 -20
View File
@@ -12,22 +12,27 @@ namespace moodleexport;
class UrlExport extends ActivityExport
{
/**
* Export all URL resources into a single Moodle activity.
* Export one URL activity.
*
* @param int $activityId The ID of the URL.
* @param string $exportDir The directory where the URL will be exported.
* @param int $moduleId The ID of the module.
* @param int $moduleId The exported module ID.
* @param int $sectionId The ID of the section.
*/
public function export($activityId, $exportDir, $moduleId, $sectionId): void
{
// Prepare the directory where the URL export will be saved
$urlDir = $this->prepareActivityDirectory($exportDir, 'url', $moduleId);
$effectiveModuleId = (int) $moduleId;
if ($effectiveModuleId <= 0) {
$effectiveModuleId = (int) $activityId;
}
// Retrieve URL data
$urlData = $this->getData($activityId, $sectionId);
$urlDir = $this->prepareActivityDirectory($exportDir, 'url', $effectiveModuleId);
$urlData = $this->getData((int) $activityId, (int) $sectionId, $effectiveModuleId);
if (empty($urlData)) {
return;
}
// Generate XML file for the URL
$this->createUrlXml($urlData, $urlDir);
$this->createModuleXml($urlData, $urlDir);
$this->createGradesXml($urlData, $urlDir);
@@ -40,28 +45,50 @@ class UrlExport extends ActivityExport
}
/**
* Get all URL data for the course.
* Get URL data for the course.
*/
public function getData(int $activityId, int $sectionId): ?array
public function getData(int $activityId, int $sectionId, ?int $moduleId = null): ?array
{
// Extract the URL information from the course data
if (empty($this->course->resources['link'][$activityId])) {
return null;
}
$url = $this->course->resources['link'][$activityId];
// Return the URL data formatted for export
$effectiveModuleId = (int) ($moduleId ?? $activityId);
if ($effectiveModuleId <= 0) {
$effectiveModuleId = $activityId;
}
$name = (string) ($url->title ?? '');
if ($sectionId > 0) {
$name = $this->lpItemTitle($sectionId, RESOURCE_LINK, $activityId, $name);
}
$name = $this->sanitizeMoodleActivityName($name, 255);
$descriptionResult = $this->extractEmbeddedFilesAndNormalizeContent(
(string) ($url->description ?? ''),
$effectiveModuleId,
'mod_url',
'intro',
0,
fn (int $sequence): int => $this->buildUrlEmbeddedFileId($effectiveModuleId, $sequence)
);
return [
'id' => $activityId,
'moduleid' => $activityId,
'moduleid' => $effectiveModuleId,
'modulename' => 'url',
'contextid' => $this->course->info['real_id'],
'name' => $url->title,
'description' => $url->description,
'externalurl' => $url->url,
'contextid' => $effectiveModuleId,
'name' => $name,
'description' => $descriptionResult['content'],
'externalurl' => (string) ($url->url ?? ''),
'timecreated' => time(),
'timemodified' => time(),
'sectionid' => $sectionId,
'sectionnumber' => 0,
'users' => [],
'files' => [],
'files' => $descriptionResult['files'],
];
}
@@ -73,10 +100,10 @@ class UrlExport extends ActivityExport
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>'.PHP_EOL;
$xmlContent .= '<activity id="'.$urlData['id'].'" moduleid="'.$urlData['moduleid'].'" modulename="'.$urlData['modulename'].'" contextid="'.$urlData['contextid'].'">'.PHP_EOL;
$xmlContent .= ' <url id="'.$urlData['id'].'">'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars($urlData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro><![CDATA['.htmlspecialchars($urlData['description']).']]></intro>'.PHP_EOL;
$xmlContent .= ' <name>'.htmlspecialchars((string) $urlData['name']).'</name>'.PHP_EOL;
$xmlContent .= ' <intro><![CDATA['.(string) $urlData['description'].']]></intro>'.PHP_EOL;
$xmlContent .= ' <introformat>1</introformat>'.PHP_EOL;
$xmlContent .= ' <externalurl>'.htmlspecialchars($urlData['externalurl']).'</externalurl>'.PHP_EOL;
$xmlContent .= ' <externalurl>'.htmlspecialchars((string) $urlData['externalurl']).'</externalurl>'.PHP_EOL;
$xmlContent .= ' <display>0</display>'.PHP_EOL;
$xmlContent .= ' <displayoptions>a:1:{s:10:"printintro";i:1;}</displayoptions>'.PHP_EOL;
$xmlContent .= ' <parameters>a:0:{}</parameters>'.PHP_EOL;
@@ -86,4 +113,12 @@ class UrlExport extends ActivityExport
$this->createXmlFile('url', $xmlContent, $urlDir);
}
/**
* Build a stable embedded file id for URL intro files.
*/
private function buildUrlEmbeddedFileId(int $moduleId, int $sequence): int
{
return 1200000000 + max(0, $moduleId) + max(1, $sequence);
}
}