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
+4 -37
View File
@@ -145,43 +145,10 @@ switch ($action) {
break;
case 'search_course':
if (api_is_teacher() || api_is_platform_admin()) {
if (isset($_GET['session_id']) && !empty($_GET['session_id'])) {
//if session is defined, lets find only courses of this session
$courseList = SessionManager::get_course_list_by_session_id(
$_GET['session_id'],
$_GET['q']
);
} else {
//if session is not defined lets search all courses STARTING with $_GET['q']
//TODO change this function to search not only courses STARTING with $_GET['q']
if (api_is_platform_admin()) {
$courseList = CourseManager::get_courses_list(
0,
0,
'title',
'ASC',
-1,
$_GET['q'],
null,
true
);
} elseif (api_is_teacher()) {
$courseList = CourseManager::get_course_list_of_user_as_course_admin(api_get_user_id(), $_GET['q']);
$category = api_get_configuration_value('course_category_code_to_use_as_model');
if (!empty($category)) {
$alreadyAdded = [];
if (!empty($courseList)) {
$alreadyAdded = array_column($courseList, 'id');
}
$coursesInCategory = CourseCategory::getCoursesInCategory($category, $_GET['q']);
foreach ($coursesInCategory as $course) {
if (!in_array($course['id'], $alreadyAdded)) {
$courseList[] = $course;
}
}
}
}
}
$courseList = CourseManager::searchCourse(
$_REQUEST['q'],
isset($_GET['session_id']) ? (int) $_GET['session_id'] : 0
);
$results = [];
if (empty($courseList)) {
+12 -9
View File
@@ -1,6 +1,7 @@
<?php
/* For licensing terms, see /license.txt */
use Chamilo\CourseBundle\Entity\CCourseDescription;
use Chamilo\CourseBundle\Entity\CTool;
use ChamiloSession as Session;
@@ -290,15 +291,17 @@ switch ($action) {
echo get_lang('PrivateAccess');
break;
}
$table = Database::get_course_table(TABLE_COURSE_DESCRIPTION);
$sql = "SELECT * FROM $table
WHERE c_id = ".$course_info['real_id']." AND session_id = 0
ORDER BY id";
$result = Database::query($sql);
if (Database::num_rows($result) > 0) {
while ($description = Database::fetch_object($result)) {
$descriptions[$description->id] = $description;
}
/** @var array<int, CCourseDescription> $courseDescriptions */
$courseDescriptions = Database::getManager()
->getRepository(CCourseDescription::class)
->findBy(['cId' => $course_info['real_id'], 'sessionId' => 0])
;
$descriptions = [];
foreach ($courseDescriptions as $courseDescription) {
$descriptions[$courseDescription->getIid()] = $courseDescription;
// Function that displays the details of the course description in html.
$content = CourseManager::get_details_course_description_html(
$descriptions,
+32 -1
View File
@@ -6,6 +6,7 @@
*/
use Chamilo\CoreBundle\Component\Editor\Driver\Driver;
use Chamilo\CoreBundle\Component\Editor\Driver\PersonalDriver;
require_once __DIR__.'/../global.inc.php';
@@ -217,6 +218,20 @@ switch ($action) {
$data = [];
$fileUpload = $_FILES['upload'];
try {
new Image($fileUpload['tmp_name']);
} catch (Exception $e) {
echo json_encode([
'uploaded' => 0,
'error' => [
'message' => get_lang('MissingImagesDetected'),
],
]);
exit;
}
$mimeType = mime_content_type($fileUpload['tmp_name']);
$isMimeAccepted = (new Driver())->mimeAccepted($mimeType, ['image']);
@@ -225,6 +240,22 @@ switch ($action) {
exit;
}
try {
$fileUpload['size'] = DocumentManager::autoResizeImageIfNeeded(
$fileUpload['size'],
$fileUpload['tmp_name']
);
} catch (Exception $e) {
echo json_encode([
'uploaded' => 0,
'error' => [
'message' => $e->getMessage(),
],
]);
exit;
}
$isAllowedToEdit = api_is_allowed_to_edit(null, true);
if ($isAllowedToEdit) {
$globalFile = ['files' => $fileUpload];
@@ -257,7 +288,7 @@ switch ($action) {
mkdir($syspath, api_get_permissions_for_new_directories(), true);
}
$webpath = UserManager::getUserPathById($userId, 'web').'my_files';
$fileUploadName = $fileUpload['name'];
$fileUploadName = disable_dangerous_file(api_replace_dangerous_char($fileUpload['name']));
if (file_exists($syspath.$fileUploadName)) {
$extension = pathinfo($fileUploadName, PATHINFO_EXTENSION);
$fileName = pathinfo($fileUploadName, PATHINFO_FILENAME);
+12 -1
View File
@@ -56,6 +56,10 @@ switch ($action) {
}
break;*/
case 'export_all_certificates':
if (!api_is_allowed_to_edit() && !api_is_student_boss()) {
exit;
}
$categoryId = (int) $_GET['cat_id'];
$filterOfficialCodeGet = isset($_GET['filter']) ? Security::remove_XSS($_GET['filter']) : null;
@@ -76,7 +80,14 @@ switch ($action) {
$userList = implode(',', $userList);
shell_exec("php $commandScript $courseCode $sessionId $categoryId $userList > /dev/null &");
shell_exec(sprintf(
"php %s %s %s %s %s > /dev/null &",
escapeshellarg($commandScript),
escapeshellarg($courseCode),
escapeshellarg((string) $sessionId),
escapeshellarg((string) $categoryId),
escapeshellarg($userList)
));
break;
case 'verify_export_all_certificates':
$categoryId = (int) $_GET['cat_id'];
+14 -8
View File
@@ -251,10 +251,6 @@ if (($search || $forceSearch) && ($search !== 'false')) {
}
$whereCondition .= $extraQuestionCondition;
if (isset($filters->custom_dates)) {
$whereCondition .= $filters->custom_dates;
}
}
} elseif (!empty($filters->rules)) {
$whereCondition .= ' AND ( ';
@@ -643,17 +639,19 @@ switch ($action) {
true
);
break;
case 'get_exercise_pending_results':
if ((false === api_is_teacher()) && (false === api_is_session_admin())) {
exit;
}
$search_start_date = isset($_REQUEST['start_date']) && !empty($_REQUEST['start_date']) ? $_REQUEST['start_date'] : null;
$search_end_date = isset($_REQUEST['end_date']) && !empty($_REQUEST['end_date']) ? $_REQUEST['end_date'] : null;
$courseId = $_REQUEST['course_id'] ?? 0;
$exerciseId = $_REQUEST['exercise_id'] ?? 0;
$status = $_REQUEST['status'] ?? 0;
$questionType = $_REQUEST['questionType'] ?? 0;
$showAttemptsInSessions = (bool) $_REQUEST['showAttemptsInSessions'];
if (!empty($_GET['filter_by_user'])) {
$showAttemptsInSessions = $_REQUEST['showAttemptsInSessions'] ? true : false;
if (isset($_GET['filter_by_user']) && !empty($_GET['filter_by_user'])) {
$filter_user = (int) $_GET['filter_by_user'];
if (empty($whereCondition)) {
$whereCondition .= " te.exe_user_id = '$filter_user'";
@@ -662,7 +660,7 @@ switch ($action) {
}
}
if (!empty($_GET['group_id_in_toolbar'])) {
if (isset($_GET['group_id_in_toolbar']) && !empty($_GET['group_id_in_toolbar'])) {
$groupIdFromToolbar = (int) $_GET['group_id_in_toolbar'];
if (!empty($groupIdFromToolbar)) {
if (empty($whereCondition)) {
@@ -681,6 +679,14 @@ switch ($action) {
$whereCondition .= " AND te.c_id = $courseId";
}
// Filtrage sur la date de fin d'exercice (exe_date)
if (!empty($search_start_date)) {
$whereCondition .= " AND te.exe_date >= '".Database::escape_string($search_start_date)." 00:00:00'";
}
if (!empty($search_end_date)) {
$whereCondition .= " AND te.exe_date <= '".Database::escape_string($search_end_date)." 23:59:59'";
}
$count = ExerciseLib::get_count_exam_results(
$exerciseId,
$whereCondition,
+17 -4
View File
@@ -251,12 +251,25 @@ switch ($action) {
$statsName = 'NumberOfUsers';
$filter = $_REQUEST['filter'];
$startDate = $_REQUEST['date_start'];
$endDate = $_REQUEST['date_end'];
$rawStartDate = isset($_REQUEST['date_start']) ? $_REQUEST['date_start'] : '';
$rawEndDate = isset($_REQUEST['date_end']) ? $_REQUEST['date_end'] : '';
$extraConditions = '';
if (!empty($startDate) && !empty($endDate)) {
$extraConditions .= " AND registration_date BETWEEN '$startDate' AND '$endDate' ";
if (!empty($rawStartDate) && !empty($rawEndDate)) {
// Validate both values against YYYY-MM-DD before embedding in SQL.
// Any other format (including SQL metacharacters) is silently ignored.
$parsedStart = DateTime::createFromFormat('Y-m-d', $rawStartDate);
$parsedEnd = DateTime::createFromFormat('Y-m-d', $rawEndDate);
if (false !== $parsedStart
&& false !== $parsedEnd
&& $parsedStart->format('Y-m-d') === $rawStartDate
&& $parsedEnd->format('Y-m-d') === $rawEndDate
) {
$startDate = Database::escape_string($rawStartDate);
$endDate = Database::escape_string($rawEndDate);
$extraConditions .= " AND registration_date BETWEEN '$startDate' AND '$endDate' ";
}
}
switch ($filter) {
+17
View File
@@ -226,6 +226,23 @@ foreach ($result as &$row) {
ini_set('log_errors', '1');
// Including configuration files
$configurationFiles = [
'mail.conf.php',
'profile.conf.php',
'course_info.conf.php',
'add_course.conf.php',
'events.conf.php',
'auth.conf.php',
];
foreach ($configurationFiles as $file) {
$file = api_get_path(CONFIGURATION_PATH).$file;
if (file_exists($file)) {
require_once $file;
}
}
/**
* Include the trad4all language file.
*/
+91 -34
View File
@@ -16,6 +16,7 @@ use Chamilo\CourseBundle\Entity\CItemProperty;
use Chamilo\UserBundle\Entity\User;
use Doctrine\ORM\Query\Expr\Join;
use Mpdf\MpdfException;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
@@ -1417,7 +1418,7 @@ class PortfolioController
);
$urlUserString = "";
if (isset($urlUser)) {
if (!empty($urlUser)) {
$urlUserString = "user=".$urlUser;
}
@@ -2350,12 +2351,24 @@ class PortfolioController
$itemDirectory = $item->getCreationDate()->format('Y-m-d-H-i-s');
$itemFilename = sprintf('%s/items/%s/item.html', $tempPortfolioDirectory, $itemDirectory);
$itemFileContent = $this->fixImagesSourcesToHtml($itemsHtml[$i]);
$imagePaths = [];
$itemFileContent = $this->fixMediaSourcesToHtml($itemsHtml[$i], $imagePaths);
$fs->dumpFile($itemFilename, $itemFileContent);
$filenames[] = $itemFilename;
foreach ($imagePaths as $imagePath) {
$inlineFile = dirname($itemFilename).'/'.basename($imagePath);
try {
$filenames[] = $inlineFile;
$fs->copy($imagePath, $inlineFile);
} catch (FileNotFoundException $notFoundException) {
continue;
}
}
$attachments = $attachmentsRepo->findFromItem($item);
/** @var PortfolioAttachment $attachment */
@@ -2367,12 +2380,15 @@ class PortfolioController
$attachment->getFilename()
);
$fs->copy(
$attachmentsDirectory.$attachment->getPath(),
$attachmentFilename
);
$filenames[] = $attachmentFilename;
try {
$fs->copy(
$attachmentsDirectory.$attachment->getPath(),
$attachmentFilename
);
$filenames[] = $attachmentFilename;
} catch (FileNotFoundException $notFoundException) {
continue;
}
}
$tblItemsData[] = [
@@ -2397,13 +2413,25 @@ class PortfolioController
foreach ($comments as $i => $comment) {
$commentDirectory = $comment->getDate()->format('Y-m-d-H-i-s');
$commentFileContent = $this->fixImagesSourcesToHtml($commentsHtml[$i]);
$imagePaths = [];
$commentFileContent = $this->fixMediaSourcesToHtml($commentsHtml[$i], $imagePaths);
$commentFilename = sprintf('%s/comments/%s/comment.html', $tempPortfolioDirectory, $commentDirectory);
$fs->dumpFile($commentFilename, $commentFileContent);
$filenames[] = $commentFilename;
foreach ($imagePaths as $imagePath) {
$inlineFile = dirname($commentFilename).'/'.basename($imagePath);
try {
$filenames[] = $inlineFile;
$fs->copy($imagePath, $inlineFile);
} catch (FileNotFoundException $notFoundException) {
continue;
}
}
$attachments = $attachmentsRepo->findFromComment($comment);
/** @var PortfolioAttachment $attachment */
@@ -2415,12 +2443,15 @@ class PortfolioController
$attachment->getFilename()
);
$fs->copy(
$attachmentsDirectory.$attachment->getPath(),
$attachmentFilename
);
$filenames[] = $attachmentFilename;
try {
$fs->copy(
$attachmentsDirectory.$attachment->getPath(),
$attachmentFilename
);
$filenames[] = $attachmentFilename;
} catch (FileNotFoundException $notFoundException) {
continue;
}
}
$tblCommentsData[] = [
@@ -4277,44 +4308,70 @@ class PortfolioController
return $commentsHtml;
}
private function fixImagesSourcesToHtml(string $htmlContent): string
/**
* @param array $imagePaths Relative paths found in $htmlContent
*/
private function fixMediaSourcesToHtml(string $htmlContent, array &$imagePaths): string
{
$doc = new DOMDocument();
@$doc->loadHTML($htmlContent);
$elements = $doc->getElementsByTagName('img');
$tagsWithSrc = ['img', 'video', 'audio', 'source'];
/** @var array<int, \DOMElement> $elements */
$elements = [];
if (empty($elements->length)) {
foreach ($tagsWithSrc as $tag) {
foreach ($doc->getElementsByTagName($tag) as $element) {
if ($element->hasAttribute('src')) {
$elements[] = $element;
}
}
}
if (empty($elements)) {
return $htmlContent;
}
$webCoursePath = api_get_path(WEB_COURSE_PATH);
$webUploadPath = api_get_path(WEB_UPLOAD_PATH);
/** @var array<int, \DOMElement> $anchorElements */
$anchorElements = $doc->getElementsByTagName('a');
$webPath = api_get_path(WEB_PATH);
$sysPath = rtrim(api_get_path(SYS_PATH), '/');
$paths = [
'/app/upload/' => $sysPath,
'/courses/' => $sysPath.'/app',
];
/** @var \DOMElement $element */
foreach ($elements as $element) {
$src = trim($element->getAttribute('src'));
if (strpos($src, 'http') === 0) {
if (!str_starts_with($src, '/')
&& !str_starts_with($src, $webPath)
) {
continue;
}
if (strpos($src, '/app/upload/') === 0) {
$element->setAttribute(
'src',
preg_replace('/\/app/upload\//', $webUploadPath, $src, 1)
);
// to search anchors linking to files
if ($anchorElements->length > 0) {
foreach ($anchorElements as $anchorElement) {
if (!$anchorElement->hasAttribute('href')) {
continue;
}
continue;
if ($src === $anchorElement->getAttribute('href')) {
$anchorElement->setAttribute('href', basename($src));
}
}
}
if (strpos($src, '/courses/') === 0) {
$element->setAttribute(
'src',
preg_replace('/\/courses\//', $webCoursePath, $src, 1)
);
$src = str_replace($webPath, '/', $src);
continue;
foreach ($paths as $prefix => $basePath) {
if (str_starts_with($src, $prefix)) {
$imagePaths[] = $basePath.urldecode($src);
$element->setAttribute('src', basename($src));
}
}
}
+147 -60
View File
@@ -242,6 +242,39 @@ class TicketManager
return Database::store_result($result);
}
/**
* Returns the list of category IDs assigned to a user.
*
* @param int $userId
* @param int $projectId
*
* @return int[]
*/
public static function getCategoryIdsByUser($userId, $projectId = 0)
{
$tableRel = Database::get_main_table(TABLE_TICKET_CATEGORY_REL_USER);
$tableCat = Database::get_main_table(TABLE_TICKET_CATEGORY);
$userId = (int) $userId;
$projectId = (int) $projectId;
$sql = "SELECT rel.category_id
FROM $tableRel rel
INNER JOIN $tableCat cat ON (rel.category_id = cat.id)
WHERE rel.user_id = $userId";
if (!empty($projectId)) {
$sql .= " AND cat.project_id = $projectId";
}
$result = Database::query($sql);
$categories = [];
while ($row = Database::fetch_array($result)) {
$categories[] = (int) $row['category_id'];
}
return $categories;
}
/**
* @param int $categoryId
*/
@@ -322,14 +355,37 @@ class TicketManager
$course_id = (int) $course_id;
$category_id = (int) $category_id;
$project_id = (int) $project_id;
$priority = empty($priority) ? self::PRIORITY_NORMAL : (int) $priority;
if ($status === '') {
$status = self::STATUS_NEW;
// Resolve priority to numeric ID (accepts ID or code like 'NRM')
$priorityId = null;
$priorityCodeOrId = $priority;
if (empty($priorityCodeOrId)) {
$priorityCodeOrId = self::PRIORITY_NORMAL;
}
if (is_numeric($priorityCodeOrId)) {
$priorityId = (int) $priorityCodeOrId;
} else {
$priorityId = self::getPriorityIdFromCode((string) $priorityCodeOrId);
}
if (empty($priorityId)) {
// Fallback to default priority if mapping failed
$priorityId = self::getPriorityIdFromCode(self::PRIORITY_NORMAL);
}
// Resolve status to numeric ID (accepts ID or code like 'NAT')
$statusId = null;
$statusCodeOrId = $status;
if ($statusCodeOrId === '' || $statusCodeOrId === null) {
$statusCodeOrId = self::STATUS_NEW;
if ($other_area > 0) {
$status = self::STATUS_FORWARDED;
$statusCodeOrId = self::STATUS_FORWARDED;
}
}
if (is_numeric($statusCodeOrId)) {
$statusId = (int) $statusCodeOrId;
} else {
$statusId = self::getStatusIdFromCode((string) $statusCodeOrId);
}
if (!empty($category_id)) {
if (empty($assignedUserId)) {
@@ -355,9 +411,9 @@ class TicketManager
$params = [
'project_id' => $project_id,
'category_id' => $category_id,
'priority_id' => $priority,
'priority_id' => $priorityId,
'personal_email' => $personalEmail,
'status_id' => $status,
'status_id' => $statusId,
'start_date' => $now,
'sys_insert_user_id' => $currentUserId,
'sys_insert_datetime' => $now,
@@ -788,7 +844,7 @@ class TicketManager
$sql_get_message_ids = "SELECT id FROM $table_ticket_message WHERE ticket_id = $ticketId";
$sql_delete_attachments = "DELETE FROM $table_ticket_message_attachments WHERE message_id IN ($sql_get_message_ids)";
Database::query($sql_delete_attachments);
$sql_assigned_log = "DELETE FROM $table_ticket_assigned_log WHERE ticket_id = $ticketId";
Database::query($sql_assigned_log);
@@ -798,7 +854,7 @@ class TicketManager
$sql_get_category = "SELECT category_id FROM $table_support_tickets WHERE id = $ticketId";
$res = Database::query($sql_get_category);
if ($row = Database::fetch_array($res)) {
$category_id = (int)$row['category_id'];
$category_id = (int) $row['category_id'];
$table_ticket_category = Database::get_main_table('ticket_category');
$sql_update_category = "UPDATE $table_ticket_category SET total_tickets = total_tickets - 1 WHERE id = $category_id AND total_tickets > 0";
Database::query($sql_update_category);
@@ -902,7 +958,14 @@ class TicketManager
// Check if a role was set to the project
if ($userIsAllowInProject == false) {
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId )";
$categoryList = self::getCategoryIdsByUser($userId, $projectId);
$categoryCondition = '';
if (!empty($categoryList)) {
$categoryIds = implode(',', array_map('intval', $categoryList));
$categoryCondition = " OR ticket.category_id IN ($categoryIds)";
}
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId".$categoryCondition.")";
}
// Search simple
@@ -948,11 +1011,11 @@ class TicketManager
$keyword_range = !empty($keyword_start_date_start) && !empty($keyword_start_date_end);
if ($keyword_range == false && $keyword_start_date_start != '') {
$sql .= " AND DATE_FORMAT(ticket.start_date,'%d/%m/%Y') >= '$keyword_start_date_start' ";
$sql .= " AND ticket.start_date >= '$keyword_start_date_start' ";
}
if ($keyword_range && $keyword_start_date_start != '' && $keyword_start_date_end != '') {
$sql .= " AND DATE_FORMAT(ticket.start_date,'%d/%m/%Y') >= '$keyword_start_date_start'
AND DATE_FORMAT(ticket.start_date,'%d/%m/%Y') <= '$keyword_start_date_end'";
$sql .= " AND ticket.start_date >= '$keyword_start_date_start'
AND ticket.start_date <= '$keyword_start_date_end'";
}
if ($keyword_course != '') {
@@ -1047,7 +1110,7 @@ class TicketManager
if ($isAdmin) {
$project_id = isset($row['project_id']) ? $row['project_id'] : (isset($_GET['project_id']) ? $_GET['project_id'] : 0);
$delete_link = '<a href="tickets.php?action=delete&ticket_id='.$row['ticket_id'].'&project_id='.$project_id.'" onclick="return confirm(\''.htmlentities(get_lang('AreYouSureYouWantToDeleteThisTicket')).'\')">'
. Display::return_icon('delete.png', get_lang('Delete')) .
.Display::return_icon('delete.png', get_lang('Delete')).
'</a>';
$ticket[] = $delete_link;
}
@@ -1072,51 +1135,54 @@ class TicketManager
if (empty($userInfo)) {
return 0;
}
$userId = $userInfo['id'];
$userId = (int) $userInfo['id'];
if (!isset($_GET['project_id'])) {
return 0;
}
$sql = "SELECT COUNT(ticket.id) AS total
$sql = "SELECT COUNT(DISTINCT ticket.id) AS total
FROM $table_support_tickets ticket
INNER JOIN $table_support_category cat
ON (cat.id = ticket.category_id)
ON (cat.id = ticket.category_id)
INNER JOIN $table_support_priority priority
ON (ticket.priority_id = priority.id)
ON (ticket.priority_id = priority.id)
INNER JOIN $table_support_status status
ON (ticket.status_id = status.id)
WHERE 1 = 1";
ON (ticket.status_id = status.id)
WHERE 1 = 1";
$projectId = (int) $_GET['project_id'];
$allowRoleList = self::getAllowedRolesFromProject($projectId);
$userIsAllowInProject = self::userIsAllowInProject($userInfo, $projectId);
// Check if a role was set to the project
if (!empty($allowRoleList) && is_array($allowRoleList)) {
if (!in_array($userInfo['status'], $allowRoleList)) {
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId )";
}
} else {
if (!api_is_platform_admin()) {
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId )";
// Apply same permission constraints as getTicketsByCurrentUser
if ($userIsAllowInProject == false) {
$categoryList = self::getCategoryIdsByUser($userId, $projectId);
$categoryCondition = '';
if (!empty($categoryList)) {
$categoryIds = implode(',', array_map('intval', $categoryList));
$categoryCondition = " OR ticket.category_id IN ($categoryIds)";
}
$sql .= " AND (ticket.assigned_last_user = $userId OR ticket.sys_insert_user_id = $userId".$categoryCondition.")";
}
// Search simple
if (isset($_GET['submit_simple'])) {
if ($_GET['keyword'] != '') {
$keyword = Database::escape_string(trim($_GET['keyword']));
$sql .= " AND (
ticket.code LIKE '%$keyword%' OR
ticket.subject LIKE '%$keyword%' OR
ticket.message LIKE '%$keyword%' OR
ticket.keyword LIKE '%$keyword%' OR
ticket.personal_email LIKE '%$keyword%' OR
ticket.source LIKE '%$keyword%'
)";
}
// Simple search (align with getTicketsByCurrentUser)
if (isset($_GET['submit_simple']) && $_GET['keyword'] !== '') {
$keyword = Database::escape_string(trim($_GET['keyword']));
$sql .= " AND (
ticket.id LIKE '%$keyword%' OR
ticket.code LIKE '%$keyword%' OR
ticket.subject LIKE '%$keyword%' OR
ticket.message LIKE '%$keyword%' OR
ticket.keyword LIKE '%$keyword%' OR
ticket.source LIKE '%$keyword%' OR
cat.name LIKE '%$keyword%' OR
status.name LIKE '%$keyword%' OR
priority.name LIKE '%$keyword%' OR
ticket.personal_email LIKE '%$keyword%'
)";
}
// Exact-match filters
$keywords = [
'project_id' => 'ticket.project_id',
'keyword_category' => 'ticket.category_id',
@@ -1128,42 +1194,42 @@ class TicketManager
];
foreach ($keywords as $keyword => $sqlLabel) {
if (!empty($_GET[$keyword])) {
if (isset($_GET[$keyword])) {
$data = Database::escape_string(trim($_GET[$keyword]));
$sql .= " AND $sqlLabel = '$data' ";
if ($data !== '') {
$sql .= " AND $sqlLabel = '$data' ";
}
}
}
// Search advanced
// Advanced search: date range and course
$keyword_start_date_start = isset($_GET['keyword_start_date_start']) ? Database::escape_string(trim($_GET['keyword_start_date_start'])) : '';
$keyword_start_date_end = isset($_GET['keyword_start_date_end']) ? Database::escape_string(trim($_GET['keyword_start_date_end'])) : '';
$keyword_range = isset($_GET['keyword_dates']) ? Database::escape_string(trim($_GET['keyword_dates'])) : '';
$keyword_course = isset($_GET['keyword_course']) ? Database::escape_string(trim($_GET['keyword_course'])) : '';
$keyword_range = !empty($keyword_start_date_start) && !empty($keyword_start_date_end);
if ($keyword_range == false && $keyword_start_date_start != '') {
$sql .= " AND DATE_FORMAT( ticket.start_date,'%d/%m/%Y') = '$keyword_start_date_start' ";
if (!$keyword_range && $keyword_start_date_start !== '') {
$sql .= " AND ticket.start_date >= '$keyword_start_date_start' ";
}
if ($keyword_range && $keyword_start_date_start != '' && $keyword_start_date_end != '') {
$sql .= " AND DATE_FORMAT( ticket.start_date,'%d/%m/%Y') >= '$keyword_start_date_start'
AND DATE_FORMAT( ticket.start_date,'%d/%m/%Y') <= '$keyword_start_date_end'";
if ($keyword_range) {
$sql .= " AND ticket.start_date >= '$keyword_start_date_start' AND ticket.start_date <= '$keyword_start_date_end'";
}
if ($keyword_course != '') {
if ($keyword_course !== '') {
$course_table = Database::get_main_table(TABLE_MAIN_COURSE);
$sql .= " AND ticket.course_id IN (
SELECT id
FROM $course_table
WHERE (
title LIKE '%$keyword_course%' OR
code LIKE '%$keyword_course%' OR
visual_code LIKE '%$keyword_course%'
)
) ";
SELECT id FROM $course_table
WHERE (
title LIKE '%$keyword_course%' OR
code LIKE '%$keyword_course%' OR
visual_code LIKE '%$keyword_course%'
)
)";
}
$res = Database::query($sql);
$obj = Database::fetch_object($res);
return (int) $obj->total;
return $obj ? (int) $obj->total : 0;
}
/**
@@ -1964,6 +2030,27 @@ class TicketManager
return 0;
}
/**
* Returns the numeric priority ID from its code (e.g. 'NRM', 'HGH').
*
* @param string $code
*
* @return int
*/
public static function getPriorityIdFromCode($code)
{
$item = Database::getManager()
->getRepository('ChamiloTicketBundle:Priority')
->findOneBy(['code' => $code])
;
if ($item) {
return $item->getId();
}
return 0;
}
/**
* @return array
*/
+5 -5
View File
@@ -86,11 +86,11 @@ class UnserializeApi
[
learnpath::class,
learnpathItem::class,
aicc::class,
aiccBlock::class,
aiccItem::class,
aiccObjective::class,
aiccResource::class,
//aicc::class,
//aiccBlock::class,
//aiccItem::class,
//aiccObjective::class,
//aiccResource::class,
scorm::class,
scormItem::class,
scormMetadata::class,
+11 -11
View File
@@ -1224,15 +1224,15 @@ class AddCourse
$code = $params['code'];
$visual_code = $params['visual_code'];
$directory = $params['directory'];
$tutor_name = isset($params['tutor_name']) ? $params['tutor_name'] : null;
$category_code = isset($params['course_category']) ? $params['course_category'] : '';
$course_language = isset($params['course_language']) && !empty($params['course_language']) ? $params['course_language'] : api_get_setting(
'platformLanguage'
);
$tutor_name = $params['tutor_name'] ?? null;
$category_code = $params['course_category'] ?? '';
$course_language = !empty($params['course_language'])
? $params['course_language']
: api_get_setting('platformLanguage');
$user_id = empty($params['user_id']) ? api_get_user_id() : (int) $params['user_id'];
$department_name = isset($params['department_name']) ? $params['department_name'] : null;
$department_url = isset($params['department_url']) ? $params['department_url'] : null;
$disk_quota = isset($params['disk_quota']) ? $params['disk_quota'] : null;
$department_name = $params['department_name'] ?? null;
$department_url = $params['department_url'] ?? null;
$disk_quota = $params['disk_quota'] ?? null;
if (!isset($params['visibility'])) {
$default_course_visibility = api_get_setting(
@@ -1253,9 +1253,9 @@ class AddCourse
$subscribe = $visibility == COURSE_VISIBILITY_OPEN_PLATFORM ? 1 : 0;
}
$unsubscribe = isset($params['unsubscribe']) ? (int) $params['unsubscribe'] : 0;
$expiration_date = isset($params['expiration_date']) ? $params['expiration_date'] : null;
$teachers = isset($params['teachers']) ? $params['teachers'] : null;
$status = isset($params['status']) ? $params['status'] : null;
$expiration_date = $params['expiration_date'] ?? null;
$teachers = $params['teachers'] ?? null;
$status = $params['status'] ?? null;
$TABLECOURSE = Database::get_main_table(TABLE_MAIN_COURSE);
$TABLECOURSUSER = Database::get_main_table(TABLE_MAIN_COURSE_USER);
+433 -12
View File
@@ -4338,6 +4338,11 @@ function api_get_item_visibility(
$groupCondition = " AND to_group_id = '$group_id' ";
}
$lpVisibilityCondition = '';
if ($tool === 'learnpath') {
$lpVisibilityCondition = " AND lastedit_type != 'LearnpathSubscription' ";
}
$sql = "SELECT visibility
FROM $TABLE_ITEMPROPERTY
WHERE
@@ -4345,7 +4350,7 @@ function api_get_item_visibility(
tool = '$tool' AND
ref = $id AND
(session_id = $session OR session_id = 0 OR session_id IS NULL)
$userCondition $typeCondition $groupCondition
$userCondition $typeCondition $groupCondition $lpVisibilityCondition
ORDER BY session_id DESC, lastedit_date DESC
LIMIT 1";
@@ -4991,7 +4996,7 @@ function api_get_item_property_info($course_id, $tool, $ref, $session_id = 0, $g
*
* @return array with all fields from c_item_property, empty array if not found or false if course could not be found
*/
function api_get_last_item_property_info(int $courseId, string $tool, int $ref, int $sessionId = null, int $groupId = null): array
function api_get_last_item_property_info(int $courseId, string $tool, int $ref, ?int $sessionId = null, ?int $groupId = null): array
{
$tool = Database::escape_string($tool);
// Definition of tables.
@@ -7163,6 +7168,22 @@ function api_is_xml_http_request()
*/
function api_getimagesize($path)
{
$path = str_replace(
[
api_get_path(WEB_COURSE_PATH),
api_get_path(WEB_UPLOAD_PATH),
api_get_path(WEB_CODE_PATH),
api_get_path(WEB_PATH),
],
[
api_get_path(SYS_COURSE_PATH),
api_get_path(SYS_UPLOAD_PATH),
api_get_path(SYS_CODE_PATH),
api_get_path(SYS_PATH),
],
$path
);
$image = new Image($path);
return $image->get_image_size();
@@ -8955,6 +8976,10 @@ function api_can_login_as($loginAsUserId, $userId = null)
$userInfo = api_get_user_info($loginAsUserId);
$isDrh = function () use ($loginAsUserId) {
if (api_is_drh()) {
if (true === api_get_configuration_value('disallow_hrm_login_as')) {
return false;
}
if (api_drh_can_access_all_session_content()) {
$users = SessionManager::getAllUsersFromCoursesFromAllSessionFromStatus(
'drh_all',
@@ -8981,14 +9006,26 @@ function api_can_login_as($loginAsUserId, $userId = null)
return false;
};
$loginAsStatusForSessionAdmins = [STUDENT];
$allowSessionAdmin = function () use ($userInfo) {
if (!api_is_session_admin()) {
return false;
}
if (api_get_configuration_value('allow_session_admin_login_as_teacher')) {
$loginAsStatusForSessionAdmins[] = COURSEMANAGER;
}
if (true === api_get_configuration_value('disallow_session_admin_login_as')) {
return false;
}
$loginAsStatusForSessionAdmins = [STUDENT];
if (api_get_configuration_value('allow_session_admin_login_as_teacher')) {
$loginAsStatusForSessionAdmins[] = COURSEMANAGER;
}
return in_array($userInfo['status'], $loginAsStatusForSessionAdmins);
};
return api_is_platform_admin() ||
(api_is_session_admin() && in_array($userInfo['status'], $loginAsStatusForSessionAdmins)) ||
$allowSessionAdmin() ||
$isDrh();
}
@@ -10234,11 +10271,11 @@ function api_unserialize_content($type, $serialized, $ignoreErrors = false)
$allowedClasses = [
learnpath::class,
learnpathItem::class,
aicc::class,
aiccBlock::class,
aiccItem::class,
aiccObjective::class,
aiccResource::class,
//aicc::class,
//aiccBlock::class,
//aiccItem::class,
//aiccObjective::class,
//aiccResource::class,
scorm::class,
scormItem::class,
scormMetadata::class,
@@ -10699,3 +10736,387 @@ function api_encrypt_hash($data, $secret)
return base64_encode($iv).base64_encode($encrypted.$tag);
}
/**
* Replace a specific term by another in all course-related text elements in the database.
* Does not rename directories or replace content of files on disk. Check tests/scripts/replace_course_code.php if
* you are looking for this.
* The replacement can replace bits in larger strings, requiring the search string to be very specific to avoid
* excess replacements.
*
* @return array The number of changes executed in each table
*/
function api_replace_terms_in_content(string $search, string $replace): array
{
$replacements = [
Database::get_course_table(TABLE_QUIZ_TEST) => [
'iid' => ['title', 'description', 'sound'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION) => [
'iid' => ['question', 'description'],
],
Database::get_course_table(TABLE_QUIZ_ANSWER) => [
'iid' => ['answer', 'comment'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_ATTENDANCE) => [
'iid' => ['name', 'description', 'attendance_qualify_title'],
],
Database::get_course_table(TABLE_BLOGS) => [
'iid' => ['blog_name', 'blog_subtitle'],
],
Database::get_course_table(TABLE_BLOGS_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_COMMENTS) => [
'iid' => ['title', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_POSTS) => [
'iid' => ['title', 'full_text'],
],
Database::get_course_table(TABLE_BLOGS_TASKS) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_AGENDA) => [
'iid' => ['title', 'content', 'comment'],
],
Database::get_course_table(TABLE_AGENDA_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_COURSE_DESCRIPTION) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_DOCUMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_DROPBOX_FEEDBACK) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_DROPBOX_FILE) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_DROPBOX_POST) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_FORUM_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_FORUM_CATEGORY) => [
'iid' => ['cat_title', 'cat_comment'],
],
Database::get_course_table(TABLE_FORUM) => [
'iid' => ['forum_title', 'forum_comment', 'forum_image'],
],
Database::get_course_table(TABLE_FORUM_POST) => [
'iid' => ['post_title', 'post_text', 'poster_name'],
],
Database::get_course_table(TABLE_FORUM_THREAD) => [
'iid' => ['thread_title', 'thread_poster_name', 'thread_title_qualify'],
],
Database::get_course_table(TABLE_GLOSSARY) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_GROUP_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_GROUP) => [
'iid' => ['name', 'description', 'secret_directory'],
],
Database::get_course_table(TABLE_LINK) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_LINK_CATEGORY) => [
'iid' => ['category_title', 'description'],
],
Database::get_course_table(TABLE_LP_MAIN) => [
'iid' => ['name', 'ref', 'description', 'path', 'content_license', 'preview_image', 'theme'],
],
Database::get_course_table(TABLE_LP_CATEGORY) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_LP_ITEM) => [
'iid' => ['prerequisite', 'description', 'title', 'parameters', 'launch_data', 'terms'],
],
Database::get_course_table(TABLE_LP_ITEM_VIEW) => [
'iid' => ['suspend_data', 'lesson_location'],
],
Database::get_course_table(TABLE_NOTEBOOK) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ONLINE_LINK) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ROLE) => [
'iid' => ['role_name', 'role_comment'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION) => [
'iid' => ['title', 'title_correction', 'description'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION_ASSIGNMENT_COMMENT) => [
'iid' => ['comment', 'file'],
],
Database::get_course_table(TABLE_SURVEY) => [
'iid' => ['title', 'subtitle', 'surveythanks', 'invite_mail', 'reminder_mail', 'mail_subject', 'access_condition', 'form_fields'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_GROUP) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION) => [
'iid' => ['survey_question', 'survey_question_comment'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION) => [
'iid' => ['option_text'],
],
Database::get_course_table(TABLE_THEMATIC) => [
'iid' => ['content', 'title'],
],
Database::get_course_table(TABLE_THEMATIC_ADVANCE) => [
'iid' => ['content'],
],
Database::get_course_table(TABLE_THEMATIC_PLAN) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_LIST) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_INTRO) => [
'iid' => ['intro_text'],
],
Database::get_course_table(TABLE_USER_INFO_DEF) => [
'iid' => ['comment'],
],
Database::get_course_table(TABLE_WIKI) => [
'iid' => ['title', 'content', 'comment', 'progress', 'linksto'],
],
Database::get_course_table(TABLE_WIKI_CONF) => [
'iid' => ['feedback1', 'feedback2', 'feedback3'],
],
Database::get_course_table(TABLE_WIKI_DISCUSS) => [
'iid' => ['comment'],
],
Database::get_main_table(TABLE_CAREER) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_CHAT) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_MAIN_CLASS) => [
'id' => ['name'],
],
Database::get_main_table(TABLE_MAIN_COURSE_REQUEST) => [
'id' => ['description', 'title', 'objetives', 'target_audience'],
],
'course_type' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_EVENT_EMAIL_TEMPLATE) => [
'id' => ['message', 'subject', 'event_type_name'],
],
Database::get_main_table(TABLE_GRADE_MODEL) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CATEGORY) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CERTIFICATE) => [
'id' => ['path_certificate'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_EVALUATION) => [
'id' => ['description', 'name'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_LINKEVAL_LOG) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_LEGAL) => [
'id' => ['content', 'changes'],
],
Database::get_main_table(TABLE_MESSAGE) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MESSAGE_ATTACHMENT) => [
'id' => ['path', 'comment', 'filename'],
],
Database::get_main_table(TABLE_NOTIFICATION) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_PERSONAL_AGENDA) => [
'id' => ['title', 'text'],
],
Database::get_main_table(TABLE_PROMOTION) => [
'id' => ['description'],
],
'room' => [
'id' => ['description'],
],
'sequence_condition' => [
'id' => ['description'],
],
'sequence_method' => [
'id' => ['description', 'formula'],
],
'sequence_rule' => [
'id' => ['description'],
],
'sequence_type_entity' => [
'id' => ['description'],
],
'sequence_variable' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SESSION) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY) => [
'survey_id' => ['subtitle', 'surveythanks', 'intro'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION) => [
'question_id' => ['survey_question', 'survey_question_comment'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION_OPTION) => [
'question_option_id' => ['option_text'],
],
Database::get_main_table(TABLE_MAIN_SKILL) => [
'id' => ['name', 'description', 'criteria'],
],
Database::get_main_table(TABLE_MAIN_SKILL_PROFILE) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SKILL_REL_USER) => [
'id' => ['argumentation'],
],
'skill_rel_user_comment' => [
'id' => ['feedback_text'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_ANNOUNCEMENTS) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_CALENDAR) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_TEMPLATE) => [
'id' => ['comment', 'content'],
],
Database::get_main_table(TABLE_MAIN_TEMPLATES) => [
'id' => ['description', 'image'],
],
Database::get_main_table(TABLE_TICKET_CATEGORY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE_ATTACHMENTS) => [
'id' => ['filename', 'path'],
],
Database::get_main_table(TABLE_TICKET_PRIORITY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_PROJECT) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_STATUS) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_TICKET) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT) => [
'id' => ['answer', 'teacher_comment', 'filename'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING) => [
'id' => ['teacher_comment'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_DEFAULT) => [
'default_id' => ['default_value'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES) => [
'exe_id' => ['data_tracking', 'questions_to_check'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ITEM_PROPERTY) => [
'id' => ['content'],
],
'track_e_open' => [
'open_id' => ['open_remote_host', 'open_agent', 'open_referer'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES_STACK) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_MAIN_USER_API_KEY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_USERGROUP) => [
'id' => ['name', 'description', 'picture', 'url'],
],
Database::get_main_table(TABLE_MAIN_BLOCK) => [
'id' => ['name', 'description', 'path'],
],
];
if (api_get_configuration_value('attendance_allow_comments')) {
$replacements['c_attendance_result_comment'] = [
'iid' => ['comment'],
];
}
if (api_get_configuration_value('exercise_text_when_finished_failure')) {
$replacements[Database::get_course_table(TABLE_QUIZ_TEST)]['iid'][] = 'text_when_finished_failure';
}
$changes = array_map(
fn ($table) => 0,
$replacements
);
foreach ($replacements as $table => $replacement) {
foreach ($replacement as $idColumn => $columns) {
$keys = array_map(fn ($column) => "$column LIKE %?%", $columns);
$values = array_fill(0, count($columns), $search);
$result = Database::select(
[$idColumn, ...$columns],
$table,
[
'where' => [
implode(' OR ', $keys) => $values,
],
'order' => "$idColumn ASC",
]
);
foreach ($result as $row) {
$attributes = array_combine(
$columns,
array_map(
fn ($column) => preg_replace('#'.$search.'#', $replace, $row[$column]),
$columns
)
);
try {
Database::update(
$table,
$attributes,
["$idColumn = ?" => $row[$idColumn]]
);
} catch (Exception $e) {
Database::handleError($e);
}
$changes[$table]++;
}
}
}
return $changes;
}
+14 -1
View File
@@ -776,6 +776,10 @@ class Attendance
);
$value['photo'] = $photo;
$addOfficialCode = api_get_configuration_value('attendance_add_official_code');
if ($addOfficialCode) {
$value['official_code'] = $user_data['official_code'];
}
$value['firstname'] = $user_data['firstname'];
$value['lastname'] = $user_data['lastname'];
$value['username'] = $user_data['username'];
@@ -1321,6 +1325,7 @@ class Attendance
if (Database::num_rows($res) > 0) {
while ($row = Database::fetch_array($res)) {
$row['duration'] = Attendance::getAttendanceCalendarExtraFieldValue('duration', $row['calendar_id']);
$row['date_time'] = api_get_local_time($row['date_time']);
$row['date_time'] = api_convert_and_format_date($row['date_time'], null, date_default_timezone_get());
$data[$user_id][] = $row;
}
@@ -2722,7 +2727,12 @@ class Attendance
// Get data table
$dataTable = [];
$headTable = ['#', get_lang('Name')];
$addOfficialCode = api_get_configuration_value('attendance_add_official_code');
if ($addOfficialCode) {
$headTable = ['#', get_lang('OfficialCode'), get_lang('Name')];
} else {
$headTable = ['#', get_lang('Name')];
}
foreach ($calendar as $classDay) {
$labelDuration = !empty($classDay['duration']) ? get_lang('Duration').' : '.$classDay['duration'] : '';
$headTable[] =
@@ -2739,6 +2749,9 @@ class Attendance
$cols = 1;
$result = [];
$result['count'] = $count;
if ($addOfficialCode) {
$result['official_code'] = $user['official_code'];
}
$result['full_name'] = api_get_person_name($user['firstname'], $user['lastname']);
foreach ($calendar as $classDay) {
$commentInfo = $this->getComment($user['user_id'], $classDay['id']);
+2 -2
View File
@@ -234,8 +234,8 @@ class Auth
$result = false;
$table = Database::get_main_table(TABLE_USER_COURSE_CATEGORY);
$sql = "UPDATE $table
SET title='".api_htmlentities($title, ENT_QUOTES, api_get_system_encoding())."'
WHERE id='".$category_id."'";
SET title = '".api_htmlentities($title, ENT_QUOTES, api_get_system_encoding())."'
WHERE id = ".$category_id." AND user_id = ".api_get_user_id();
$resultQuery = Database::query($sql);
if (Database::affected_rows($resultQuery)) {
$result = true;
+19 -4
View File
@@ -3030,16 +3030,31 @@ class Blog
$visibility_icon = ($info_log[2] == 0) ? 'invisible' : 'visible';
$visibility_info = ($info_log[2] == 0) ? 'Visible' : 'Invisible';
$my_image = '<a href="'.api_get_self().'?action=visibility&blog_id='.$info_log[3].'">';
$secToken = Security::get_existing_token('blog');
$my_image = '<a href="'.api_get_self().'?'
.http_build_query([
'action' => 'visibility',
'blog_id' => $info_log[3],
'blog_sec_token' => $secToken,
]).'">';
$my_image .= Display::return_icon($visibility_icon.'.png', get_lang($visibility_info));
$my_image .= "</a>";
$my_image .= '<a href="'.api_get_self().'?action=edit&blog_id='.$info_log[3].'">';
$my_image .= '<a href="'.api_get_self().'?'
.http_build_query([
'action' => 'edit',
'blog_id' => $info_log[3],
]).'">';
$my_image .= Display::return_icon('edit.png', get_lang('EditBlog'));
$my_image .= "</a>";
$my_image .= '<a href="'.api_get_self().'?action=delete&blog_id='.$info_log[3].'" ';
$my_image .= 'onclick="javascript:if(!confirm(\''.addslashes(
$my_image .= '<a href="'.api_get_self().'?'
.http_build_query([
'action' => 'delete',
'blog_id' => $info_log[3],
'blog_sec_token' => $secToken,
]).'" onclick="javascript:if(!confirm(\''.addslashes(
api_htmlentities(get_lang("ConfirmYourChoice"), ENT_QUOTES, $charset)
).'\')) return false;" >';
$my_image .= Display::return_icon('delete.png', get_lang('DeleteBlog'));
+63 -14
View File
@@ -8,6 +8,7 @@ use Chamilo\CoreBundle\Entity\Repository\SequenceResourceRepository;
use Chamilo\CoreBundle\Entity\SequenceResource;
use Chamilo\CourseBundle\Component\CourseCopy\CourseBuilder;
use Chamilo\CourseBundle\Component\CourseCopy\CourseRestorer;
use Chamilo\CourseBundle\Entity\CCourseDescription;
use ChamiloSession as Session;
use Doctrine\Common\Collections\Criteria;
@@ -93,7 +94,7 @@ class CourseManager
// Create the course keys
$keys = AddCourse::define_course_keys($params['wanted_code']);
$params['exemplary_content'] = isset($params['exemplary_content']) ? $params['exemplary_content'] : false;
$params['exemplary_content'] = $params['exemplary_content'] ?? false;
if (count($keys)) {
$params['code'] = $keys['currentCourseCode'];
@@ -2436,7 +2437,7 @@ class CourseManager
return [];
}
$session_id != 0 ? $session_condition = ' WHERE g.session_id IN(1,'.intval($session_id).')' : $session_condition = ' WHERE g.session_id = 0';
$session_id != 0 ? $session_condition = ' WHERE g.session_id = '.intval($session_id) : $session_condition = ' WHERE g.session_id = 0';
if ($in_get_empty_group == 0) {
// get only groups that are not empty
$sql = "SELECT DISTINCT g.id, g.iid, g.name
@@ -3442,24 +3443,24 @@ class CourseManager
/**
* Lists details of the course description.
*
* @param array The course description
* @param string The encoding
* @param bool If true is displayed if false is hidden
* @param array<int, CCourseDescription> $descriptions The course description
* @param string $charset The encoding
* @param bool $action_show If true is displayed if false is hidden
*
* @return string The course description in html
*/
public static function get_details_course_description_html(
$descriptions,
$charset,
$action_show = true
) {
array $descriptions,
string $charset,
bool $action_show = true
): ?string {
$data = null;
if (isset($descriptions) && count($descriptions) > 0) {
if (count($descriptions) > 0) {
foreach ($descriptions as $description) {
$data .= '<div class="sectiontitle">';
if (api_is_allowed_to_edit() && $action_show) {
//delete
$data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&action=delete&description_id='.$description->id.'" onclick="javascript:if(!confirm(\''.addslashes(api_htmlentities(
$data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&action=delete&description_id='.$description->getIid().'" onclick="javascript:if(!confirm(\''.addslashes(api_htmlentities(
get_lang('ConfirmYourChoice'),
ENT_QUOTES,
$charset
@@ -3471,7 +3472,7 @@ class CourseManager
);
$data .= '</a> ';
//edit
$data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&description_id='.$description->id.'">';
$data .= '<a href="'.api_get_self().'?'.api_get_cidreq().'&description_id='.$description->getIid().'">';
$data .= Display::return_icon(
'edit.png',
get_lang('Edit'),
@@ -3480,10 +3481,10 @@ class CourseManager
);
$data .= '</a> ';
}
$data .= $description->title;
$data .= Security::remove_XSS($description->getTitle());
$data .= '</div>';
$data .= '<div class="sectioncomment">';
$data .= Security::remove_XSS($description->content);
$data .= Security::remove_XSS($description->getContent());
$data .= '</div>';
}
} else {
@@ -7435,6 +7436,54 @@ class CourseManager
return $logo;
}
public static function searchCourse(string $searchTerm, ?int $sessionId = null): array
{
if (!empty($sessionId)) {
//if session is defined, lets find only courses of this session
return SessionManager::get_course_list_by_session_id(
$sessionId,
$searchTerm
);
}
//if session is not defined lets search all courses STARTING with $searchTerm
//TODO change this function to search not only courses STARTING with $searchTerm
if (api_is_platform_admin()) {
return CourseManager::get_courses_list(
0,
0,
'title',
'ASC',
-1,
$searchTerm,
null,
true
);
}
if (api_is_teacher()) {
$courseList = CourseManager::get_course_list_of_user_as_course_admin(api_get_user_id(), $searchTerm);
$category = api_get_configuration_value('course_category_code_to_use_as_model');
if (!empty($category)) {
$alreadyAdded = [];
if (!empty($courseList)) {
$alreadyAdded = array_column($courseList, 'id');
}
$coursesInCategory = CourseCategory::getCoursesInCategory($category, $searchTerm);
foreach ($coursesInCategory as $course) {
if (!in_array($course['id'], $alreadyAdded)) {
$courseList[] = $course;
}
}
}
return $courseList;
}
return [];
}
/**
* Check if a specific access-url-related setting is a problem or not.
*
+52 -8
View File
@@ -3,6 +3,7 @@
/* For licensing terms, see /license.txt */
use ChamiloSession as Session;
use enshrined\svgSanitize\Sanitizer;
/**
* Class DocumentManager
@@ -486,6 +487,14 @@ class DocumentManager
}
echo $content;
} else {
if ('image/svg+xml' === $contentType) {
$svgContent = file_get_contents($full_file_name);
echo (new Sanitizer())->sanitize($svgContent);
return true;
}
if (isset($enableMathJaxScript) && $enableMathJaxScript === true) {
$content = file_get_contents($full_file_name);
$content = self::includeMathJaxScript($content);
@@ -3207,27 +3216,27 @@ class DocumentManager
fclose($handle);
break;
case 'application/pdf':
exec("pdftotext $doc_path -", $output, $ret_val);
exec("pdftotext ".escapeshellarg($doc_path)." -", $output, $ret_val);
break;
case 'application/postscript':
$temp_file = tempnam(sys_get_temp_dir(), 'chamilo');
exec("ps2pdf $doc_path $temp_file", $output, $ret_val);
exec("ps2pdf ".escapeshellarg($doc_path)." ".escapeshellarg($temp_file), $output, $ret_val);
if ($ret_val !== 0) { // shell fail, probably 127 (command not found)
return false;
}
exec("pdftotext $temp_file -", $output, $ret_val);
exec("pdftotext ".escapeshellarg($temp_file)." -", $output, $ret_val);
unlink($temp_file);
break;
case 'application/msword':
exec("catdoc $doc_path", $output, $ret_val);
exec("catdoc ".escapeshellarg($doc_path), $output, $ret_val);
break;
case 'text/html':
exec("html2text $doc_path", $output, $ret_val);
exec("html2text ".escapeshellarg($doc_path), $output, $ret_val);
break;
case 'text/rtf':
// Note: correct handling of code pages in unrtf
// on debian lenny unrtf v0.19.2 can not, but unrtf v0.20.5 can
exec("unrtf --text $doc_path", $output, $ret_val);
exec("unrtf --text ".escapeshellarg($doc_path), $output, $ret_val);
if ($ret_val == 127) { // command not found
return false;
}
@@ -3245,10 +3254,10 @@ class DocumentManager
}
break;
case 'application/vnd.ms-powerpoint':
exec("catppt $doc_path", $output, $ret_val);
exec("catppt ".escapeshellarg($doc_path), $output, $ret_val);
break;
case 'application/vnd.ms-excel':
exec("xls2csv -c\" \" $doc_path", $output, $ret_val);
exec("xls2csv -c\" \" ".escapeshellarg($doc_path), $output, $ret_val);
break;
}
@@ -7168,6 +7177,41 @@ class DocumentManager
);
}
public static function autoResizeImageIfNeeded(int $size, string $tmpName): int
{
$resizeMax = api_get_configuration_value('wysiwyg_image_auto_resize_max');
if (is_array($resizeMax)) {
if ($size > ($resizeMax['mb'] * 1024 * 1024)) {
throw new Exception(get_lang('UplFileTooBig'));
}
$temp = new Image($tmpName);
$pictureInfo = $temp->get_image_info();
$thumbSize = 0;
if ($pictureInfo['width'] > $pictureInfo['height']) {
if ($pictureInfo['width'] > $resizeMax['w']) {
$thumbSize = $resizeMax['w'];
}
} else {
if ($pictureInfo['height'] > $resizeMax['h']) {
$thumbSize = $resizeMax['h'];
}
}
if ($thumbSize) {
$temp->resize($thumbSize);
$temp->send_image($tmpName);
return filesize($tmpName);
}
}
return $size;
}
/**
* Parse file information into a link.
*
+2
View File
@@ -18,8 +18,10 @@ if (file_exists($file)) {
$language = $iso;
}
$questionId = isset($_REQUEST['question_id']) ? (int) $_REQUEST['question_id'] : 0;
$sessionId = api_get_session_id();
$template->assign('question_id', $questionId);
$template->assign('session_id', $sessionId);
$template->assign('elfinder_lang', $language);
$template->assign('elfinder_translation_file', $includeFile);
$template->display('default/javascript/editor/ckeditor/elfinder.tpl');
+11 -382
View File
@@ -2441,6 +2441,8 @@ HOTSPOT;
$courseId = $values['course_id'] ?? 0;
$exerciseId = $values['exercise_id'] ?? 0;
$status = $values['status'] ?? 0;
$questionType = $values['questionType'] ?? ($values['questionTypeId'] ?? 0);
$showAttemptsInSessions = api_get_configuration_value('show_exercise_attempts_in_all_user_sessions');
$whereCondition = '';
if (isset($_GET['filter_by_user']) && !empty($_GET['filter_by_user'])) {
$filter_user = (int) $_GET['filter_by_user'];
@@ -2486,7 +2488,10 @@ HOTSPOT;
false,
false,
true,
$status
$status,
$showAttemptsInSessions,
$questionType,
true
);
if (!empty($result)) {
@@ -3245,9 +3250,6 @@ HOTSPOT;
if (api_is_drh() && !api_is_platform_admin()) {
$delete_link = null;
}
if (api_is_session_admin()) {
$delete_link = '';
}
if ($revised == 3) {
$delete_link = null;
}
@@ -5455,7 +5457,7 @@ EOT;
// Category report
$category_was_added_for_this_test = false;
if (isset($objQuestionTmp->category) && !empty($objQuestionTmp->category)) {
if (!empty($objQuestionTmp->category)) {
if (!isset($category_list[$objQuestionTmp->category]['score'])) {
$category_list[$objQuestionTmp->category]['score'] = 0;
}
@@ -5493,7 +5495,7 @@ EOT;
$category_list[$objQuestionTmp->category]['total_questions']++;
$category_was_added_for_this_test = true;
}
if (isset($objQuestionTmp->category_list) && !empty($objQuestionTmp->category_list)) {
if (!empty($objQuestionTmp->category_list)) {
foreach ($objQuestionTmp->category_list as $category_id) {
$category_list[$category_id]['score'] += $my_total_score;
$category_list[$category_id]['total'] += $my_total_weight;
@@ -5502,7 +5504,7 @@ EOT;
}
// No category for this question!
if ($category_was_added_for_this_test == false) {
if (!$category_was_added_for_this_test) {
if (!isset($category_list['none']['score'])) {
$category_list['none']['score'] = 0;
}
@@ -6253,6 +6255,8 @@ EOT;
*/
public static function getFeedbackText($message)
{
$message = Security::remove_XSS($message);
return Display::return_message($message, 'warning', false);
}
@@ -7479,381 +7483,6 @@ EOT;
return $output;
}
public static function replaceTermsInContent(string $search, string $replace): array
{
$replacements = [
Database::get_course_table(TABLE_QUIZ_TEST) => [
'iid' => ['title', 'description', 'sound'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION) => [
'iid' => ['question', 'description'],
],
Database::get_course_table(TABLE_QUIZ_ANSWER) => [
'iid' => ['answer', 'comment'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_ATTENDANCE) => [
'iid' => ['name', 'description', 'attendance_qualify_title'],
],
Database::get_course_table(TABLE_BLOGS) => [
'iid' => ['blog_name', 'blog_subtitle'],
],
Database::get_course_table(TABLE_BLOGS_ATTACHMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_COMMENTS) => [
'iid' => ['title', 'comment'],
],
Database::get_course_table(TABLE_BLOGS_POSTS) => [
'iid' => ['title', 'full_text'],
],
Database::get_course_table(TABLE_BLOGS_TASKS) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_AGENDA) => [
'iid' => ['title', 'content', 'comment'],
],
Database::get_course_table(TABLE_AGENDA_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_COURSE_DESCRIPTION) => [
'iid' => ['title', 'content'],
],
Database::get_course_table(TABLE_DOCUMENT) => [
'iid' => ['path', 'comment'],
],
Database::get_course_table(TABLE_DROPBOX_FEEDBACK) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_DROPBOX_FILE) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_DROPBOX_POST) => [
'iid' => ['feedback'],
],
Database::get_course_table(TABLE_FORUM_ATTACHMENT) => [
'iid' => ['path', 'comment', 'filename'],
],
Database::get_course_table(TABLE_FORUM_CATEGORY) => [
'iid' => ['cat_title', 'cat_comment'],
],
Database::get_course_table(TABLE_FORUM) => [
'iid' => ['forum_title', 'forum_comment', 'forum_image'],
],
Database::get_course_table(TABLE_FORUM_POST) => [
'iid' => ['post_title', 'post_text', 'poster_name'],
],
Database::get_course_table(TABLE_FORUM_THREAD) => [
'iid' => ['thread_title', 'thread_poster_name', 'thread_title_qualify'],
],
Database::get_course_table(TABLE_GLOSSARY) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_GROUP_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_GROUP) => [
'iid' => ['name', 'description', 'secret_directory'],
],
Database::get_course_table(TABLE_LINK) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_LINK_CATEGORY) => [
'iid' => ['category_title', 'description'],
],
Database::get_course_table(TABLE_LP_MAIN) => [
'iid' => ['name', 'ref', 'description', 'path', 'content_license', 'preview_image', 'theme'],
],
Database::get_course_table(TABLE_LP_CATEGORY) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_LP_ITEM) => [
'iid' => ['prerequisite', 'description', 'title', 'parameters', 'launch_data', 'terms'],
],
Database::get_course_table(TABLE_LP_ITEM_VIEW) => [
'iid' => ['suspend_data', 'lesson_location'],
],
Database::get_course_table(TABLE_NOTEBOOK) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ONLINE_LINK) => [
'iid' => ['name'],
],
Database::get_course_table(TABLE_QUIZ_QUESTION_CATEGORY) => [
'iid' => ['title', 'description'],
],
Database::get_course_table(TABLE_ROLE) => [
'iid' => ['role_name', 'role_comment'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION) => [
'iid' => ['title', 'title_correction', 'description'],
],
Database::get_course_table(TABLE_STUDENT_PUBLICATION_ASSIGNMENT_COMMENT) => [
'iid' => ['comment', 'file'],
],
Database::get_course_table(TABLE_SURVEY) => [
'iid' => ['title', 'subtitle', 'surveythanks', 'invite_mail', 'reminder_mail', 'mail_subject', 'access_condition', 'form_fields'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_GROUP) => [
'iid' => ['name', 'description'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION) => [
'iid' => ['survey_question', 'survey_question_comment'],
],
Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION) => [
'iid' => ['option_text'],
],
Database::get_course_table(TABLE_THEMATIC) => [
'iid' => ['content', 'title'],
],
Database::get_course_table(TABLE_THEMATIC_ADVANCE) => [
'iid' => ['content'],
],
Database::get_course_table(TABLE_THEMATIC_PLAN) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_LIST) => [
'iid' => ['description'],
],
Database::get_course_table(TABLE_TOOL_INTRO) => [
'iid' => ['intro_text'],
],
Database::get_course_table(TABLE_USER_INFO_DEF) => [
'iid' => ['comment'],
],
Database::get_course_table(TABLE_WIKI) => [
'iid' => ['title', 'content', 'comment', 'progress', 'linksto'],
],
Database::get_course_table(TABLE_WIKI_CONF) => [
'iid' => ['feedback1', 'feedback2', 'feedback3'],
],
Database::get_course_table(TABLE_WIKI_DISCUSS) => [
'iid' => ['comment'],
],
Database::get_main_table(TABLE_CAREER) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_CHAT) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_MAIN_CLASS) => [
'id' => ['name'],
],
Database::get_main_table(TABLE_MAIN_COURSE_REQUEST) => [
'id' => ['description', 'title', 'objetives', 'target_audience'],
],
'course_type' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_EVENT_EMAIL_TEMPLATE) => [
'id' => ['message', 'subject', 'event_type_name'],
],
Database::get_main_table(TABLE_GRADE_MODEL) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CATEGORY) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_CERTIFICATE) => [
'id' => ['path_certificate'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_EVALUATION) => [
'id' => ['description', 'name'],
],
Database::get_main_table(TABLE_MAIN_GRADEBOOK_LINKEVAL_LOG) => [
'id' => ['name', 'description'],
],
Database::get_main_table(TABLE_MAIN_LEGAL) => [
'id' => ['content', 'changes'],
],
Database::get_main_table(TABLE_MESSAGE) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MESSAGE_ATTACHMENT) => [
'id' => ['path', 'comment', 'filename'],
],
Database::get_main_table(TABLE_NOTIFICATION) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_PERSONAL_AGENDA) => [
'id' => ['title', 'text'],
],
Database::get_main_table(TABLE_PROMOTION) => [
'id' => ['description'],
],
'room' => [
'id' => ['description'],
],
'sequence_condition' => [
'id' => ['description'],
],
'sequence_method' => [
'id' => ['description', 'formula'],
],
'sequence_rule' => [
'id' => ['description'],
],
'sequence_type_entity' => [
'id' => ['description'],
],
'sequence_variable' => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SESSION) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY) => [
'survey_id' => ['subtitle', 'surveythanks', 'intro'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION) => [
'question_id' => ['survey_question', 'survey_question_comment'],
],
Database::get_main_table(TABLE_MAIN_SHARED_SURVEY_QUESTION_OPTION) => [
'question_option_id' => ['option_text'],
],
Database::get_main_table(TABLE_MAIN_SKILL) => [
'id' => ['name', 'description', 'criteria'],
],
Database::get_main_table(TABLE_MAIN_SKILL_PROFILE) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_MAIN_SKILL_REL_USER) => [
'id' => ['argumentation'],
],
'skill_rel_user_comment' => [
'id' => ['feedback_text'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_ANNOUNCEMENTS) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_CALENDAR) => [
'id' => ['content'],
],
Database::get_main_table(TABLE_MAIN_SYSTEM_TEMPLATE) => [
'id' => ['comment', 'content'],
],
Database::get_main_table(TABLE_MAIN_TEMPLATES) => [
'id' => ['description', 'image'],
],
Database::get_main_table(TABLE_TICKET_CATEGORY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_TICKET_MESSAGE_ATTACHMENTS) => [
'id' => ['filename', 'path'],
],
Database::get_main_table(TABLE_TICKET_PRIORITY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_PROJECT) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_STATUS) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_TICKET_TICKET) => [
'id' => ['message'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT) => [
'id' => ['answer', 'teacher_comment', 'filename'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING) => [
'id' => ['teacher_comment'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_DEFAULT) => [
'default_id' => ['default_value'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES) => [
'exe_id' => ['data_tracking', 'questions_to_check'],
],
Database::get_main_table(TABLE_STATISTIC_TRACK_E_ITEM_PROPERTY) => [
'id' => ['content'],
],
'track_e_open' => [
'open_id' => ['open_remote_host', 'open_agent', 'open_referer'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_TRACK_STORED_VALUES_STACK) => [
'id' => ['sv_value'],
],
Database::get_main_table(TABLE_MAIN_USER_API_KEY) => [
'id' => ['description'],
],
Database::get_main_table(TABLE_USERGROUP) => [
'id' => ['name', 'description', 'picture', 'url'],
],
Database::get_main_table(TABLE_MAIN_BLOCK) => [
'id' => ['name', 'description', 'path'],
],
];
if (api_get_configuration_value('attendance_allow_comments')) {
$replacements['c_attendance_result_comment'] = [
'iid' => ['comment'],
];
}
if (api_get_configuration_value('exercise_text_when_finished_failure')) {
$replacements[Database::get_course_table(TABLE_QUIZ_TEST)]['iid'][] = 'text_when_finished_failure';
}
$changes = array_map(
fn ($table) => 0,
$replacements
);
foreach ($replacements as $table => $replacement) {
foreach ($replacement as $idColumn => $columns) {
$keys = array_map(fn ($column) => "$column LIKE %?%", $columns);
$values = array_fill(0, count($columns), $search);
$result = Database::select(
[$idColumn, ...$columns],
$table,
[
'where' => [
implode(' OR ', $keys) => $values,
],
'order' => "$idColumn ASC",
]
);
foreach ($result as $row) {
$attributes = array_combine(
$columns,
array_map(
fn ($column) => preg_replace('#'.$search.'#', $replace, $row[$column]),
$columns
)
);
try {
Database::update(
$table,
$attributes,
["$idColumn = ?" => $row[$idColumn]]
);
} catch (Exception $e) {
Database::handleError($e);
}
$changes[$table]++;
}
}
}
return $changes;
}
private static function subscribeSessionWhenFinishedFailure(int $exerciseId): void
{
$failureSession = self::getSessionWhenFinishedFailure($exerciseId);
+49 -55
View File
@@ -5,11 +5,7 @@
use Chamilo\CoreBundle\Component\Editor\Connector;
use Chamilo\CoreBundle\Component\Filesystem\Data;
use Ddeboer\DataImport\Writer\CsvWriter;
use Ddeboer\DataImport\Writer\ExcelWriter;
use MediaAlchemyst\Alchemyst;
use MediaAlchemyst\DriversContainer;
use Neutron\TemporaryFilesystem\Manager;
use Neutron\TemporaryFilesystem\TemporaryFilesystem;
use Symfony\Component\Filesystem\Filesystem;
/**
@@ -77,14 +73,19 @@ class Export
{
$filePath = api_get_path(SYS_ARCHIVE_PATH).uniqid('').'.xlsx';
$file = new \SplFileObject($filePath, 'w');
$writer = new ExcelWriter($file);
@$writer->prepare();
foreach ($data as $row) {
@$writer->writeItem($row);
$excel = @new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$row = 1;
foreach ($data as $item) {
$values = array_values($item);
$count = count($values);
for ($i = 0; $i < $count; $i++) {
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i + 1, $row, $values[$i]);
}
$row++;
}
@$writer->finish();
$writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($excel, 'Xlsx');
$writer->save($filePath);
DocumentManager::file_send_for_download($filePath, true, $filename.'.xlsx');
exit;
@@ -102,9 +103,8 @@ class Export
$filePath = api_get_path(SYS_ARCHIVE_PATH).uniqid('').'.xlsx';
$file = new \SplFileObject($filePath, 'w');
$excel = @new PHPExcel();
$excel = @new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$type = 'Excel2007';
$sheet = null;
$row = 1;
$prependHeaderRow = false;
@@ -125,7 +125,7 @@ class Export
$headers = array_keys($item);
for ($i = 0; $i < $count; $i++) {
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i, $row, $headers[$i]);
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i + 1, $row, $headers[$i]);
}
$row++;
}
@@ -136,9 +136,9 @@ class Export
if (false !== strpos($values[$i], '[comment]')) {
list($txtValue, $txtComment) = explode('[comment]', $values[$i]);
}
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i, $row, $txtValue);
@$excel->getActiveSheet()->setCellValueByColumnAndRow($i + 1, $row, $txtValue);
if (!empty($txtComment)) {
$columnLetter = PHPExcel_Cell::stringFromColumnIndex($i);
$columnLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i + 1);
$coordinate = $columnLetter.$row;
@$excel->getActiveSheet()->getComment($coordinate)->getText()->createTextRun($txtComment);
}
@@ -146,7 +146,7 @@ class Export
$row++;
}
$writer = \PHPExcel_IOFactory::createWriter($excel, $type);
$writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($excel, 'Xlsx');
$writer->save($file->getPathname());
DocumentManager::file_send_for_download($filePath, true, $filename.'.xlsx');
@@ -370,51 +370,45 @@ class Export
return false;
}
if (!empty($html)) {
$fs = new Filesystem();
$paths = [
'root_sys' => api_get_path(SYS_PATH),
'path.temp' => api_get_path(SYS_ARCHIVE_PATH),
];
$connector = new Connector();
if (empty($html)) {
return false;
}
$drivers = new DriversContainer();
$drivers['configuration'] = [
'unoconv.binaries' => $unoconv,
'unoconv.timeout' => 60,
];
$fs = new Filesystem();
$paths = [
'root_sys' => api_get_path(SYS_PATH),
'path.temp' => api_get_path(SYS_ARCHIVE_PATH),
];
$connector = new Connector();
$tempFilesystem = TemporaryFilesystem::create();
$manager = new Manager($tempFilesystem, $fs);
$alchemyst = new Alchemyst($drivers, $manager);
$dataFileSystem = new Data($paths, $fs, $connector, $unoconv);
$content = $dataFileSystem->convertRelativeToAbsoluteUrl($html);
// convertRelativeToAbsoluteUrl returns a simple_html_dom object; cast to string explicitly
$content = (string) $content;
$dataFileSystem = new Data($paths, $fs, $connector, $alchemyst);
$content = $dataFileSystem->convertRelativeToAbsoluteUrl($html);
$filePath = $dataFileSystem->putContentInTempFile(
$content,
api_replace_dangerous_char($name),
'html'
);
$safeName = api_replace_dangerous_char($name);
$filePath = $dataFileSystem->putContentInTempFile($content, $safeName, 'html');
$try = true;
if (empty($filePath)) {
error_log("htmlToOdt: failed to create temp file for '$name'");
while ($try) {
try {
$convertedFile = $dataFileSystem->transcode(
$filePath,
$format
);
return false;
}
$try = false;
DocumentManager::file_send_for_download(
$convertedFile,
false,
$name.'.'.$format
);
} catch (Exception $e) {
// error_log($e->getMessage());
}
try {
$convertedFile = $dataFileSystem->transcode($filePath, $format);
if ($convertedFile) {
DocumentManager::file_send_for_download(
$convertedFile,
true,
$name.'.'.$format
);
} else {
error_log("htmlToOdt: transcode failed for '$name'");
}
} catch (Exception $e) {
error_log("htmlToOdt: exception during transcode for '$name': ".$e->getMessage());
}
}
}
+79 -8
View File
@@ -789,7 +789,7 @@ class ExtraField extends Model
$setData = [];
foreach ($showOnlyTheseFields as $variable) {
$extraName = 'extra_'.$variable;
if (in_array($extraName, array_keys($extraData))) {
if (array_key_exists($extraName, $extraData)) {
$setData[$extraName] = $extraData[$extraName];
}
}
@@ -824,13 +824,40 @@ class ExtraField extends Model
$help
);
if (!empty($requiredFields)) {
/** @var HTML_QuickForm_input $element */
foreach ($form->getElements() as $element) {
$name = str_replace('extra_', '', $element->getName());
if (in_array($name, $requiredFields)) {
$form->setRequired($element);
}
// Ensure $requiredFields is an array
$requiredFields = is_array($requiredFields) ? $requiredFields : [];
// Fetch the configured “unique extra field” var
$uniqueField = api_get_configuration_value('extra_field_to_validate_on_user_registration');
// Determine if were editing an existing user (item_id present) or creating a new one
$currentUserId = $form->getElementValue('item_id') ?: null;
// Always mark the unique extra field as required on user forms
if (
$this->type === 'user'
&& !empty($uniqueField)
&& !in_array($uniqueField, $requiredFields, true)
) {
$requiredFields[] = $uniqueField;
}
/** @var HTML_QuickForm_element $element */
foreach ($form->getElements() as $element) {
// Strip the “extra_” prefix to get the field name
$name = str_replace('extra_', '', $element->getName());
// 1) Mark as required if configured
if (in_array($name, $requiredFields, true)) {
$form->setRequired($element);
}
// 2) If this is the special extra field on a user form, add uniqueness validation
if ($this->type === 'user' && !empty($uniqueField) && $name === $uniqueField) {
$this->applyExtraFieldUniquenessRule(
$form,
$name,
$uniqueField,
$currentUserId
);
}
}
@@ -3277,6 +3304,50 @@ JAVASCRIPT;
return null;
}
/**
* Add the “unique per URL” validation rule for the extrafield.
*
* @param &$form
* @param string $fieldVar The extrafield variable name (without “extra_”)
* @param string $uniqueField Configured unique field var
* @param int|null $currentUserId Null for creation, or the existing user ID when editing
*/
protected function applyExtraFieldUniquenessRule(&$form, string $fieldVar, string $uniqueField, ?int $currentUserId): void
{
// Only apply if this is the configured unique field
if ($fieldVar !== $uniqueField) {
return;
}
$elementName = 'extra_'.$fieldVar;
$message = sprintf(
get_lang('A user with the same %s already exists in this portal'),
$fieldVar
);
if ($currentUserId === null) {
// Creation: forbid any existing match
$form->addRule(
$elementName,
$message,
'callback',
['UserManager', 'isExtraFieldValueUniquePerUrl']
);
} else {
// Editing: allow if the only match is this same user
$form->addRule(
$elementName,
$message,
'callback',
function (string $value) use ($currentUserId) {
$existingId = UserManager::isExtraFieldValueUniquePerUrl($value, true);
return $existingId === null || $existingId == $currentUserId;
}
);
}
}
/**
* @param \FormValidator $form
* @param int $defaultValueId
+21
View File
@@ -1221,4 +1221,25 @@ class ExtraFieldValue extends Model
return true;
}
/**
* @return array<int, array<string, string>>
*/
public static function formatValues(array $extraInfo): array
{
$formatted = [];
foreach ($extraInfo as $extra) {
/** @var ExtraFieldValues $extraValue */
$extraValue = $extra['value'];
$formatted[] = [
'variable' => $extraValue->getField()->getVariable(),
'display_text' => $extraValue->getField()->getDisplayText(),
'value' => $extraValue->getValue(),
];
}
return $formatted;
}
}
+8 -4
View File
@@ -212,7 +212,7 @@ function move($source, $target, $forceMove = true, $moveContent = false)
if (is_file($source)) {
if ($forceMove) {
if (!$isWindowsOS && $canExec) {
exec('mv '.$source.' '.$target.'/'.$file_name);
exec('mv '.escapeshellarg($source).' '.escapeshellarg($target.'/'.$file_name));
} else {
// Try copying
copy($source, $target.'/'.$file_name);
@@ -235,15 +235,19 @@ function move($source, $target, $forceMove = true, $moveContent = false)
$base = basename($source);
$out = [];
$retVal = -1;
exec('mv '.$source.'/* '.$target.'/'.$base, $out, $retVal);
exec(
'find '.escapeshellarg($source).' -mindepth 1 -maxdepth 1 -exec mv -t '.escapeshellarg($target.'/'.$base).' {} +',
$out,
$retVal
);
if ($retVal !== 0) {
return false; // mv should return 0 on success
}
exec('rm -rf '.$source);
exec('rm -rf '.escapeshellarg($source));
} else {
$out = [];
$retVal = -1;
exec("mv $source $target", $out, $retVal);
exec('mv '.escapeshellarg($source).' '.escapeshellarg($target), $out, $retVal);
if ($retVal !== 0) {
error_log("Chamilo error fileManage.lib.php: mv $source $target\n");
+1 -1
View File
@@ -26,7 +26,7 @@ use enshrined\svgSanitize\Sanitizer;
*/
function php2phps($file_name)
{
return preg_replace('/\.(phar.?|php.?|phtml.?)(\.){0,1}.*$/i', '.phps', $file_name);
return preg_replace('/\.(phar.?|php.?|pht.?|phtml.?)(\.){0,1}.*$/i', '.phps', $file_name);
}
/**
+9 -1
View File
@@ -1427,9 +1427,16 @@ class GroupManager
return false;
}
$session_id = api_get_session_id();
$studentStatus = 5;
if (isset($session_id) && $session_id != 0) {
$studentStatus = 0;
}
$complete_user_list = CourseManager::get_user_list_from_course_code(
$_course['code'],
$session_id
$session_id,
null,
null,
$studentStatus
);
$groupIid = $groupInfo['iid'];
$category = self::get_category_from_group($groupIid);
@@ -1448,6 +1455,7 @@ class GroupManager
}
$usersToAdd = [];
shuffle($complete_user_list);
foreach ($complete_user_list as $userInfo) {
$isSubscribed = self::is_subscribed($userInfo['user_id'], $groupInfo);
if ($isSubscribed) {
+51
View File
@@ -33,6 +33,8 @@ class Image
exit;
}
}
$this->image_wrapper->reencode();
}
public function resize($max_size_for_picture)
@@ -157,6 +159,7 @@ abstract class ImageWrapper
}
$this->path = $path;
$this->set_image_wrapper(); //Creates image obj
$this->reencode();
}
abstract public function set_image_wrapper();
@@ -169,6 +172,8 @@ abstract class ImageWrapper
abstract public function crop($x, $y, $width, $height, $src_width, $src_height);
abstract public function reencode(): void;
abstract public function send_image($file = '', $compress = -1, $convert_file_to = null);
/**
@@ -347,6 +352,27 @@ class ImagickWrapper extends ImageWrapper
return $result;
}
}
/**
* @throws Exception
*/
public function reencode(): void
{
if ($this->image_validated) {
try {
$this->image->setImageFormat($this->type);
$this->image->writeImage($this->path);
} catch (ImagickException $e) {
throw new Exception();
}
$this->image->clear();
return;
}
throw new Exception();
}
}
/**
@@ -482,6 +508,31 @@ class GDWrapper extends ImageWrapper
@imagedestroy($src_img);
}
/**
* @throws Exception
*/
public function reencode(): void
{
if (!$this->image_validated) {
return;
}
switch ($this->type) {
case 'jpeg':
case 'jpg':
imagejpeg($this->bg, $this->path);
break;
case 'png':
imagepng($this->bg, $this->path);
break;
case 'gif':
imagegif($this->bg, $this->path);
break;
}
}
/**
* @author José Loguercio <jose.loguercio@beeznest.com>
*
+31 -14
View File
@@ -714,19 +714,36 @@ function api_format_date($time, $format = null, $language = null)
//$date_formatter->setPattern($date_format);
$formatted_date = api_to_system_encoding($date_formatter->format($time), 'UTF-8');
} else {
// We replace %a %A %b %B masks of date format with translated strings
// Format date without strftime() (deprecated PHP 8.1, removed PHP 9.0).
// Process each %X token individually so date() never sees translated
// day/month names (which contain letters it would misinterpret as codes).
$translated = &_api_get_day_month_names($language);
$date_format = str_replace(
['%A', '%a', '%B', '%b'],
[
$translated['days_long'][(int) strftime('%w', $time)],
$translated['days_short'][(int) strftime('%w', $time)],
$translated['months_long'][(int) strftime('%m', $time) - 1],
$translated['months_short'][(int) strftime('%m', $time) - 1],
],
$date_format
);
$formatted_date = api_to_system_encoding(strftime($date_format, $time), 'UTF-8');
$dayOfWeek = (int) date('w', $time);
$monthIndex = (int) date('n', $time) - 1;
$strfToDate = [
'd' => 'd', 'm' => 'm', 'Y' => 'Y', 'y' => 'y',
'H' => 'H', 'I' => 'h', 'M' => 'i', 'S' => 's',
'p' => 'A', 'P' => 'a', 'e' => 'j', 'w' => 'w',
'u' => 'N',
];
$formatted_date = '';
for ($fi = 0, $flen = strlen($date_format); $fi < $flen; $fi++) {
if ($date_format[$fi] !== '%' || $fi + 1 >= $flen) {
$formatted_date .= $date_format[$fi];
continue;
}
$code = $date_format[++$fi];
switch ($code) {
case 'A': $formatted_date .= $translated['days_long'][$dayOfWeek]; break;
case 'a': $formatted_date .= $translated['days_short'][$dayOfWeek]; break;
case 'B': $formatted_date .= $translated['months_long'][$monthIndex]; break;
case 'b': $formatted_date .= $translated['months_short'][$monthIndex]; break;
case '%': $formatted_date .= '%'; break;
default:
$formatted_date .= isset($strfToDate[$code]) ? date($strfToDate[$code], $time) : '%'.$code;
}
}
$formatted_date = api_to_system_encoding($formatted_date, 'UTF-8');
}
date_default_timezone_set($system_timezone);
@@ -1052,7 +1069,7 @@ function api_sort_by_first_name($language = null)
* 'quarter_end' => '2022-12-31',
* 'quarter_title' => 'Q4 2022']
*/
function getQuarterDates(string $date = null): array
function getQuarterDates(?string $date = null): array
{
if (empty($date)) {
$date = api_get_utc_datetime();
@@ -1207,7 +1224,7 @@ function api_htmlentities($string, $quote_style = ENT_COMPAT, $encoding = 'UTF-8
break;
}
return htmlentities($string, ENT_SUBSTITUTE, 'UTF-8');
return mb_encode_numericentity($string, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
}
/**
+1 -1
View File
@@ -1840,7 +1840,7 @@ class Link extends Model
);
}
$client = new Client(['defaults' => $defaults]);
$client = new Client($defaults);
try {
$responseIpv6 = $client->get($url);
+74 -11
View File
@@ -37,9 +37,9 @@ class Login
if ($reset) {
if ($by_username) {
$secret_word = self::get_secret_word($user['email']);
$token = self::generate_reset_token($user['uid']);
if ($reset) {
$reset_link = $portal_url."main/auth/lostPassword.php?reset=".$secret_word."&id=".$user['uid'];
$reset_link = $portal_url."main/auth/lostPassword.php?reset=".$token."&id=".$user['uid'];
$reset_link = Display::url($reset_link, $reset_link);
} else {
$reset_link = get_lang('Pass')." : $user[password]";
@@ -53,9 +53,9 @@ class Login
}
} else {
foreach ($user as $this_user) {
$secret_word = self::get_secret_word($this_user['email']);
$token = self::generate_reset_token($this_user['uid']);
if ($reset) {
$reset_link = $portal_url."main/auth/lostPassword.php?reset=".$secret_word."&id=".$this_user['uid'];
$reset_link = $portal_url."main/auth/lostPassword.php?reset=".$token."&id=".$this_user['uid'];
$reset_link = Display::url($reset_link, $reset_link);
} else {
$reset_link = get_lang('Pass')." : $this_user[password]";
@@ -248,9 +248,36 @@ class Login
*
* @author Olivier Cauberghe <olivier.cauberghe@UGent.be>, Ghent University
*/
/**
* Generate a cryptographically random reset token for the given user,
* store it (with a timestamp) in the database, and return it.
*
* @param int $userId
*
* @return string The hex token
*/
public static function generate_reset_token($userId)
{
$token = bin2hex(random_bytes(32));
$em = Database::getManager();
/** @var User $user */
$user = $em->find('ChamiloUserBundle:User', (int) $userId);
if ($user) {
$user->setConfirmationToken($token);
$user->setPasswordRequestedAt(new \DateTime());
$em->persist($user);
$em->flush();
}
return $token;
}
/**
* @deprecated Use generate_reset_token() instead.
*/
public static function get_secret_word($add)
{
return $secret_word = sha1($add);
return sha1($add);
}
/**
@@ -285,15 +312,51 @@ class Login
return get_lang('CouldNotResetPassword');
}
if (self::get_secret_word($user['email']) == $secret) {
// OK, secret word is good. Now change password and mail it.
$user['password'] = api_generate_password();
UserManager::updatePassword($id, $user['password']);
// Validate token against the stored confirmation_token.
$em = Database::getManager();
/** @var User $dbUser */
$dbUser = $em->find('ChamiloUserBundle:User', $id);
return self::send_password_to_user($user, $by_username);
if (!$dbUser) {
return get_lang('CouldNotResetPassword');
}
return get_lang('NotAllowed');
$storedToken = $dbUser->getConfirmationToken();
$requestedAt = $dbUser->getPasswordRequestedAt();
// Token must exist (a reset must have been requested first).
if (empty($storedToken) || empty($requestedAt)) {
return get_lang('NotAllowed');
}
// Token expires after 1 hour.
$expiresAt = clone $requestedAt;
$expiresAt->modify('+1 hour');
if (new \DateTime() > $expiresAt) {
// Clear expired token.
$dbUser->setConfirmationToken(null);
$dbUser->setPasswordRequestedAt(null);
$em->persist($dbUser);
$em->flush();
return get_lang('NotAllowed');
}
// Timing-safe comparison.
if (!hash_equals($storedToken, $secret)) {
return get_lang('NotAllowed');
}
// Token is valid — change the password and clear the token.
$user['password'] = api_generate_password();
UserManager::updatePassword($id, $user['password']);
$dbUser->setConfirmationToken(null);
$dbUser->setPasswordRequestedAt(null);
$em->persist($dbUser);
$em->flush();
return self::send_password_to_user($user, $by_username);
}
/**
+4
View File
@@ -1097,6 +1097,10 @@ class MessageManager
$fileCopied = true;
}
}
if ('image/svg+xml' === $type) {
sanitizeSvgFile($new_path);
}
}
if ($fileCopied) {
+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);
}
}
+568 -54
View File
@@ -19,58 +19,20 @@ class MySpace
public static function getAdminActions()
{
$actions = [
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=coaches',
'content' => get_lang('DisplayCoaches'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=user',
'content' => get_lang('DisplayUserOverview'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=session',
'content' => get_lang('DisplaySessionOverview'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=course',
'content' => get_lang('DisplayCourseOverview'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'tracking/question_course_report.php?view=admin',
'content' => get_lang('LPQuestionListResults'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'tracking/course_session_report.php?view=admin',
'content' => get_lang('LPExerciseResultsBySession'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=accessoverview',
'content' => get_lang('DisplayAccessOverview').' ('.get_lang('Beta').')',
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/exercise_category_report.php',
'content' => get_lang('ExerciseCategoryAllSessionsReport'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/survey_report.php',
'content' => get_lang('SurveysReport'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/tc_report.php',
'content' => get_lang('TCReport'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/ti_report.php',
'content' => get_lang('TIReport'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/question_stats_global.php',
'content' => get_lang('QuestionStats'),
],
[
'url' => api_get_path(WEB_CODE_PATH).'mySpace/question_stats_global_detail.php',
'content' => get_lang('ExerciseAttemptStatsReport'),
],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=coaches', 'content' => get_lang('DisplayCoaches')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=user', 'content' => get_lang('DisplayUserOverview')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=session', 'content' => get_lang('DisplaySessionOverview')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=course', 'content' => get_lang('DisplayCourseOverview')],
['url' => api_get_path(WEB_CODE_PATH).'tracking/question_course_report.php?view=admin', 'content' => get_lang('LPQuestionListResults')],
['url' => api_get_path(WEB_CODE_PATH).'tracking/course_session_report.php?view=admin', 'content' => get_lang('LPExerciseResultsBySession')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/admin_view.php?display=accessoverview', 'content' => get_lang('DisplayAccessOverview').' ('.get_lang('Beta').')'],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/exercise_category_report.php', 'content' => get_lang('ExerciseCategoryAllSessionsReport')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/survey_report.php', 'content' => get_lang('SurveysReport')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/tc_report.php', 'content' => get_lang('TCReport')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/ti_report.php', 'content' => get_lang('TIReport')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/question_stats_global.php', 'content' => get_lang('QuestionStats')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/question_stats_global_detail.php', 'content' => get_lang('ExerciseAttemptStatsReport')],
['url' => api_get_path(WEB_CODE_PATH).'mySpace/duplicated_users.php', 'content' => get_lang('DuplicatedUsers')],
];
$field = new ExtraField('user');
@@ -98,7 +60,22 @@ class MySpace
];
}
return Display::actions($actions, null);
$html = '<div class="clearfix" style="margin:8px 0 14px;">';
foreach ($actions as $a) {
$label = $a['content'];
if (stripos($label, '(Beta)') !== false) {
$label = str_ireplace('(Beta)', '<small class="text-muted">(Beta)</small>', $label);
}
$html .= '<a href="'.$a['url'].'" class="btn btn-default btn-sm" '.
'style="margin:4px 6px; border:1px solid #e1e5eb; border-radius:9999px; background:#fff;">'
.$label.
'</a>';
}
$html .= '</div>';
return $html;
}
/**
@@ -1515,7 +1492,7 @@ class MySpace
/**
* Displays a list as a table of teachers who are set authors of lp's item by a extra_field authors.
*/
public static function displayResumeLpByItem(string $startDate = null, string $endDate = null, bool $csv = false)
public static function displayResumeLpByItem(?string $startDate = null, ?string $endDate = null, bool $csv = false)
{
$tableHtml = '';
$table = '';
@@ -4397,6 +4374,464 @@ class MySpace
return get_lang('NoEntity');
}
/** Return all user extra fields as variable => human label. */
public static function duGetUserExtraFields(): array
{
$ef = new ExtraField('user');
$list = $ef->get_all();
$out = [];
if (!empty($list)) {
foreach ($list as $row) {
$out[$row['variable']] = $row['display_text'].' ('.$row['variable'].')';
}
ksort($out);
}
return $out;
}
/** Return user extra field info by variable name. */
public static function duGetUserExtraFieldByVariable(string $var)
{
$ef = new ExtraField('user');
return $ef->get_handler_field_info_by_field_variable($var);
}
/** Table exists? (current DB) */
public static function duTableExists(string $table): bool
{
$table = Database::escape_string($table);
$res = Database::query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '$table' LIMIT 1");
return $res && Database::num_rows($res) > 0;
}
/** Column exists in table? */
public static function duColumnExists(string $table, string $column): bool
{
$table = Database::escape_string($table);
$column = Database::escape_string($column);
$res = Database::query("
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = '$table'
AND column_name = '$column'
LIMIT 1
");
return $res && Database::num_rows($res) > 0;
}
/**
* Safe UPDATE (with pre-dedup when composite UNIQUE/PK exists).
*
* @param array $uniqueOn Columns (excluding user_id) that are part of a UNIQUE/PK and may collide when moving.
*/
public static function duSafeUpdateUserRef(string $table, string $column, int $fromUserId, int $toUserId, array $uniqueOn = []): int
{
if (!self::duTableExists($table) || !self::duColumnExists($table, $column)) {
return 0;
}
// Pre-deduplicate if composite key exists
if (!empty($uniqueOn)) {
$onParts = [];
foreach ($uniqueOn as $c) {
if (!self::duColumnExists($table, $c)) {
// if any listed unique column does not exist, skip dedup and proceed to UPDATE
$onParts = [];
break;
}
$onParts[] = "a.`$c` = b.`$c`";
}
if (!empty($onParts)) {
$onSql = implode(' AND ', $onParts);
$sqlDel = "
DELETE a
FROM `$table` a
JOIN `$table` b
ON $onSql
AND b.`$column` = $toUserId
WHERE a.`$column` = $fromUserId
";
$resDel = Database::query($sqlDel);
// If you need to log deletes: $delRows = Database::affected_rows($resDel);
}
}
// Move references (UPDATE IGNORE avoids duplicates)
$sqlUpd = "UPDATE IGNORE `$table` SET `$column` = $toUserId WHERE `$column` = $fromUserId";
$resUpd = Database::query($sqlUpd);
return Database::affected_rows($resUpd);
}
/**
* Safe UPDATE for tables with two user columns (e.g. user_rel_user).
* Allows independent dedup per column.
*
* @return array [updatedColA, updatedColB]
*/
public static function duSafeUpdateUserRefDual(
string $table,
string $colA,
string $colB,
int $fromUserId,
int $toUserId,
array $uniqueOnA = [],
array $uniqueOnB = []
): array {
$totA = 0;
$totB = 0;
if (!self::duTableExists($table)) {
return [$totA, $totB];
}
if (self::duColumnExists($table, $colA)) {
// Dedup for colA → toUserId
if (!empty($uniqueOnA)) {
$onParts = [];
foreach ($uniqueOnA as $c) {
if (!self::duColumnExists($table, $c)) {
$onParts = [];
break;
}
$onParts[] = "a.`$c` = b.`$c`";
}
if (!empty($onParts)) {
$onSql = implode(' AND ', $onParts);
$sqlDelA = "
DELETE a
FROM `$table` a
JOIN `$table` b
ON $onSql
AND b.`$colA` = $toUserId
WHERE a.`$colA` = $fromUserId
";
Database::query($sqlDelA);
}
}
$res = Database::query("UPDATE IGNORE `$table` SET `$colA` = $toUserId WHERE `$colA` = $fromUserId");
$totA = Database::affected_rows($res);
}
if (self::duColumnExists($table, $colB)) {
if (!empty($uniqueOnB)) {
$onParts = [];
foreach ($uniqueOnB as $c) {
if (!self::duColumnExists($table, $c)) {
$onParts = [];
break;
}
$onParts[] = "a.`$c` = b.`$c`";
}
if (!empty($onParts)) {
$onSql = implode(' AND ', $onParts);
$sqlDelB = "
DELETE a
FROM `$table` a
JOIN `$table` b
ON $onSql
AND b.`$colB` = $toUserId
WHERE a.`$colB` = $fromUserId
";
Database::query($sqlDelB);
}
}
$res1 = Database::query("UPDATE IGNORE `$table` SET `$colB` = $toUserId WHERE `$colB` = $fromUserId");
$totB = Database::affected_rows($res1);
}
return [$totA, $totB];
}
/** REPLACE(search, replace) in a text column (with LIKE filter). */
public static function duReplaceInColumn(string $table, string $column, string $search, string $replace): int
{
if (!self::duTableExists($table) || !self::duColumnExists($table, $column)) {
return 0;
}
$search = Database::escape_string($search);
$replace = Database::escape_string($replace);
$sql = "UPDATE `$table`
SET `$column` = REPLACE(`$column`, '$search', '$replace')
WHERE `$column` LIKE '%$search%'";
$res = Database::query($sql);
return Database::affected_rows($res);
}
/** Duplicated values for a given extra-field on the current portal. */
public static function duGetDuplicateValues(int $fieldId, int $urlId): array
{
$tblVals = Database::get_main_table(TABLE_EXTRA_FIELD_VALUES);
$tblUser = Database::get_main_table(TABLE_MAIN_USER);
$tblRel = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
$sql = "SELECT v.value AS the_value, COUNT(DISTINCT u.id) AS qty
FROM $tblVals v
JOIN $tblUser u ON u.id = v.item_id
JOIN $tblRel r ON r.user_id = u.id AND r.access_url_id = $urlId
WHERE v.field_id = $fieldId AND v.value <> ''
GROUP BY v.value
HAVING COUNT(DISTINCT u.id) > 1
ORDER BY qty DESC, the_value ASC";
$res = Database::query($sql);
return Database::store_result($res, 'ASSOC');
}
/** Users having exactly a value for the given extra-field (filtered by portal). */
public static function duGetUsersByFieldValue(int $fieldId, int $urlId, string $value, string $userStatusList = ''): array
{
$tblVals = Database::get_main_table(TABLE_EXTRA_FIELD_VALUES);
$tblUser = Database::get_main_table(TABLE_MAIN_USER);
$tblRel = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
$valueSql = "'".Database::escape_string($value)."'";
$filterUserStatus = "";
if ($userStatusList != '') {
$filterUserStatus = "AND u.status in (".$userStatusList.")";
}
$sql = "SELECT DISTINCT u.id AS user_id, u.username, u.firstname, u.lastname, u.email, u.registration_date
FROM $tblUser u
JOIN $tblRel r ON r.user_id = u.id AND r.access_url_id = $urlId
JOIN $tblVals v ON v.item_id = u.id AND v.field_id = $fieldId
WHERE v.value = $valueSql
$filterUserStatus
ORDER BY u.id ASC";
$res = Database::query($sql);
return Database::store_result($res, 'ASSOC');
}
/** Deactivate (active=0) or delete user with API if available. */
public static function duDisableOrDeleteUser(int $userId, string $mode): bool
{
$mode = $mode === 'delete' ? 'delete' : 'deactivate';
if ($mode === 'delete' && class_exists('UserManager') && method_exists('UserManager', 'delete_user')) {
return UserManager::delete_user($userId);
}
// Deactivate
$tblUser = Database::get_main_table(TABLE_MAIN_USER);
$sql = "UPDATE $tblUser SET active = 0 WHERE user_id = $userId";
return (bool) Database::query($sql);
}
/**
* Move ALL references from $fromUserId → $toUserId, including special cases.
* Returns a log like "table.col (n)" for UI display.
*/
public static function duUpdateAllUserRefsList(int $fromUserId, int $toUserId): array
{
$log = [];
// Tables with composite UNIQUE/PK (pre-dedup)
$composite = [
// table, column, uniqueOn (exclude the user column)
['access_url_rel_user', 'user_id', ['access_url_id']],
['fos_user_user_group', 'user_id', ['group_id']],
['user_rel_tag', 'user_id', ['tag_id']],
['user_rel_course_vote', 'user_id', ['c_id']],
['session_rel_user', 'user_id', ['session_id']],
['session_rel_course_rel_user', 'user_id', ['session_id', 'c_id']],
];
foreach ($composite as [$t,$col,$uniqueOn]) {
$n = self::duSafeUpdateUserRef($t, $col, $fromUserId, $toUserId, $uniqueOn);
if ($n) {
$log[] = "$t.$col ($n)";
}
}
// Simple tables (no composite unique with user_id)
$simple = [
['admin', 'user_id'],
['agenda_event_invitation', 'creator_id'],
['agenda_event_invitee', 'user_id'],
['class_user', 'user_id'],
['course_rel_user', 'user_id'],
['course_rel_user_catalogue', 'user_id'],
['course_request', 'user_id'],
['c_attendance_result', 'user_id'],
['c_attendance_sheet', 'user_id'],
['c_attendance_sheet_log', 'lastedit_user_id'],
['c_blog_comment', 'author_id'],
['c_blog_post', 'author_id'],
['c_blog_rating', 'user_id'],
['c_blog_rel_user', 'user_id'],
['c_blog_task_rel_user', 'user_id'],
['c_chat_connected', 'user_id'],
['c_dropbox_category', 'user_id'],
['c_dropbox_feedback', 'author_user_id'],
['c_dropbox_person', 'user_id'],
['c_dropbox_post', 'dest_user_id'],
['c_forum_mailcue', 'user_id'],
['c_forum_notification', 'user_id'],
['c_forum_post', 'poster_id'],
['c_forum_thread', 'thread_poster_id'],
['c_forum_thread_qualify', 'user_id'],
['c_forum_thread_qualify', 'qualify_user_id'],
['c_forum_thread_qualify_log', 'user_id'],
['c_forum_thread_qualify_log', 'qualify_user_id'],
['c_group_rel_tutor', 'user_id'],
['c_group_rel_user', 'user_id'],
['c_item_property', 'to_user_id'],
['c_item_property', 'insert_user_id'],
['c_item_property', 'lastedit_user_id'],
['c_lp_category_user', 'user_id'],
['c_lp_view', 'user_id'],
['c_notebook', 'user_id'],
['c_online_connected', 'user_id'],
['c_permission_user', 'user_id'],
['c_role_user', 'user_id'],
['c_student_publication', 'user_id'],
['c_student_publication_comment', 'user_id'],
['c_student_publication_rel_user', 'user_id'],
['c_survey', 'author'],
['c_survey_answer', 'user'],
['c_survey_invitation', 'user'],
['c_userinfo_content', 'user_id'],
['c_wiki', 'user_id'],
['c_wiki_mailcue', 'user_id'],
['chat', 'from_user'],
['chat', 'to_user'],
['chat_video', 'from_user'],
['chat_video', 'to_user'],
['extra_field_saved_search', 'user_id'],
['gradebook_category', 'user_id'],
['gradebook_certificate', 'user_id'],
['gradebook_evaluation', 'user_id'],
['gradebook_link', 'user_id'],
['gradebook_linkeval_log', 'user_id_log'],
['gradebook_result', 'user_id'],
['gradebook_result_log', 'user_id'],
['gradebook_score_log', 'user_id'],
['justification_document_rel_users', 'user_id'],
['message', 'user_receiver_id'],
['message', 'user_sender_id'],
['notification', 'dest_user_id'],
['personal_agenda', 'user'],
['plugin_bbb_meeting', 'user_id'],
['plugin_bbb_room', 'participant_id'],
['plugin_buycourses_item_rel_beneficiary', 'user_id'],
['plugin_buycourses_paypal_payouts', 'user_id'],
['plugin_buycourses_sale', 'user_id'],
['plugin_buycourses_subscription_rel_sale', 'user_id'],
['plugin_card_game', 'user_id'],
['plugin_h5p', 'user_id'],
['plugin_h5p_import_results', 'user_id'],
['plugin_lti_provider_result', 'user_id'],
['portfolio', 'user_id'],
['portfolio_category', 'user_id'],
['portfolio_comment', 'author_id'],
['sequence_value', 'user_id'],
['session', 'id_coach'],
['session', 'session_admin_id'],
['shared_survey', 'author'],
['skill_rel_user', 'argumentation_author_id'],
['skill_rel_user', 'assigned_by'],
['skill_rel_user', 'user_id'],
['skill_rel_user_comment', 'feedback_giver_id'],
['templates', 'user_id'],
['ticket_assigned_log', 'user_id'],
['ticket_assigned_log', 'sys_insert_user_id'],
['ticket_category', 'sys_insert_user_id'],
['ticket_category', 'sys_lastedit_user_id'],
['ticket_category_rel_user', 'user_id'],
['ticket_message', 'sys_insert_user_id'],
['ticket_message', 'sys_lastedit_user_id'],
['ticket_message_attachments', 'sys_lastedit_user_id'],
['ticket_message_attachments', 'sys_insert_user_id'],
['ticket_priority', 'sys_lastedit_user_id'],
['ticket_priority', 'sys_insert_user_id'],
['ticket_project', 'sys_lastedit_user_id'],
['ticket_project', 'sys_insert_user_id'],
['ticket_ticket', 'sys_lastedit_user_id'],
['ticket_ticket', 'sys_insert_user_id'],
['track_e_access', 'access_user_id'],
['track_e_access_complete', 'user_id'],
['track_e_attempt', 'user_id'],
['track_e_attempt_recording', 'author'],
['track_e_course_access', 'user_id'],
['track_e_downloads', 'down_user_id'],
['track_e_exercises', 'exe_user_id'],
['track_e_hotpotatoes', 'exe_user_id'],
['track_e_hotspot', 'hotspot_user_id'],
['track_e_item_property', 'lastedit_user_id'],
['track_e_lastaccess', 'access_user_id'],
['track_e_links', 'links_user_id'],
['track_e_login', 'login_user_id'],
['track_e_online', 'login_user_id'],
['track_e_uploads', 'upload_user_id'],
['track_stored_values', 'user_id'],
['track_stored_values_stack', 'user_id'],
['user', 'creator_id'],
['usergroup_rel_user', 'user_id'],
['user_api_key', 'user_id'],
['user_course_category', 'user_id'],
['user_rel_event_type', 'user_id'],
['xapi_internal_log', 'user_id'],
];
foreach ($simple as [$t,$col]) {
$n = self::duSafeUpdateUserRef($t, $col, $fromUserId, $toUserId);
if ($n) {
$log[] = "$t.$col ($n)";
}
}
// user_rel_user (two user columns)
if (self::duTableExists('user_rel_user')) {
// Dedup by user_id
$sqlDel1 = "
DELETE a FROM user_rel_user a
JOIN user_rel_user b
ON a.friend_user_id = b.friend_user_id
AND b.user_id = $toUserId
WHERE a.user_id = $fromUserId
";
Database::query($sqlDel1);
$resUp1 = Database::query("UPDATE IGNORE user_rel_user SET user_id = $toUserId WHERE user_id = $fromUserId");
$n1 = Database::affected_rows($resUp1);
// Dedup by friend_user_id
$sqlDel2 = "
DELETE a FROM user_rel_user a
JOIN user_rel_user b
ON a.user_id = b.user_id
AND b.friend_user_id = $toUserId
WHERE a.friend_user_id = $fromUserId
";
Database::query($sqlDel2);
$resUp2 = Database::query("UPDATE IGNORE user_rel_user SET friend_user_id = $toUserId WHERE friend_user_id = $fromUserId");
$n2 = Database::affected_rows($resUp2);
if ($n1) {
$log[] = "user_rel_user.user_id ($n1)";
}
if ($n2) {
$log[] = "user_rel_user.friend_user_id ($n2)";
}
}
// Special cases
$nDef = self::duUpdateTrackEDefault($fromUserId, $toUserId);
if ($nDef) {
$log[] = "track_e_default (default_user_id/default_value) ($nDef)";
}
$nEf = self::duUpdateExtraFieldValuesUserType($fromUserId, $toUserId); // idem
if ($nEf) {
$log[] = "extra_field_values.item_id (user-type) ($nEf)";
}
return $log;
}
/**
* Gets a list of users who were enrolled in the lessons.
* It is necessary that in the extra field, a company is defined.
@@ -4569,4 +5004,83 @@ class MySpace
return $sql;
}
/** track_e_default: default_value text with user_id in various formats and numeric default_user_id. */
private static function duUpdateTrackEDefault(int $fromUserId, int $toUserId): int
{
if (!self::duTableExists('track_e_default')) {
return 0;
}
$moved = 0;
// default_user_id (if present)
if (self::duColumnExists('track_e_default', 'default_user_id')) {
$result1 = Database::query("UPDATE IGNORE track_e_default SET default_user_id = $toUserId WHERE default_user_id = $fromUserId");
$moved += Database::affected_rows($result1);
}
// Events where default_value == user id as plain text
$eventsDirect = [
'user_created', 'exe_result_deleted', 'session_add_user_course', 'session_add_user',
'user_updated', 'user_deleted', 'exe_attempt_deleted', 'user_disable', 'user_password_updated',
'user_enable', 'session_delete_user', 'session_delete_user_course', 'exe_incomplete_results_deleted',
];
if (self::duColumnExists('track_e_default', 'event_type') && self::duColumnExists('track_e_default', 'default_value')) {
$in = "'".implode("','", array_map('Database::escape_string', $eventsDirect))."'";
$sql = "
UPDATE track_e_default
SET default_value = '$toUserId'
WHERE default_value = '$fromUserId'
AND event_type IN ($in)
";
$result2 = Database::query($sql);
$moved += Database::affected_rows($result2);
}
// user_subscribed / user_unsubscribed with default_value_type='user_object'
if (self::duColumnExists('track_e_default', 'default_value_type') && self::duColumnExists('track_e_default', 'event_type')) {
foreach (['user_subscribed', 'user_unsubscribed'] as $evt) {
// Replace twice the ID and also the profile.php?u=FROM
$moved += self::duReplaceInColumn('track_e_default', 'default_value', "profile.php?u=".$fromUserId, "profile.php?u=".$toUserId);
$moved += self::duReplaceInColumn('track_e_default', 'default_value', (string) $fromUserId, (string) $toUserId);
// NOTE: REPLACE already uses LIKE; we do not narrow with WHERE to keep cross-install compatibility.
}
}
// soc_gr_u_unsubs / soc_gr_u_subs: "gid: GGG - uid: XXX"
foreach (['soc_gr_u_unsubs', 'soc_gr_u_subs'] as $evt) {
$moved += self::duReplaceInColumn('track_e_default', 'default_value', "uid: ".$fromUserId, "uid: ".$toUserId);
}
return $moved;
}
/** extra_field_values: for extra_field.extra_field_type = 1 (user), move item_id. */
private static function duUpdateExtraFieldValuesUserType(int $fromUserId, int $toUserId): int
{
if (!self::duTableExists('extra_field') || !self::duTableExists('extra_field_values')) {
return 0;
}
// Remove collisions (same field_id already pointing to target user)
$sqlDel = "
DELETE v
FROM extra_field_values v
JOIN extra_field f ON f.id = v.field_id AND f.extra_field_type = 1
JOIN extra_field_values w ON w.field_id = v.field_id AND w.item_id = $toUserId
WHERE v.item_id = $fromUserId
";
Database::query($sqlDel);
// Move remaining
$sqlUpd = "
UPDATE extra_field_values v
JOIN extra_field f ON f.id = v.field_id AND f.extra_field_type = 1
SET v.item_id = $toUserId
WHERE v.item_id = $fromUserId
";
$result = Database::query($sqlUpd);
return Database::affected_rows($result);
}
}
+1 -1
View File
@@ -861,7 +861,7 @@ class nusoap_base
$sec = time();
$usec = 0;
}
return strftime('%Y-%m-%d %H:%M:%S', $sec) . '.' . sprintf('%06d', $usec);
return date('Y-m-d H:i:s', $sec) . '.' . sprintf('%06d', $usec);
}
/**
+5 -2
View File
@@ -52,11 +52,14 @@ class OpenGraph implements Iterator
curl_setopt($curl, CURLOPT_FAILONERROR, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_MAXREDIRS, 3);
curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_TIMEOUT, 15);
curl_setopt($curl, CURLOPT_TIMEOUT, 10);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($curl, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']);
curl_setopt($curl, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT'] ?? 'Chamilo');
$response = curl_exec($curl);
@@ -38,10 +38,8 @@ class HTML_QuickForm_Rule_CompareDate extends HTML_QuickForm_Rule
if (!is_array($values[0]) && !is_array($values[1])) {
return api_strtotime($values[0]) < api_strtotime($values[1]);
} else {
$compareFn = create_function(
'$a, $b', 'return mktime($a[\'H\'],$a[\'i\'],0,$a[\'M\'],$a[\'d\'],$a[\'Y\']) <= mktime($b[\'H\'],$b[\'i\'],0,$b[\'M\'],$b[\'d\'],$b[\'Y\'] );'
);
return $compareFn($values[0], $values[1]);
return mktime($values[0]['H'], $values[0]['i'], 0, $values[0]['M'], $values[0]['d'], $values[0]['Y'])
<= mktime($values[1]['H'], $values[1]['i'], 0, $values[1]['M'], $values[1]['d'], $values[1]['Y']);
}
}
}
@@ -14,7 +14,6 @@ class Html_Quickform_Rule_Date extends HTML_QuickForm_Rule
*/
function validate($date, $options)
{
$compareDate = create_function('$a', 'return checkdate($a[\'M\'],$a[\'d\'],$a[\'Y\']);');
return $compareDate($date);
return checkdate($date['M'], $date['d'], $date['Y']);
}
}
@@ -773,14 +773,6 @@ class HTML_QuickForm_advmultiselect extends HTML_QuickForm_select
$leftAll = "<br /><br /><button $attrStrAdd /> <em class='fa fa-backward'></em></button>";
}
$strHtmlAdd = "<div class='d-flex justify-content-center align-items-center' style='height: 100%; padding-top: 100px;'>
<button $attrStrAdd><em class='fa fa-arrow-right'></em></button>
</div>";
$strHtmlRemove = "<div class='d-flex justify-content-center align-items-center' style='padding-top: 10px;'>
<button $attrStrRemove><em class='fa fa-arrow-left'></em></button>
</div>";
// build the select all button with all its attributes
$strHtmlAll = '';
+2 -2
View File
@@ -348,14 +348,14 @@ class HTML_QuickForm_date extends HTML_QuickForm_group
$this->_options['maxYear'],
$this->_options['minYear'] > $this->_options['maxYear']? -1: 1
);
array_walk($options, create_function('&$v,$k','$v = substr($v,-2);'));
array_walk($options, function(&$v, $k) { $v = substr($v, -2); });
break;
case 'h':
$options = $this->_createOptionList(1, 12);
break;
case 'g':
$options = $this->_createOptionList(1, 12);
array_walk($options, create_function('&$v,$k', '$v = intval($v);'));
array_walk($options, function(&$v, $k) { $v = intval($v); });
break;
case 'H':
$options = $this->_createOptionList(0, 23);
+10 -3
View File
@@ -419,8 +419,11 @@ class HTML_QuickForm_select extends HTML_QuickForm_element
if (!empty($strValues) && in_array($option['attr']['value'], $strValues, true)) {
$option['attr']['selected'] = 'selected';
}
$strHtml .= $tabs . "<option" . $this->_getAttrString($option['attr']) . '>' .
$option['text'] . "</option>";
$strHtml .= $tabs.Display::tag(
'option',
Security::remove_XSS($option['text']),
$option['attr']
);
}
foreach ($this->_optgroups as $optgroup) {
$strHtml .= $tabs . '<optgroup label="' . $optgroup['label'] . '">';
@@ -432,7 +435,11 @@ class HTML_QuickForm_select extends HTML_QuickForm_element
$option['selected'] = 'selected';
}
$strHtml .= $tabs . " <option" . $this->_getAttrString($option) . '>' .$text . "</option>";
$strHtml .= $tabs.Display::tag(
'option',
Security::remove_XSS($text),
$option
);
}
$strHtml .= "</optgroup>";
}
@@ -188,7 +188,7 @@ class Text_CAPTCHA_Driver_Equation extends Text_CAPTCHA_Driver_Base
{
$equation = sprintf($operator, $one, $two);
$function = create_function('', 'return ' . $equation . ';');
$result = eval('return ' . $equation . ';');
if ($this->_numbersToText) {
$numberWords = new Numbers_Words();
@@ -198,7 +198,7 @@ class Text_CAPTCHA_Driver_Equation extends Text_CAPTCHA_Driver_Base
$numberWords->toWords($two, $this->_locale)
);
}
return array($equation, $function());
return array($equation, $result);
}
/**
@@ -332,7 +332,7 @@ class Text_Diff_Engine_native {
$i = 0;
$j = 0;
assert('count($lines) == count($changed)');
assert(count($lines) == count($changed));
$len = count($lines);
$other_len = count($other_changed);
@@ -353,7 +353,7 @@ class Text_Diff_Engine_native {
}
while ($i < $len && ! $changed[$i]) {
assert('$j < $other_len && ! $other_changed[$j]');
assert($j < $other_len && !$other_changed[$j]);
$i++; $j++;
while ($j < $other_len && $other_changed[$j]) {
$j++;
@@ -385,11 +385,11 @@ class Text_Diff_Engine_native {
while ($start > 0 && $changed[$start - 1]) {
$start--;
}
assert('$j > 0');
assert($j > 0);
while ($other_changed[--$j]) {
continue;
}
assert('$j >= 0 && !$other_changed[$j]');
assert($j >= 0 && !$other_changed[$j]);
}
/* Set CORRESPONDING to the end of the changed run, at the
@@ -410,7 +410,7 @@ class Text_Diff_Engine_native {
$i++;
}
assert('$j < $other_len && ! $other_changed[$j]');
assert($j < $other_len && !$other_changed[$j]);
$j++;
if ($j < $other_len && $other_changed[$j]) {
$corresponding = $i;
@@ -426,11 +426,11 @@ class Text_Diff_Engine_native {
while ($corresponding < $i) {
$changed[--$start] = 1;
$changed[--$i] = 0;
assert('$j > 0');
assert($j > 0);
while ($other_changed[--$j]) {
continue;
}
assert('$j >= 0 && !$other_changed[$j]');
assert($j >= 0 && !$other_changed[$j]);
}
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ class Text_Diff_Engine_shell {
if ($from_line_no < $match[1] || $to_line_no < $match[4]) {
// copied lines
assert('$match[1] - $from_line_no == $match[4] - $to_line_no');
assert($match[1] - $from_line_no == $match[4] - $to_line_no);
array_push($edits,
new Text_Diff_Op_copy(
$this->_getLines($from_lines, $from_line_no, $match[1] - 1),
+4 -3
View File
@@ -1128,8 +1128,9 @@ class Plugin
return $tool;
}
/**
* Get whether the course tool should be visible by default or not
* Get whether the course tool should be visible by default or not.
*/
protected function getCourseToolDefaultVisibility(): bool
{
@@ -1137,8 +1138,8 @@ class Plugin
}
/**
* Set whether the tool created in the course must be visible or not
* @param bool $visibility
* Set whether the tool created in the course must be visible or not.
*
* @return void
*/
protected function setCourseToolDefaultVisibility(bool $visibility)
+1 -1
View File
@@ -310,7 +310,7 @@ class Security
*
* @return mixed Filtered string or array
*/
public static function remove_XSS($var, int $user_status = null, bool $filter_terms = false)
public static function remove_XSS($var, ?int $user_status = null, bool $filter_terms = false)
{
if ($filter_terms) {
$var = self::filter_terms($var);
+37 -12
View File
@@ -2052,6 +2052,22 @@ class SessionManager
$course_list[] = $row['c_id'];
}
// Build list of users already subscribed to the session as students.
// This allows us to avoid re-enrolling them into all courses again
// when they are already part of the session (preserves manual
// unsubscriptions at the course level).
$usersAlreadyInSession = [];
if (!empty($userList)) {
$userIdsStr = "'".implode("','", $userList)."'";
$sql = "SELECT user_id FROM $tbl_session_rel_user
WHERE session_id = $sessionId AND relation_type = 0
AND user_id IN ($userIdsStr)";
$resUsersInSession = Database::query($sql);
while ($row = Database::fetch_array($resUsersInSession)) {
$usersAlreadyInSession[] = (int) $row['user_id'];
}
}
if ($session->getSendSubscriptionNotification() &&
is_array($userList)
) {
@@ -2154,8 +2170,8 @@ class SessionManager
$usersToSubscribeInCourse = array_filter(
$userList,
function ($userId) use ($existingUsers) {
return !in_array($userId, $existingUsers);
function ($userId) use ($existingUsers, $usersAlreadyInSession) {
return !in_array($userId, $existingUsers) && !in_array($userId, $usersAlreadyInSession);
}
);
@@ -3277,7 +3293,8 @@ class SessionManager
$from = null,
$to = null,
$urlId = 0,
$onlyThisSessionList = []
$onlyThisSessionList = [],
$includeSessionWithNoCourse = false
) {
$session_table = Database::get_main_table(TABLE_MAIN_SESSION);
$session_category_table = Database::get_main_table(TABLE_MAIN_SESSION_CATEGORY);
@@ -3287,6 +3304,12 @@ class SessionManager
$course_table = Database::get_main_table(TABLE_MAIN_COURSE);
$urlId = empty($urlId) ? api_get_current_access_url_id() : (int) $urlId;
$return_array = [];
$courseFrom = "LEFT JOIN ".$session_course_table." sco ON (sco.session_id = s.id)
INNER JOIN ".$course_table." c ON sco.c_id = c.id";
if ($includeSessionWithNoCourse) {
$courseFrom = "";
}
$sql_query = " SELECT
DISTINCT(s.id),
@@ -3302,8 +3325,7 @@ class SessionManager
INNER JOIN $user_table u ON s.id_coach = u.user_id
INNER JOIN $table_access_url_rel_session ar ON ar.session_id = s.id
LEFT JOIN $session_category_table sc ON s.session_category_id = sc.id
LEFT JOIN $session_course_table sco ON (sco.session_id = s.id)
INNER JOIN $course_table c ON sco.c_id = c.id
$courseFrom
WHERE ar.access_url_id = $urlId ";
$availableFields = [
@@ -5987,9 +6009,9 @@ class SessionManager
* @param int $sessionId
* @param int $courseId
*
* @return array
* @return array<int, int>
*/
public static function getCoachesByCourseSession($sessionId, $courseId)
public static function getCoachesByCourseSession($sessionId, $courseId): array
{
$table = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
$sessionId = (int) $sessionId;
@@ -6005,7 +6027,7 @@ class SessionManager
$coaches = [];
if (Database::num_rows($result) > 0) {
while ($row = Database::fetch_array($result)) {
$coaches[] = $row['user_id'];
$coaches[] = (int) $row['user_id'];
}
}
@@ -10047,9 +10069,9 @@ class SessionManager
}
// 2. SESSION DATA
$row2 = [$courseInfo['title']];
$row2[] = $sessionInfo['access_start_date'];
$row2[] = $sessionInfo['access_end_date'];
$row2 = $config['course_field_value'] ? [$config['course_field_value']] : [$courseInfo['title']];
$row2[] = (new DateTime($sessionInfo['access_start_date']))->format('d/m/Y');
$row2[] = (new DateTime($sessionInfo['access_end_date']))->format('d/m/Y');
$extraValuesObj = new ExtraFieldValue('session');
$sessionExtra = $extraValuesObj->getAllValuesByItem($sessionId);
@@ -10058,6 +10080,9 @@ class SessionManager
foreach ($sessionFields as $entry) {
if (!empty($entry['field'])) {
$value = $sessionExtraMap[$entry['field']] ?? '';
if (!empty($entry['numberOfLetter']) && $entry['numberOfLetter'] > 0) {
$value = mb_substr($value, 0, $entry['numberOfLetter']);
}
} else {
$value = '';
}
@@ -10104,7 +10129,7 @@ class SessionManager
$userInfo = api_get_user_info($userId);
$row = [];
$row[] = $rowIndex === 0 ? get_lang('Learners') : '';
$row[] = get_lang('Learners');
$userExtraObj = new ExtraFieldValue('user');
$userExtra = $userExtraObj->getAllValuesByItem($userId);
+46 -2
View File
@@ -2111,19 +2111,63 @@ class SocialManager extends UserManager
return $html;
}
/**
* Check if a URL is safe to fetch server-side (not targeting internal resources).
*
* Blocks private/reserved IP ranges, non-HTTP schemes, and unresolvable hosts
* to prevent SSRF attacks (CWE-918).
*/
public static function isUrlSafe(string $url): bool
{
$parsed = parse_url($url);
// Allow only http and https schemes
if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
return false;
}
$host = $parsed['host'] ?? '';
if (empty($host)) {
return false;
}
// Resolve hostname to IP
$ip = gethostbyname($host);
if ($ip === $host) {
// DNS resolution failed
return false;
}
// Block private and reserved IP ranges
if (false === filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
return false;
}
return true;
}
/**
* verify if Url Exist - Using Curl.
*/
public static function verifyUrl(string $uri): bool
{
if (!self::isUrlSafe($uri)) {
return false;
}
$client = new Client();
try {
$response = $client->request('GET', $uri, [
'timeout' => 15,
'timeout' => 10,
'verify' => false,
'allow_redirects' => ['max' => 3],
'headers' => [
'User-Agent' => $_SERVER['HTTP_USER_AGENT'],
'User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'Chamilo',
],
]);
+2 -2
View File
@@ -41,7 +41,7 @@ class Statistics
*
* @return int Number of courses counted
*/
public static function countCourses(string $categoryCode = null, string $dateFrom = null, string $dateUntil = null)
public static function countCourses(?string $categoryCode = null, ?string $dateFrom = null, ?string $dateUntil = null)
{
$courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
$accessUrlRelCourseTable = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
@@ -1722,7 +1722,7 @@ class Statistics
* Return de number of certificates generated.
* This function is resource intensive.
*/
public static function countCertificatesByQuarter(string $dateFrom = null, string $dateUntil = null): int
public static function countCertificatesByQuarter(?string $dateFrom = null, ?string $dateUntil = null): int
{
$tableGradebookCertificate = Database::get_main_table(TABLE_MAIN_GRADEBOOK_CERTIFICATE);
+108 -9
View File
@@ -5071,7 +5071,8 @@ class Tracking
FROM $tbl_session_course sc
INNER JOIN $courseTable c
ON sc.c_id = c.id
WHERE session_id= $session_id";
WHERE session_id= $session_id
ORDER BY position ASC";
$result = Database::query($sql);
@@ -6535,10 +6536,7 @@ class Tracking
$user_id,
$course_code,
[],
$session_id_from_get,
false,
false,
$lpShowMaxProgress
$session_id_from_get
);
$total_time_login = self::get_time_spent_on_the_course(
@@ -7902,6 +7900,9 @@ class Tracking
$attemptRecording = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_RECORDING);
$TBL_TRACK_E_COURSE_ACCESS = Database::get_main_table(TABLE_STATISTIC_TRACK_E_COURSE_ACCESS);
$TBL_TRACK_E_LAST_ACCESS = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LASTACCESS);
$TBL_TRACK_E_ACCESS = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ACCESS);
$TBL_TRACK_E_ACCESS_COMPLETE = 'track_e_access_complete';
$TBL_TRACK_E_DOWNLOADS = Database::get_main_table(TABLE_STATISTIC_TRACK_E_DOWNLOADS);
$TBL_LP_VIEW = Database::get_course_table(TABLE_LP_VIEW);
$TBL_NOTEBOOK = Database::get_course_table(TABLE_NOTEBOOK);
$TBL_STUDENT_PUBLICATION = Database::get_course_table(TABLE_STUDENT_PUBLICATION);
@@ -8041,6 +8042,101 @@ class Tracking
}
}
// 4b. track_e_access
$sql = "SELECT access_id FROM $TBL_TRACK_E_ACCESS
WHERE
c_id = $course_id AND
access_session_id = $origin_session_id AND
access_user_id = $user_id ";
$res = Database::query($sql);
$list = [];
while ($row = Database::fetch_array($res, 'ASSOC')) {
$list[] = $row['access_id'];
}
if (!empty($list)) {
foreach ($list as $id) {
if ($update_database) {
$sql = "UPDATE $TBL_TRACK_E_ACCESS
SET access_session_id = $new_session_id
WHERE access_id = $id";
if ($debug) {
echo $sql;
}
Database::query($sql);
if (!isset($result_message[$TBL_TRACK_E_ACCESS])) {
$result_message[$TBL_TRACK_E_ACCESS] = 0;
}
$result_message[$TBL_TRACK_E_ACCESS]++;
}
}
}
// 4c. track_e_access_complete
$sql = "SELECT count(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'track_e_access_complete'";
$res = Database::query($sql);
$row = Database::fetch_row($result);
if ($row[0] > 0) {
$sql = "SELECT id FROM $TBL_TRACK_E_ACCESS_COMPLETE
WHERE
c_id = $course_id AND
session_id = $origin_session_id AND
user_id = $user_id ";
$res = Database::query($sql);
$list = [];
while ($row = Database::fetch_array($res, 'ASSOC')) {
$list[] = $row['id'];
}
if (!empty($list)) {
foreach ($list as $id) {
if ($update_database) {
$sql = "UPDATE $TBL_TRACK_E_ACCESS_COMPLETE
SET session_id = $new_session_id
WHERE id = $id";
if ($debug) {
echo $sql;
}
Database::query($sql);
if (!isset($result_message[$TBL_TRACK_E_ACCESS_COMPLETE])) {
$result_message[$TBL_TRACK_E_ACCESS_COMPLETE] = 0;
}
$result_message[$TBL_TRACK_E_ACCESS_COMPLETE]++;
}
}
}
}
// 4d. track_e_downloads
$sql = "SELECT down_id FROM $TBL_TRACK_E_DOWNLOADS
WHERE
c_id = $course_id AND
down_session_id = $origin_session_id AND
down_user_id = $user_id ";
$res = Database::query($sql);
$list = [];
while ($row = Database::fetch_array($res, 'ASSOC')) {
$list[] = $row['down_id'];
}
if (!empty($list)) {
foreach ($list as $id) {
if ($update_database) {
$sql = "UPDATE $TBL_TRACK_E_DOWNLOADS
SET down_session_id = $new_session_id
WHERE down_id = $id";
if ($debug) {
echo $sql;
}
Database::query($sql);
if (!isset($result_message[$TBL_TRACK_E_DOWNLOADS])) {
$result_message[$TBL_TRACK_E_DOWNLOADS] = 0;
}
$result_message[$TBL_TRACK_E_DOWNLOADS]++;
}
}
}
// 5. lp_item_view
// CHECK ORIGIN
$sql = "SELECT * FROM $TBL_LP_VIEW
@@ -8709,12 +8805,15 @@ class Tracking
$session = api_get_session_info($row['session_id']);
$course = api_get_course_info_by_id($row['c_id']);
$sessionName = $session['name'] ?? '';
$courseTitle = $course['title'] ?? '';
if ($reportType == 'time_report') {
$rows[] = [
$user['lastname'],
$user['firstname'],
$session['name'],
$course['title'],
$sessionName,
$courseTitle,
api_get_local_time($row['login_course_date']),
api_get_local_time($row['logout_course_date']),
gmdate('H:i:s', $row['time']),
@@ -8724,8 +8823,8 @@ class Tracking
$rows[] = [
$user['lastname'],
$user['firstname'],
$session['name'],
$course['title'],
$sessionName,
$courseTitle,
$row['lp_name'],
api_get_local_time(date('Y-m-d H:i:s', $row['start_time'])),
$extraFieldValue['value'] ?? '',
+56 -19
View File
@@ -153,7 +153,13 @@ class UserGroup extends Model
while ($data = Database::fetch_array($result)) {
$userId = $data['user_id'];
$userInfo = api_get_user_info($userId);
$data['name'] = $userInfo['complete_name_with_username'];
if ('true' === api_get_setting('order_user_list_by_official_code')) {
$officialCode = !empty($userInfo['official_code']) ? $userInfo['official_code'].' - ' : '? - ';
$data['name'] = $officialCode.$userInfo['complete_name_with_username'];
} else {
$officialCode = !empty($userInfo['official_code']) ? ' - '.$userInfo['official_code'] : null;
$data['name'] = $userInfo['complete_name_with_username']." $officialCode";
}
if ($showCalendar) {
$calendar = $calendarPlugin->getUserCalendar($userId);
@@ -1870,28 +1876,59 @@ class UserGroup extends Model
// Storing the new photos in 4 versions with various sizes.
/*$image->resize(
// get original size and set width (widen) or height (heighten).
// width or height will be set maintaining aspect ratio.
$image->getSize()->widen( 700 )
);*/
$srcImage = imagecreatefromstring(file_get_contents($source_file));
if ($srcImage === false) {
return false;
}
// Usign the Imagine service
$imagine = new Imagine\Gd\Imagine();
$image = $imagine->open($source_file);
$srcWidth = imagesx($srcImage);
$srcHeight = imagesy($srcImage);
$options = [
'quality' => 90,
];
// Closure: resizes $srcImage to $w x $h and saves to $dest
$saveResized = function ($w, $h, $dest) use ($srcImage, $srcWidth, $srcHeight, $extension) {
$dst = imagecreatetruecolor($w, $h);
if ($extension === 'png' || $extension === 'gif') {
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127);
imagefilledrectangle($dst, 0, 0, $w, $h, $transparent);
}
imagecopyresampled($dst, $srcImage, 0, 0, 0, 0, $w, $h, $srcWidth, $srcHeight);
switch ($extension) {
case 'jpg':
case 'jpeg':
imagejpeg($dst, $dest, 90);
break;
case 'png':
imagepng($dst, $dest, 1);
break;
case 'gif':
imagegif($dst, $dest);
break;
}
imagedestroy($dst);
};
//$image->resize(new Imagine\Image\Box(200, 200))->save($path.'big_'.$filename);
$image->resize($image->getSize()->widen(200))->save($path.'big_'.$filename, $options);
// Helper: compute dimensions to fit within $maxW x $maxH keeping aspect ratio
$fitDimensions = function ($maxW, $maxH) use ($srcWidth, $srcHeight) {
if ($srcWidth <= 0 || $srcHeight <= 0) {
return [$maxW, $maxH];
}
$scale = min($maxW / $srcWidth, $maxH / $srcHeight);
$image = $imagine->open($source_file);
$image->resize(new Imagine\Image\Box(85, 85))->save($path.'medium_'.$filename, $options);
return [(int) round($srcWidth * $scale), (int) round($srcHeight * $scale)];
};
$image = $imagine->open($source_file);
$image->resize(new Imagine\Image\Box(22, 22))->save($path.'small_'.$filename);
[$bigW, $bigH] = $fitDimensions(200, 200);
$saveResized($bigW, $bigH, $path.'big_'.$filename);
[$medW, $medH] = $fitDimensions(85, 85);
$saveResized($medW, $medH, $path.'medium_'.$filename);
[$smlW, $smlH] = $fitDimensions(22, 22);
$saveResized($smlW, $smlH, $path.'small_'.$filename);
imagedestroy($srcImage);
/*
$small = self::resize_picture($source_file, 22);
@@ -2165,7 +2202,7 @@ class UserGroup extends Model
if (file_exists($file)) {
$picture['file'] = $image_array['dir'].$size_picture.$picture_file;
if ($height > 0) {
$dimension = api_getimagesize($picture['file']);
$dimension = api_getimagesize($file);
$margin = ($height - $dimension['width']) / 2;
//@ todo the padding-top should not be here
}
+66 -14
View File
@@ -2343,7 +2343,13 @@ class UserManager
}
}
$sql .= str_replace("\'", "'", Database::escape_string($extraConditions));
// $extraConditions is a caller-constructed SQL fragment, not a scalar
// value — escaping it as a string then immediately un-escaping the
// result (str_replace("\'", "'", ...)) produced a net-zero effect while
// giving a false sense of safety. Callers are responsible for ensuring
// any values embedded in $extraConditions are individually escaped or
// validated before being passed here.
$sql .= $extraConditions;
if (!empty($order_by) && count($order_by) > 0) {
$sql .= ' ORDER BY '.Database::escape_string(implode(',', $order_by));
@@ -4513,18 +4519,16 @@ class UserManager
if ($user_id === false) {
return false;
}
$service_name = Database::escape_string($api_service);
if (is_string($service_name) === false) {
return false;
}
$t_api = Database::get_main_table(TABLE_MAIN_USER_API_KEY);
$md5 = md5((time() + ($user_id * 5)) - rand(10000, 10000)); //generate some kind of random key
$sql = "INSERT INTO $t_api (user_id, api_key,api_service) VALUES ($user_id,'$md5','$service_name')";
$res = Database::query($sql);
if ($res === false) {
return false;
} //error during query
$num = Database::insert_id();
$apiKey = bin2hex(random_bytes(16)); // cryptographically secure random API key
$num = Database::insert(
Database::get_main_table(TABLE_MAIN_USER_API_KEY),
[
'user_id' => $user_id,
'api_key' => $apiKey,
'api_service' => $api_service,
]
);
return $num == 0 ? false : $num;
}
@@ -6988,7 +6992,7 @@ SQL;
public static function blockIfMaxLoginAttempts(array $userInfo)
{
if (false === (bool) $userInfo['active'] || null === $userInfo['last_login']) {
if (!isset($userInfo['active']) || false === (bool) $userInfo['active'] || null === $userInfo['last_login']) {
return;
}
@@ -8312,6 +8316,54 @@ SQL;
return Database::store_result(Database::query($sql), 'ASSOC');
}
/**
* Check or fetch a user by extrafield on this portal.
*
* @param string $value The extrafield value to test (e.g. DNI).
* @param bool $returnId If true, return the existing user ID or null; otherwise return true/false for uniqueness.
*
* @return bool|int|null When $returnId===false: true if unique, false if already exists.
* When $returnId===true: existing user ID or null if none.
*/
public static function isExtraFieldValueUniquePerUrl(string $value, bool $returnId = false)
{
$field = api_get_configuration_value('extra_field_to_validate_on_user_registration');
if (empty($field) || $value === '') {
// If there's nothing to check, treat as “unique” or “no ID”
return $returnId ? null : true;
}
$accessUrlId = api_get_current_access_url_id();
$tUser = Database::get_main_table(TABLE_MAIN_USER);
$tField = Database::get_main_table(TABLE_EXTRA_FIELD);
$tValue = Database::get_main_table(TABLE_EXTRA_FIELD_VALUES);
$tRelUrl = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
$sql = "
SELECT u.id
FROM {$tUser} u
JOIN {$tValue} v ON v.item_id = u.id
JOIN {$tField} f ON f.id = v.field_id
JOIN {$tRelUrl} url ON url.user_id = u.id
WHERE f.variable = '".Database::escape_string($field)."'
AND v.value = '".Database::escape_string($value)."'
AND url.access_url_id = {$accessUrlId}
LIMIT 1
";
$result = Database::query($sql);
$row = Database::fetch_array($result, 'ASSOC');
if ($returnId) {
// return the existing user ID, or null if none
return $row['id'] ?? null;
}
// return true if no match was found (i.e. unique), false otherwise
return empty($row);
}
/**
* @return EncoderFactory
*/
+524 -38
View File
@@ -3,12 +3,12 @@
/* For licensing terms, see /license.txt */
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ExtraFieldValues;
use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CourseBundle\Entity\CLpCategory;
use Chamilo\CourseBundle\Entity\CNotebook;
use Chamilo\CourseBundle\Entity\Repository\CNotebookRepository;
use Chamilo\UserBundle\Entity\User;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
/**
@@ -61,7 +61,9 @@ class Rest extends WebService
public const GET_COURSE_LINKS = 'course_links';
public const GET_COURSE_WORKS = 'course_works';
public const GET_COURSE_EXERCISES = 'course_exercises';
public const GET_COURSE_GRADEBOOK = 'course_gradebook';
public const GET_COURSES_DETAILS_BY_EXTRA_FIELD = 'courses_details_by_extra_field';
public const GET_COURSE_BY_CODE = 'course_details_by_code';
public const SAVE_COURSE_NOTEBOOK = 'save_course_notebook';
@@ -127,6 +129,7 @@ class Rest extends WebService
public const ADD_USERS_SESSION = 'add_users_session';
public const SUBSCRIBE_USER_TO_SESSION_FROM_USERNAME = 'subscribe_user_to_session_from_username';
public const SUBSCRIBE_USERS_TO_SESSION = 'subscribe_users_to_session';
public const ADD_SESSION_COURSE_COACHES = 'add_session_course_coaches';
public const UNSUBSCRIBE_USERS_FROM_SESSION = 'unsubscribe_users_from_session';
public const GET_USERS_SUBSCRIBED_TO_SESSION = 'get_users_subscribed_to_session';
@@ -154,6 +157,9 @@ class Rest extends WebService
public const DELETE_GROUP_SUB_COURSE = 'delete_group_sub_course';
public const DELETE_GROUP_SUB_SESSION = 'delete_group_sub_session';
public const GET_AUDIT_ITEMS = 'get_audit_items';
public const SUBSCRIBE_COURSE_TO_SESSION_FROM_EXTRA_FIELD = 'subscribe_course_to_session_from_extra_field';
public const SUBSCRIBE_USER_TO_SESSION_FROM_EXTRA_FIELD = 'subscribe_user_to_session_from_extra_field';
public const UPDATE_SESSION_FROM_EXTRA_FIELD = 'update_session_from_extra_field';
/**
* @var Session
@@ -181,7 +187,7 @@ class Rest extends WebService
*
* @param int $userId
*/
private function __getConfiguredUsernameById(int $userId = null): string
private function __getConfiguredUsernameById(?int $userId = null): string
{
if (empty($userId)) {
return '';
@@ -238,12 +244,7 @@ class Rest extends WebService
}
}
/**
* @param string $encoded
*
* @return array
*/
public static function decodeParams($encoded)
public static function decodeParams(string $encoded): array
{
return json_decode($encoded);
}
@@ -500,6 +501,40 @@ class Rest extends WebService
return $data;
}
/**
* @throws Exception
*/
public function getCourseByCode(string $q, int $sessionId = 0): array
{
if (!api_is_teacher() && !api_is_platform_admin()) {
self::throwNotAllowedException();
}
if (strlen($q) < 3) {
throw new Exception(get_lang('TooShort'));
}
$courseList = CourseManager::searchCourse($q, $sessionId);
$em = Database::getManager();
return array_map(
function ($courseInfo) use ($em) {
/** @var Course $course */
$course = $em->find(Course::class, $courseInfo['id']);
return [
'id' => $course->getId(),
'title' => $course->getTitle(),
'code' => $course->getCode(),
'directory' => $course->getDirectory(),
'urlPicture' => CourseManager::getPicturePath($course, true),
'teachers' => CourseManager::getTeacherListFromCourseCodeToString($course->getCode()),
];
},
$courseList
);
}
/**
* @throws Exception
*
@@ -1235,20 +1270,11 @@ class Rest extends WebService
'username' => $this->user->getUsername(),
'officialCode' => $this->user->getOfficialCode(),
'phone' => $this->user->getPhone(),
'extra' => [],
];
$fieldValue = new ExtraFieldValue('user');
$extraInfo = $fieldValue->getAllValuesForAnItem($this->user->getId(), true);
$extraInfo = (new ExtraFieldValue('user'))->getAllValuesForAnItem($this->user->getId(), true);
foreach ($extraInfo as $extra) {
/** @var ExtraFieldValues $extraValue */
$extraValue = $extra['value'];
$result['extra'][] = [
'title' => $extraValue->getField()->getDisplayText(true),
'value' => $extraValue->getValue(),
];
}
$result['extra'] = ExtraFieldValue::formatValues($extraInfo);
return $result;
}
@@ -1828,25 +1854,23 @@ class Rest extends WebService
return $out;
}
public function addCourse(array $courseParam): array
/**
* @throws Exception
*/
public function addCourse(ParameterBag $request): array
{
self::protectAdminEndpoint();
$idCampus = isset($courseParam['id_campus']) ? $courseParam['id_campus'] : 1;
$title = isset($courseParam['title']) ? $courseParam['title'] : '';
$wantedCode = isset($courseParam['wanted_code']) ? $courseParam['wanted_code'] : null;
$diskQuota = isset($courseParam['disk_quota']) ? $courseParam['disk_quota'] : '100';
$visibility = isset($courseParam['visibility']) ? (int) $courseParam['visibility'] : null;
$removeCampusId = $courseParam['remove_campus_id_from_wanted_code'] ?? 0;
$language = $courseParam['language'] ?? '';
$idCampus = $request->getInt('id_campus', 1);
$title = $request->get('title');
$wantedCode = $request->get('wanted_code');
$diskQuota = $request->getInt('disk_quota', 100);
$visibility = $request->getInt('visibility');
$removeCampusId = $request->getBoolean('remove_campus_id_from_wanted_code');
$language = $request->get('language');
if (isset($courseParam['visibility'])) {
if ($courseParam['visibility'] &&
$courseParam['visibility'] >= 0 &&
$courseParam['visibility'] <= 3
) {
$visibility = (int) $courseParam['visibility'];
}
if (!isset(Course::getStatusList()[$visibility])) {
throw new Exception(get_lang('VisibilityCannotBeChanged'));
}
$params = [];
@@ -1860,12 +1884,16 @@ class Rest extends WebService
$params['disk_quota'] = $diskQuota;
$params['course_language'] = $language;
foreach ($courseParam as $key => $value) {
foreach ($request->all() as $key => $value) {
if (substr($key, 0, 6) === 'extra_') { //an extra field
$params[$key] = $value;
}
}
if ('true' === api_get_setting('teacher_can_select_course_template')) {
$params['course_template'] = $request->getInt('course_template');
}
$courseInfo = CourseManager::create_course($params, $params['user_id'], $idCampus);
$results = [];
if (!empty($courseInfo)) {
@@ -2545,7 +2573,7 @@ class Rest extends WebService
*
* @return int The matching session id, or an array with details about the session
*/
public function getSessionFromExtraField(string $fieldName, string $fieldValue)
public function getSessionFromExtraField(string $fieldName, string $fieldValue): int
{
// find sessions that have that value in the given field
$valueModel = new ExtraFieldValue('session');
@@ -2568,7 +2596,7 @@ class Rest extends WebService
}
// return sessionId
return intval($sessionIdList[0]['item_id']);
return (int) $sessionIdList[0]['item_id'];
}
/**
@@ -2679,10 +2707,26 @@ class Rest extends WebService
throw new Exception(get_lang('NoData'));
}
if (!api_is_platform_admin() && $userId != $this->user->getId()) {
$isAdmin = api_is_platform_admin();
if (!$isAdmin && $userId != $this->user->getId()) {
self::throwNotAllowedException();
}
// Fields that only platform admins may change
$adminOnlyFields = [
'status',
'roles',
'auth_source',
'enabled',
'active',
'creator_id',
'registration_date',
'expiration_date',
'hr_dept_id',
'official_code',
];
if (!empty($parameters['new_login_name'])) {
// Make sure the new username, if set, is available
if (!UserManager::is_username_available($parameters['new_login_name'])) {
@@ -2704,6 +2748,9 @@ class Rest extends WebService
// apply submitted modifications
foreach ($parameters as $name => $value) {
if (!$isAdmin && in_array(strtolower($name), $adminOnlyFields, true)) {
self::throwNotAllowedException();
}
switch (strtolower($name)) {
case 'email':
$user->setEmail($value);
@@ -4381,6 +4428,384 @@ class Rest extends WebService
];
}
/**
* Subscribe a specific course to a specific session, identified via extra field values.
*
* This method:
* - Locates the session ID using the provided session extra field name/value via ExtraFieldValue('session').
* - Locates the course c_id using the provided course extra field name/value via ExtraFieldValue('course').
* - Adds the course to the session using SessionManager::add_courses_to_session() (similar to addCoursesSession()).
*
* Required parameters:
* - session_field_name: Name of the extra field for sessions (e.g., 'peoplesoft_sid').
* - session_field_value: Value of the session extra field (e.g., '123450').
* - course_field_name: Name of the extra field for courses (e.g., 'peoplesoft_cid').
* - course_field_value: Value of the course extra field (e.g., '1').
*
* @param array $params Associative array of POST parameters.
*
* @throws Exception
*
* @return array Response in format: ['error' => bool, 'data' => array] on success, or ['error' => true, 'message' => string] on failure.
*/
public function subscribeCourseToSessionFromExtraField($params)
{
// Validate required parameters (redundant with v2.php but for safety)
$required = ['session_field_name', 'session_field_value', 'course_field_name', 'course_field_value'];
foreach ($required as $key) {
if (empty($params[$key])) {
return [
'error' => true,
'message' => 'Missing required parameter: '.$key,
];
}
}
$sessionFieldName = $params['session_field_name'];
$sessionFieldValue = $params['session_field_value'];
$courseFieldName = $params['course_field_name'];
$courseFieldValue = $params['course_field_value'];
// Get session ID from extra field value using ExtraFieldValue model
$sessionValueModel = new ExtraFieldValue('session');
$sessionIdList = $sessionValueModel->get_item_id_from_field_variable_and_field_value(
$sessionFieldName,
$sessionFieldValue,
false,
false,
true
);
if (empty($sessionIdList)) {
return [
'error' => true,
'message' => 'No session found with extra field value "'.$sessionFieldValue.'".',
];
}
$sessionId = (int) $sessionIdList[0]['item_id']; // Assume single match
// Get course c_id from extra field value using ExtraFieldValue model
$courseValueModel = new ExtraFieldValue('course');
$courseIdList = $courseValueModel->get_item_id_from_field_variable_and_field_value(
$courseFieldName,
$courseFieldValue,
false,
false,
true
);
if (empty($courseIdList)) {
return [
'error' => true,
'message' => 'No course found with extra field value "'.$courseFieldValue.'".',
];
}
$cId = (int) $courseIdList[0]['item_id']; // Assume single match
// Add course to session using existing core method (mirrors addCoursesSession logic)
$success = SessionManager::add_courses_to_session($sessionId, [$cId], false);
if ($success) {
return [
'error' => false,
'data' => [
'status' => true,
'message' => 'Course subscribed to session',
'id_session' => $sessionId,
'c_id' => $cId,
],
];
} else {
return [
'error' => true,
'message' => 'Failed to subscribe course to session.',
];
}
}
/**
* Subscribe a specific user to a specific session, identified via extra field values.
*
* This method:
* - Locates the session ID using the provided session extra field name/value via ExtraFieldValue('session').
* - Locates the user ID using the provided user extra field name/value via ExtraFieldValue('user').
* - Adds the user to the session using SessionManager::subscribe_users_to_session() (similar to subscribeUsersToSession()).
*
* Required parameters:
* - session_field_name: Name of the extra field for sessions (e.g., 'peoplesoft_sid').
* - session_field_value: Value of the session extra field (e.g., '123450').
* - user_field_name: Name of the extra field for users (e.g., 'peoplesoft_uid').
* - user_field_value: Value of the user extra field (e.g., '1').
*
* @param array $params Associative array of POST parameters.
*
* @return array Response in format: ['error' => bool, 'data' => array] on success, or ['error' => true, 'message' => string] on failure.
*/
public function subscribeUserToSessionFromExtraField($params)
{
// Validate required parameters (redundant with v2.php but for safety)
$required = ['session_field_name', 'session_field_value', 'user_field_name', 'user_field_value'];
foreach ($required as $key) {
if (empty($params[$key])) {
return [
'error' => true,
'message' => 'Missing required parameter: '.$key,
];
}
}
$sessionFieldName = $params['session_field_name'];
$sessionFieldValue = $params['session_field_value'];
$userFieldName = $params['user_field_name'];
$userFieldValue = $params['user_field_value'];
// Get session ID from extra field value using ExtraFieldValue model
$sessionValueModel = new ExtraFieldValue('session');
$sessionIdList = $sessionValueModel->get_item_id_from_field_variable_and_field_value(
$sessionFieldName,
$sessionFieldValue,
false,
false,
true
);
if (empty($sessionIdList)) {
return [
'error' => true,
'message' => 'No session found with extra field value "'.$sessionFieldValue.'".',
];
}
$sessionId = (int) $sessionIdList[0]['item_id']; // Extract item_id from sub-array, assume single match
// Get user ID from extra field value using ExtraFieldValue model
$userValueModel = new ExtraFieldValue('user');
$userIdList = $userValueModel->get_item_id_from_field_variable_and_field_value(
$userFieldName,
$userFieldValue,
false,
false,
true
);
if (empty($userIdList)) {
return [
'error' => true,
'message' => 'No user found with extra field value "'.$userFieldValue.'".',
];
}
$userId = (int) $userIdList[0]['item_id']; // Extract item_id from sub-array, assume single match
// Add user to session using existing core method (mirrors subscribeUsersToSession logic)
$success = SessionManager::subscribeUsersToSession($sessionId, [$userId]);
if ($success) {
return [
'error' => false,
'data' => [
'status' => true,
'message' => 'User subscribed to session',
'id_session' => $sessionId,
'user_id' => $userId,
],
];
} else {
return [
'error' => true,
'message' => 'Failed to subscribe user to session.',
];
}
}
/**
* Update a specific session, identified via extra field value.
*
* This method:
* - Locates the session ID using the provided extra field name/value via ExtraFieldValue('session').
* - Calls updateSession() with the located ID and provided update parameters (e.g., name, coach_username, dates).
*
* Required parameters:
* - field_name: Name of the extra field for sessions (e.g., 'peoplesoft_sid').
* - field_value: Value of the session extra field (e.g., PeopleSoft ID).
* - Optional update fields: name, coach_username, access_start_date, access_end_date, etc.
*
* @param array $params Associative array of POST parameters.
*
* @return array Response in format: ['error' => bool, 'data' => array] on success, or ['error' => true, 'message' => string] on failure.
*/
public function updateSessionFromExtraField($params)
{
// Validate required parameters (redundant with v2.php but for safety)
$required = ['field_name', 'field_value'];
foreach ($required as $key) {
if (empty($params[$key])) {
return [
'error' => true,
'message' => 'Missing required parameter: '.$key,
];
}
}
$fieldName = $params['field_name'];
$fieldValue = $params['field_value'];
// Get session ID from extra field value using ExtraFieldValue model
$sessionValueModel = new ExtraFieldValue('session');
$sessionIdList = $sessionValueModel->get_item_id_from_field_variable_and_field_value(
$fieldName,
$fieldValue,
false,
false,
true
);
if (empty($sessionIdList)) {
return [
'error' => true,
'message' => 'No session found with extra field value "'.$fieldValue.'".',
];
}
$sessionId = (int) $sessionIdList[0]['item_id']; // Extract item_id from sub-array, assume single match
// Prepare params for updateSession() by adding the located ID
$params['id_session'] = $sessionId;
// Get coach ID if we got it as username
if (!empty($params['coach_username'])) {
$params['id_coach'] = UserManager::get_user_id_from_username($params['coach_username']);
}
// Delegate to existing updateSession() method (mirrors its logic)
$result = $this->updateSession($params);
// Override message and include ID in data if successful
if (!$result['error']) {
$result['data']['id_session'] = $sessionId;
$result['data']['message'] = 'Session updated';
}
return $result;
}
/**
* @throws Exception
*/
public function addSessionCourseCoaches(ParameterBag $request)
{
$sessionId = $request->getInt('id_session');
$courseId = $request->getInt('course_id');
$em = Database::getManager();
$countSession = $em->getRepository(Session::class)->count(['id' => $sessionId]);
if (!$countSession) {
throw new Exception(get_lang('NoSession'));
}
if (!SessionManager::cantEditSession($sessionId)) {
throw new Exception(get_lang('NotAllowed'));
}
$countCourse = $em->getRepository(Course::class)->count(['id' => $courseId]);
if (!$countCourse) {
throw new Exception(get_lang('NoCourse'));
}
$coachesToSubscribe = array_filter(
array_map(fn ($coachId) => (int) $coachId, $request->get('coach_id', []))
);
$subscribedCoaches = SessionManager::getCoachesByCourseSession($sessionId, $courseId);
$coachesToRemove = array_diff($subscribedCoaches, $coachesToSubscribe);
foreach ($coachesToSubscribe as $coachId) {
SessionManager::set_coach_to_course_session(
$coachId,
$sessionId,
$courseId
);
}
foreach ($coachesToRemove as $coachId) {
SessionManager::set_coach_to_course_session($coachId, $sessionId, $courseId, true);
}
Event::addEvent(
LOG_WS.self::ADD_SESSION_COURSE_COACHES,
'session_id-course_id-coach_ids',
(int) $_POST['id_session'].':'.implode(',', $coachesToSubscribe)
);
}
/**
* @throws Exception
*/
public function getCourseGradebook(): array
{
$isDrhOfCourse = CourseManager::isUserSubscribedInCourseAsDrh(
$this->user->getId(),
api_get_course_info($this->course->getCode())
);
$isDrhOfSession = $this->session
&& !empty(SessionManager::getSessionFollowedByDrh($this->user->getId(), $this->session->getId()));
if (!$isDrhOfCourse && !$isDrhOfSession) {
GradebookUtils::block_students();
}
Event::event_access_tool(TOOL_GRADEBOOK);
$cats = Category::load(
null,
null,
$this->course->getCode(),
null,
null,
$this->session ? $this->session->getId() : null,
false
);
$cats = array_filter(
$cats,
fn ($cat) => $cat->get_parent_id() == 0
);
if (empty($cats)) {
throw new Exception(get_lang('NoCategory'));
}
$cat = array_shift($cats);
$allEval = $cat->get_evaluations(0, true);
$allLinks = $cat->get_links(0, true);
$users = GradebookUtils::get_all_users($allEval, $allLinks);
$mainCourseCategory = Category::load(
null,
null,
$this->course->getCode(),
null,
null,
$this->session ? $this->session->getId() : null
);
$flatViewTable = new FlatViewTable(
$cat,
$users,
$allEval,
$allLinks,
true,
0,
null,
$mainCourseCategory[0]
);
$flatViewTable->setAutoFill(false);
$flatViewTable->set_additional_parameters(['export_pdf' => true]);
$headers = $this->formatGradebookHeaders($flatViewTable->datagen);
$rows = $this->formatGradebookRows($headers, $flatViewTable->datagen);
return [
'headers' => $headers,
'rows' => $rows,
];
}
/**
* Generate an API key for webservices access for the given user ID.
*/
@@ -4429,4 +4854,65 @@ class Rest extends WebService
return api_get_self().'?'
.http_build_query(array_merge($queryParams, $additionalParams));
}
private function formatGradebookHeaders(FlatViewDataGenerator $dataGen): array
{
$result = [];
$colIndex = 0;
foreach ($dataGen->get_header_names() as $header) {
if (is_array($header)) {
$groupLabel = preg_replace(
'/<a[^>]*>(.*?)<\/a>\s*(.*?)<\/span>/i',
"$1\n$2",
$header['header'] ?? 'group_'.$colIndex
);
$groupLabel = strip_tags($groupLabel);
foreach ($header['items'] as $item) {
$item = preg_replace('/<br>\s*<small>(.*?)<\/small>/', "\n$1", $item);
$item = strip_tags($item);
$result[] = [
'key' => 'col_'.$colIndex,
'label' => trim($item),
'group_label' => trim($groupLabel),
];
$colIndex++;
}
} else {
$header = strip_tags($header);
$result[] = [
'key' => 'col_'.$colIndex,
'label' => trim($header),
'group_label' => null,
];
$colIndex++;
}
}
return $result;
}
private function formatGradebookRows(array $headers, FlatViewDataGenerator $dataGen): array
{
$keys = array_column($headers, 'key');
$rows = [];
foreach ($dataGen->get_data() as $row) {
array_shift($row);
$cleaned = array_map(static fn ($v) => is_string($v) ? trim(strip_tags($v)) : $v, $row);
$mapped = [];
foreach ($keys as $i => $key) {
$mapped[$key] = $cleaned[$i] ?? null;
}
$rows[] = $mapped;
}
return $rows;
}
}
+2 -2
View File
@@ -678,7 +678,7 @@ class xajax
$sErrorResponse->addAlert("** Logging Error **\n\nxajax was unable to write to the error log file:\n" . $this->sLogFile);
}
else {
fwrite($fH, "** xajax Error Log - " . strftime("%b %e %Y %I:%M:%S %p") . " **" . $GLOBALS['xajaxErrorHandlerText'] . "\n\n\n");
fwrite($fH, "** xajax Error Log - " . date("M j Y g:i:s A") . " **" . $GLOBALS['xajaxErrorHandlerText'] . "\n\n\n");
fclose($fH);
}
}
@@ -1163,7 +1163,7 @@ function xajaxErrorHandler($errno, $errstr, $errfile, $errline)
else if ($errno == E_USER_ERROR) {
$errTypeStr = "USER FATAL ERROR";
}
else if ($errno == E_STRICT) {
else if (defined('E_STRICT') && $errno == E_STRICT) {
return;
}
else {
+4 -3
View File
@@ -535,9 +535,10 @@ class xajaxResponse
if ($this->bOutputEntities) {
// An adaptation for the Dokeos LMS, 22-AUG-2009.
if (function_exists('api_convert_encoding')) {
$sData = call_user_func_array(
'api_convert_encoding',
array(&$sData, 'HTML-ENTITIES', $this->sEncoding)
$sData = mb_encode_numericentity(
mb_convert_encoding($sData, 'UTF-8', $this->sEncoding),
[0x80, 0x10FFFF, 0, 0x1FFFFF],
'UTF-8'
);
} //if (function_exists('mb_convert_encoding')) {
elseif (function_exists('mb_convert_encoding')) {