From d421e4276a897facdcbaac6f6126b421acb4d184 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Fri, 6 Feb 2026 00:14:40 -0500 Subject: [PATCH] Course: Fix duplicate assignments when copying session-only content - refs BT#22588 --- public/main/session/session_list.php | 114 ++++--- .../Component/CourseCopy/CourseBuilder.php | 179 ++++++----- .../Component/CourseCopy/CourseRestorer.php | 262 ++++++++++------ .../Component/CourseCopy/CourseSelectForm.php | 296 +++++++++++------- 4 files changed, 522 insertions(+), 329 deletions(-) diff --git a/public/main/session/session_list.php b/public/main/session/session_list.php index 07355c0c4e2..d85a60a592a 100644 --- a/public/main/session/session_list.php +++ b/public/main/session/session_list.php @@ -53,12 +53,12 @@ exit(); case 'copy': $result = SessionManager::copy( - (int) $idChecked, - true, - true, - false, - false, - $copySessionContent + (int) $idChecked, + true, + true, + false, + false, + $copySessionContent ); if ($result) { Display::addFlash(Display::return_message(get_lang('Item copied'))); @@ -138,6 +138,36 @@ #session-table .ui-jqgrid { max-width: 100%; } +#session-table.sessions-grid-wrap .ui-jqgrid tr.jqgrow, +#session-table.sessions-grid-wrap .ui-jqgrid tr.jqgrow td { + height: auto !important; +} +#session-table.sessions-grid-wrap .ui-jqgrid tr.jqgrow td { + white-space: normal !important; + line-height: 1.25 !important; + padding-top: 6px !important; + padding-bottom: 6px !important; + vertical-align: top !important; + overflow: visible !important; + text-overflow: clip !important; + word-break: break-word; + overflow-wrap: anywhere; +} +#session-table.sessions-grid-wrap .ui-jqgrid-htable th div { + white-space: normal !important; + height: auto !important; + line-height: 1.2 !important; +} +#session-table.sessions-grid-wrap .ui-jqgrid .ui-jqgrid-bdiv { + overflow-x: auto !important; +} +#session-table #gbox_sessions, +#session-table #gview_sessions, +#session-table #gview_sessions .ui-jqgrid-hdiv, +#session-table #gview_sessions .ui-jqgrid-bdiv, +#session-table #gview_sessions .ui-jqgrid-pager { + width: 100% !important; +} .sessions-advanced-search-float { position: fixed !important; z-index: 3000 !important; @@ -151,13 +181,6 @@ .sessions-advanced-search-float .ui-jqgrid { font-size: 13px; } -#session-table #gbox_sessions, -#session-table #gview_sessions, -#session-table #gview_sessions .ui-jqgrid-hdiv, -#session-table #gview_sessions .ui-jqgrid-bdiv, -#session-table #gview_sessions .ui-jqgrid-pager { - width: 100% !important; -} #sessions_pager .ui-pg-button.ui-state-hover .ch-tool-icon, #sessions_pager .ui-pg-button:hover .ch-tool-icon, #sessions_pager .ui-pg-button.ui-state-hover .mdi, @@ -249,8 +272,8 @@ $column_model = $result['column_model']; $extra_params['autowidth'] = 'true'; $extra_params['height'] = 'auto'; -$extra_params['shrinkToFit'] = true; -$extra_params['forceFit'] = true; +$extra_params['shrinkToFit'] = false; +$extra_params['forceFit'] = false; switch ($listType) { case 'custom': @@ -346,7 +369,7 @@ function show_cols(grid, added_cols) { $(elem).datetimepicker('setDate', next_month); } - //Great hack + // Great hack register_second_select = function(elem) { second_filters[$(elem).val()] = $(elem); } @@ -391,11 +414,47 @@ function show_cols(grid, added_cols) { setSearchSelect("status"); var grid = $("#sessions"); - // Re-apply width after each data render (jqGrid sometimes resets widths) + function resizeSessionsGrid() { + var container = document.getElementById("session-table"); + if (!container) { + return; + } + + var rect = container.getBoundingClientRect(); + var newWidth = Math.floor(rect.width); + + // Avoid applying invalid widths (e.g. while hidden inside a tab) + if (!newWidth || newWidth < 200) { + return; + } + + grid.jqGrid("setGridWidth", newWidth, true); + } + + function applySessionsTooltips() { + // Add a tooltip with full text to each simple cell (skip action/icon cells). + var $cells = grid.closest("#gbox_sessions").find("tr.jqgrow td"); + $cells.each(function() { + var $td = $(this); + if ($td.attr("title")) { + return; + } + if ($td.find("a,button,svg,i").length) { + return; + } + var text = $.trim($td.text()); + if (text) { + $td.attr("title", text); + } + }); + } + + // Re-apply width and tooltips after each data render grid.jqGrid("setGridParam", { gridComplete: function () { setTimeout(function () { resizeSessionsGrid(); + applySessionsTooltips(); }, 0); } }); @@ -403,6 +462,7 @@ function show_cols(grid, added_cols) { // Ensure width is correct after page load (fonts/tabs/layout final width) $(window).on("load", function () { resizeSessionsGrid(); + applySessionsTooltips(); }); // If tabs affect layout, recalc when tab is shown (Bootstrap) + fallback click @@ -470,23 +530,6 @@ function toggleAdvancedSearch() { grid.jqGrid("searchGrid", prmSearch); } - function resizeSessionsGrid() { - var container = document.getElementById("session-table"); - if (!container) { - return; - } - - var rect = container.getBoundingClientRect(); - var newWidth = Math.floor(rect.width); - - // Avoid applying invalid widths (e.g. while hidden inside a tab) - if (!newWidth || newWidth < 200) { - return; - } - - grid.jqGrid("setGridWidth", newWidth, true); - } - var prmSearch = { multipleSearch: true, overlay: false, @@ -622,6 +665,7 @@ function resizeSessionsGrid() { // Initial paint adjustments setTimeout(function() { resizeSessionsGrid(); + applySessionsTooltips(); }, 0); }); @@ -667,7 +711,7 @@ function resizeSessionsGrid() { echo Display::toolbarAction('toolbar', [$actionsLeft, $actionsRight]); echo SessionManager::getSessionListTabs($listType); -echo '
'; +echo '
'; echo Display::grid_html('sessions'); echo '
'; diff --git a/src/CourseBundle/Component/CourseCopy/CourseBuilder.php b/src/CourseBundle/Component/CourseCopy/CourseBuilder.php index f8e8ac7b2aa..06ad1cba374 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseBuilder.php +++ b/src/CourseBundle/Component/CourseCopy/CourseBuilder.php @@ -48,6 +48,7 @@ use Chamilo\CourseBundle\Entity\CQuizQuestionOption; use Chamilo\CourseBundle\Entity\CQuizRelQuestion; use Chamilo\CourseBundle\Entity\CStudentPublication; +use Chamilo\CourseBundle\Entity\CStudentPublicationAssignment; use Chamilo\CourseBundle\Entity\CSurvey; use Chamilo\CourseBundle\Entity\CSurveyQuestion; use Chamilo\CourseBundle\Entity\CSurveyQuestionOption; @@ -1076,116 +1077,130 @@ private function serializeGradebookCategory(GradebookCategory $c): array public function build_works( object $legacyCourse, ?CourseEntity $courseEntity, - ?SessionEntity $sessionEntity, - array $ids + ?SessionEntity $sessionEntity ): void { if (!$courseEntity instanceof CourseEntity) { return; } - $repo = Container::getStudentPublicationRepository(); - $qb = $this->getResourcesByCourseQbFromRepo($repo, $courseEntity, $sessionEntity, $this->withBaseContent); + $this->course->resources[RESOURCE_WORK] ??= []; + + // Try to read the flag from the backup/copy context + $copyOnlySessionItems = (bool) ( + $this->course->copy_only_session_items + ?? $this->course->copyOnlySessionItems + ?? false + ); + + // If we only want session items, don't export base-course works (sid=0). + if ($copyOnlySessionItems && null === $sessionEntity) { + if (method_exists($this, 'dlog')) { + $this->dlog('build_works: skipped for base course because copy_only_session_items is enabled', [ + 'course_id' => (int) $courseEntity->getId(), + ]); + } + return; + } + + $qb = $this->createContextQb( + CStudentPublication::class, + $courseEntity, + $sessionEntity, + 'w' + ); + + $linksAlias = $this->getContextLinksAlias('w'); - // Export folders only (assignments are folders). Keep active only. $qb - ->andWhere('resource.filetype = :ft') + ->andWhere('w.filetype = :ft') ->setParameter('ft', 'folder') - ->andWhere('resource.active = 1') + ->andWhere('w.publicationParent IS NULL') + ->andWhere('w.active IN (0,1)') + ->distinct() + ->groupBy('w.iid') + ->orderBy('w.iid', 'ASC') ; - $or = $qb->expr()->orX('resource.publicationParent IS NULL'); - - try { - $meta = $this->em->getClassMetadata(CStudentPublication::class); - - if ($meta->hasAssociation('assignment')) { - $asgmtAlias = 'asgmt'; + // Force strict session links when copying only session items. + if ($copyOnlySessionItems && $sessionEntity) { + $qb + ->andWhere($linksAlias.'.session = :session') + ->setParameter('session', $sessionEntity) + ; + } - if (!$this->hasJoinAlias($qb, $asgmtAlias)) { - $qb->leftJoin('resource.assignment', $asgmtAlias); - $qb->addSelect($asgmtAlias); - } + $results = $qb->getQuery()->getResult(); - $targetClass = $meta->getAssociationTargetClass('assignment'); - $targetMeta = $this->em->getClassMetadata($targetClass); - $idField = $targetMeta->getSingleIdentifierFieldName(); // e.g. "id" / "iid" + $seen = []; + foreach ($results as $pub) { + if (!$pub instanceof CStudentPublication) { + continue; + } - // Assignment folder => joined assignment id is not null - $or->add($qb->expr()->isNotNull($asgmtAlias.'.'.$idField)); - } else { - // Legacy/scalar mapping fallback - $or->add('resource.assignment IS NOT NULL'); + $iid = (int) $pub->getIid(); + if ($iid <= 0 || isset($seen[$iid])) { + continue; } - } catch (Throwable) { - // keep legacy behavior if metadata access fails - $or->add('resource.assignment IS NOT NULL'); - } + $seen[$iid] = true; - $qb->andWhere($or) - ->addOrderBy('resource.iid', 'ASC') - ; + $assignment = $pub->getAssignment(); - if (!empty($ids)) { - $qb->andWhere('resource.iid IN (:ids)') - ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); - } + $params = [ + 'iid' => $iid, + 'title' => (string) $pub->getTitle(), + 'description' => (string) $pub->getDescription(), - /** @var CStudentPublication[] $rows */ - $rows = $qb->getQuery()->getResult(); + 'weight' => (float) ($pub->getWeight() ?? 0.0), + 'qualification' => (float) ($pub->getQualification() ?? 0.0), + 'allow_text_assignment' => (int) ($pub->getAllowTextAssignment() ?? 0), - $exported = 0; + 'default_visibility' => (bool) ($pub->getDefaultVisibility() ?? false), + 'student_delete_own_publication' => (bool) ($pub->getStudentDeleteOwnPublication() ?? false), - foreach ($rows as $row) { - $iid = (int) $row->getIid(); - if ($iid <= 0) { - continue; + 'extensions' => (string) ($pub->getExtensions() ?? ''), + 'group_category_work_id' => (int) ($pub->getGroupCategoryWorkId() ?? 0), + 'post_group_id' => (int) ($pub->getPostGroupId() ?? 0), + ]; + + try { + $u = $pub->getUser(); + $params['user_id'] = $u ? (int) $u->getId() : 0; + } catch (\Throwable) { + $params['user_id'] = 0; } - $title = (string) $row->getTitle(); - $desc = (string) ($row->getDescription() ?? ''); + if ($assignment instanceof CStudentPublicationAssignment) { + $params['enable_qualification'] = (bool) ($assignment->getEnableQualification() ?? false); - // Detect documents linked in description - $this->findAndSetDocumentsInText($desc); + $expiresOn = $assignment->getExpiresOn(); + $endsOn = $assignment->getEndsOn(); - $asgmt = $row->getAssignment(); + $params['expires_on'] = $expiresOn instanceof \DateTimeInterface ? $expiresOn->format('Y-m-d H:i:s') : null; + $params['ends_on'] = $endsOn instanceof \DateTimeInterface ? $endsOn->format('Y-m-d H:i:s') : null; - // These fields are meaningful only when the folder is an actual assignment folder. - $expiresOn = $asgmt?->getExpiresOn()?->format('Y-m-d H:i:s'); - $endsOn = $asgmt?->getEndsOn()?->format('Y-m-d H:i:s'); - $addToCal = $asgmt && (int) $asgmt->getEventCalendarId() > 0 ? 1 : 0; - $enableQ = (bool) ($asgmt?->getEnableQualification() ?? false); + $params['add_to_calendar'] = ((int) ($assignment->getEventCalendarId() ?? 0)) > 0; + } else { + $params['enable_qualification'] = false; + $params['expires_on'] = null; + $params['ends_on'] = null; + $params['add_to_calendar'] = false; + } - $params = [ - 'id' => $iid, - 'title' => $title, - 'description' => $desc, - 'weight' => (float) $row->getWeight(), - 'qualification' => (float) $row->getQualification(), - 'allow_text_assignment' => (int) $row->getAllowTextAssignment(), - 'default_visibility' => (bool) ($row->getDefaultVisibility() ?? false), - 'student_delete_own_publication' => (bool) ($row->getStudentDeleteOwnPublication() ?? false), - 'extensions' => $row->getExtensions(), - 'group_category_work_id' => (int) $row->getGroupCategoryWorkId(), - 'post_group_id' => (int) $row->getPostGroupId(), - 'enable_qualification' => $enableQ, - 'add_to_calendar' => $addToCal ? 1 : 0, - 'expires_on' => $expiresOn ?: null, - 'ends_on' => $endsOn ?: null, - 'name' => $title, - 'url' => null, - ]; + $work = new \stdClass(); + $work->params = $params; + $work->destination_id = -1; - $legacy = new Work($params); - $legacyCourse->add_resource($legacy); - $exported++; - $this->trace( - '[CourseBuilder::build_works] Exported work folder iid='.$iid - .' is_assignment='.( $asgmt ? '1' : '0' ) - .' title="'.$title.'"' - ); + // Key by iid = stable and unique. + $this->course->resources[RESOURCE_WORK][$iid] = $work; } - $this->trace('[CourseBuilder::build_works] Done. Exported='.$exported.' session_id='.(int) ($sessionEntity?->getId() ?? 0)); + if (method_exists($this, 'dlog')) { + $this->dlog('build_works: done', [ + 'count' => count($this->course->resources[RESOURCE_WORK]), + 'copy_only_session_items' => (bool) $copyOnlySessionItems, + 'session_id' => (int) ($sessionEntity?->getId() ?? 0), + ]); + } } /** diff --git a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php index 2fbceead80d..7375fae50f8 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php @@ -6218,7 +6218,8 @@ private function resolveDestinationIid(string $bucket, int $srcId): int /** * Restore Student Publications (works) from backup selection. * - Honors file policy: FILE_SKIP (1), FILE_RENAME (2), FILE_OVERWRITE (3) - * - Keeps existing behavior: HTML rewriting, optional calendar event, destination_id mapping + * - Dedupe across multiple restore calls: if the same item was already restored, reuse it and adjust links. + * - If "copy only session items" is enabled: skip base restore (sid=0) and enforce session-only link. */ public function restore_works(int $sessionId = 0): void { @@ -6229,7 +6230,19 @@ public function restore_works(int $sessionId = 0): void /** @var EntityManagerInterface $em */ $em = \Database::getManager(); - // Resolve destination course entity safely (avoid wrong class collisions). + $copyOnlySessionItems = (bool) ( + $this->course->copy_only_session_items + ?? $this->course->copyOnlySessionItems + ?? false + ); + + // If user asked to copy only session items, never restore works for sid=0. + if ($copyOnlySessionItems && 0 === (int) $sessionId) { + $this->dlog('restore_works: skipped base restore because copy_only_session_items is enabled', []); + return; + } + + // Resolve destination course entity safely. $courseEntity = api_get_course_entity((int) $this->destination_course_id); if (!$courseEntity instanceof CourseEntity) { $destinationCourseId = (int) ($this->destination_course_id ?? 0); @@ -6268,49 +6281,143 @@ public function restore_works(int $sessionId = 0): void $filePolicy = (int) ($this->file_option ?? $FILE_RENAME); + $mappedBySource = []; + // enforce session-only link when copy_only_session_items is enabled. + $ensureSessionOnlyLink = function (CStudentPublication $pub) use ($em, $courseEntity, $sessionEntity, $copyOnlySessionItems): void { + if (!$copyOnlySessionItems || !$sessionEntity) { + return; + } + + // Ensure session link exists. + try { + if (!$pub->getFirstResourceLinkFromCourseSession($courseEntity, $sessionEntity)) { + $pub->addCourseLink($courseEntity, $sessionEntity); + } + } catch (\Throwable) { + // Ignore link creation failures. + } + + // Remove base link (session IS NULL) to avoid showing it in base course and causing "exists" conflicts. + try { + if (method_exists($pub, 'getResourceNode') && $pub->getResourceNode()) { + $node = $pub->getResourceNode(); + if (method_exists($node, 'getResourceLinks')) { + foreach ($node->getResourceLinks() as $rl) { + // Defensive checks + if (!method_exists($rl, 'getCourse') || !method_exists($rl, 'getSession')) { + continue; + } + $rlCourse = $rl->getCourse(); + $rlSession = $rl->getSession(); + + $sameCourse = $rlCourse && method_exists($rlCourse, 'getId') && (int) $rlCourse->getId() === (int) $courseEntity->getId(); + $isBase = null === $rlSession; + + if ($sameCourse && $isBase) { + $em->remove($rl); + } + } + $em->flush(); + } + } + } catch (\Throwable) { + // Ignore base-link removal failures. + } + }; + + // strict "exists" check, avoiding "session IS NULL" when we are restoring session-only. + $findExistingStrict = function (string $title) use ($em, $courseEntity, $sessionEntity, $sessionId): ?CStudentPublication { + $qb = $em->createQueryBuilder(); + $qb + ->select('w') + ->from(CStudentPublication::class, 'w') + ->innerJoin('w.resourceNode', 'rn') + ->innerJoin('rn.resourceLinks', 'rl') + ->andWhere('w.filetype = :ft') + ->andWhere('w.publicationParent IS NULL') + ->andWhere('w.active IN (0,1)') + ->andWhere('w.title = :title') + ->andWhere('rl.course = :course') + ->setParameter('ft', 'folder') + ->setParameter('title', $title) + ->setParameter('course', $courseEntity) + ->setMaxResults(1) + ; + + if ((int) $sessionId > 0) { + $qb->andWhere('rl.session = :session')->setParameter('session', $sessionEntity); + } else { + $qb->andWhere('rl.session IS NULL'); + } + + return $qb->getQuery()->getOneOrNullResult(); + }; + $this->dlog('restore_works: begin', [ 'count' => \count($this->course->resources[RESOURCE_WORK] ?? []), 'policy' => $filePolicy, 'session_id' => (int) $sessionId, + 'copy_only_session_items' => (bool) $copyOnlySessionItems, ]); - // IMPORTANT: Use the Doctrine entity FQCN explicitly (avoid Tool\User collision). - $creatorRef = $em->getReference(\Chamilo\CoreBundle\Entity\User::class, (int) api_get_user_id()); - foreach (($this->course->resources[RESOURCE_WORK] ?? []) as $legacyId => $obj) { $legacyId = (int) $legacyId; try { $p = (array) ($obj->params ?? []); + // Use explicit source iid if present. + $sourceIid = (int) ($p['iid'] ?? $legacyId); + + // If already restored earlier (even in another call), don't create again. + $alreadyDst = (int) (($obj->destination_id ?? -1) ?: -1); + if ($alreadyDst > 0) { + $pub = $em->find(CStudentPublication::class, $alreadyDst); + if ($pub instanceof CStudentPublication) { + $ensureSessionOnlyLink($pub); + } + + $this->dlog('restore_works: reused already restored item', [ + 'src_id' => $legacyId, + 'src_iid' => $sourceIid, + 'dst_iid' => $alreadyDst, + ]); + continue; + } + + // Dedupe within this call (in case build produced duplicates). + if (isset($mappedBySource[$sourceIid])) { + $this->course->resources[RESOURCE_WORK][$legacyId] ??= new \stdClass(); + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $mappedBySource[$sourceIid]; + + $this->dlog('restore_works: duplicate source entry ignored', [ + 'src_id' => $legacyId, + 'src_iid' => $sourceIid, + 'dst_iid' => (int) $mappedBySource[$sourceIid], + ]); + continue; + } + $title = trim((string) ($p['title'] ?? 'Work')); if ('' === $title) { $title = 'Work'; } $baseTitle = $title; - $description = (string) ($p['description'] ?? ''); - $description = $this->rewriteHtmlForCourse($description, (int) $sessionId, '[work.description]'); + $rawDescription = (string) ($p['description'] ?? ''); + $rewrittenDescription = $this->rewriteHtmlForCourse($rawDescription, (int) $sessionId, '[work.description]'); $enableQualification = filter_var(($p['enable_qualification'] ?? false), FILTER_VALIDATE_BOOLEAN); $addToCalendar = filter_var(($p['add_to_calendar'] ?? false), FILTER_VALIDATE_BOOLEAN); $expiresOn = null; if (!empty($p['expires_on'])) { - try { - $expiresOn = new \DateTime((string) $p['expires_on']); - } catch (\Throwable) { - $expiresOn = null; - } + try { $expiresOn = new \DateTime((string) $p['expires_on']); } catch (\Throwable) { $expiresOn = null; } } $endsOn = null; if (!empty($p['ends_on'])) { - try { - $endsOn = new \DateTime((string) $p['ends_on']); - } catch (\Throwable) { - $endsOn = null; - } + try { $endsOn = new \DateTime((string) $p['ends_on']); } catch (\Throwable) { $endsOn = null; } } if ($expiresOn && $endsOn && $endsOn < $expiresOn) { @@ -6330,22 +6437,29 @@ public function restore_works(int $sessionId = 0): void $groupCategoryWorkId = isset($p['group_category_work_id']) ? (int) $p['group_category_work_id'] : 0; $postGroupId = isset($p['post_group_id']) ? (int) $p['post_group_id'] : 0; - // Find existing root folder with same title (course + session scope) - $existing = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $title, null, 'folder') - ->andWhere('resource.publicationParent IS NULL') - ->andWhere('resource.active IN (0,1)') - ->setMaxResults(1) - ->getQuery() - ->getOneOrNullResult(); + $creatorId = (int) ($p['user_id'] ?? 0); + if ($creatorId <= 0) { + $creatorId = (int) api_get_user_id(); + } + $creatorRef = $em->getReference(\Chamilo\CoreBundle\Entity\User::class, $creatorId); + + // Strict exists check: session-only must NOT match base (session NULL). + $existing = $findExistingStrict($title); if ($existing) { if ($filePolicy === $FILE_SKIP) { + $dstIid = (int) $existing->getIid(); + $mappedBySource[$sourceIid] = $dstIid; + $this->course->resources[RESOURCE_WORK][$legacyId] ??= new \stdClass(); - $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid(); + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = $dstIid; + + $ensureSessionOnlyLink($existing); $this->dlog('restore_works: skipped (exists)', [ 'src_id' => $legacyId, - 'dst_iid' => (int) $existing->getIid(), + 'src_iid' => $sourceIid, + 'dst_iid' => $dstIid, 'title' => (string) $existing->getTitle(), ]); continue; @@ -6355,22 +6469,18 @@ public function restore_works(int $sessionId = 0): void $n = 1; while (true) { $candidate = $baseTitle.' ('.$n.')'; - - $dup = $pubRepo->findAllByCourse($courseEntity, $sessionEntity, $candidate, null, 'folder') - ->andWhere('resource.publicationParent IS NULL') - ->andWhere('resource.active IN (0,1)') - ->setMaxResults(1) - ->getQuery() - ->getOneOrNullResult(); + $dup = $findExistingStrict($candidate); if (!$dup) { $title = $candidate; break; } + $n++; if ($n > 5000) { $this->dlog('restore_works: rename safeguard triggered, skipping', [ 'src_id' => $legacyId, + 'src_iid' => $sourceIid, 'base_title' => $baseTitle, ]); continue 2; @@ -6383,10 +6493,9 @@ public function restore_works(int $sessionId = 0): void } if (!$existing) { - // Create new work folder $pub = (new CStudentPublication()) ->setTitle($title) - ->setDescription($description) + ->setDescription($rewrittenDescription) ->setFiletype('folder') ->setContainsFile(0) ->setWeight($weight) @@ -6404,7 +6513,6 @@ public function restore_works(int $sessionId = 0): void $pub->addCourseLink($courseEntity, $sessionEntity); $em->persist($pub); - $em->flush(); $assignment = (new CStudentPublicationAssignment()) ->setPublication($pub) @@ -6416,6 +6524,7 @@ public function restore_works(int $sessionId = 0): void $em->persist($assignment); $em->flush(); + $ensureSessionOnlyLink($pub); if ($addToCalendar) { try { $link = $pub->getFirstResourceLink(); @@ -6454,12 +6563,16 @@ public function restore_works(int $sessionId = 0): void } } + $dstIid = (int) $pub->getIid(); + $mappedBySource[$sourceIid] = $dstIid; + $this->course->resources[RESOURCE_WORK][$legacyId] ??= new \stdClass(); - $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $pub->getIid(); + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = $dstIid; $this->dlog('restore_works: created', [ 'src_id' => $legacyId, - 'dst_iid' => (int) $pub->getIid(), + 'src_iid' => $sourceIid, + 'dst_iid' => $dstIid, 'title' => (string) $pub->getTitle(), ]); @@ -6468,7 +6581,7 @@ public function restore_works(int $sessionId = 0): void // Overwrite existing $existing - ->setDescription($this->rewriteHtmlForCourse($description, (int) $sessionId, '[work.description.overwrite]')) + ->setDescription($rewrittenDescription) ->setWeight($weight) ->setQualification($qualification) ->setAllowTextAssignment($allowText) @@ -6479,80 +6592,21 @@ public function restore_works(int $sessionId = 0): void ->setPostGroupId($postGroupId) ; - try { - if (!$existing->getFirstResourceLinkFromCourseSession($courseEntity, $sessionEntity)) { - $existing->setParent($courseEntity); - $existing->addCourseLink($courseEntity, $sessionEntity); - } - } catch (\Throwable) { - // Ignore link issues - } - $em->persist($existing); $em->flush(); - $assignment = $existing->getAssignment(); - if (!$assignment) { - $assignment = (new CStudentPublicationAssignment())->setPublication($existing); - $em->persist($assignment); - } - - $assignment - ->setEnableQualification($enableQualification || $qualification > 0.0) - ->setExpiresOn($expiresOn) - ->setEndsOn($endsOn) - ; - - if (!$addToCalendar) { - $assignment->setEventCalendarId(0); - } - - $em->flush(); - - if ($addToCalendar && $assignment->getEventCalendarId() <= 0) { - try { - $link = $existing->getFirstResourceLink(); - if ($link) { - $eventTitle = sprintf(get_lang('Handing over of task %s'), $existing->getTitle()); - - $content = (string) $existing->getDescription(); - $content = $this->rewriteHtmlForCourse($content, (int) $sessionId, '[work.calendar.overwrite]'); - - $start = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC')); - $end = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC')); - - $event = (new CCalendarEvent()) - ->setTitle($eventTitle) - ->setContent($content) - ->setParent($courseEntity) - ->setCreator($creatorRef) - ->addLink(clone $link) - ->setStartDate($start) - ->setEndDate($end) - ->setColor(CCalendarEvent::COLOR_STUDENT_PUBLICATION) - ; - - $em->persist($event); - $em->flush(); + $ensureSessionOnlyLink($existing); - $assignment->setEventCalendarId((int) $event->getIid()); - $em->flush(); - } - } catch (\Throwable $e) { - $this->dlog('restore_works: calendar failed on overwrite (ignored)', [ - 'src_id' => $legacyId, - 'dst_iid' => (int) $existing->getIid(), - 'err' => $e->getMessage(), - ]); - } - } + $dstIid = (int) $existing->getIid(); + $mappedBySource[$sourceIid] = $dstIid; $this->course->resources[RESOURCE_WORK][$legacyId] ??= new \stdClass(); - $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int) $existing->getIid(); + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = $dstIid; $this->dlog('restore_works: overwritten', [ 'src_id' => $legacyId, - 'dst_iid' => (int) $existing->getIid(), + 'src_iid' => $sourceIid, + 'dst_iid' => $dstIid, 'title' => (string) $existing->getTitle(), ]); } catch (\Throwable $e) { diff --git a/src/CourseBundle/Component/CourseCopy/CourseSelectForm.php b/src/CourseBundle/Component/CourseCopy/CourseSelectForm.php index 7f9f1106cfb..240a65d1158 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseSelectForm.php +++ b/src/CourseBundle/Component/CourseCopy/CourseSelectForm.php @@ -40,11 +40,26 @@ private static function twBtnNeutral(): string return 'inline-flex items-center gap-2 rounded-md border border-gray-30 bg-white px-3 py-1.5 text-sm font-medium text-gray-90 shadow-sm hover:bg-gray-10 focus:outline-none focus:ring-2 focus:ring-primary/20'; } + private static function twBtnPrimary(): string + { + return 'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-semibold shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-primary/20'; + } + + private static function twBtnPrimaryStyle(): string + { + return 'background: rgb(var(--color-primary-base, 37 99 235)); color: rgb(var(--color-primary-button-text, 255 255 255));'; + } + private static function twCheckbox(): string { return 'h-4 w-4 rounded border-gray-30 text-primary focus:ring-primary/20'; } + private static function twItemRow(): string + { + return 'rounded-md border border-gray-20 bg-white px-3 py-2 hover:bg-gray-10'; + } + private static function traceEnabled(): bool { return defined('COURSE_COPY_TRACE_ENABLED') && true === constant('COURSE_COPY_TRACE_ENABLED'); @@ -103,12 +118,10 @@ private static function normalizeDocumentPathString(string $raw): string return '/'; } - // Ensure leading slash if ($s[0] !== '/') { $s = '/'.$s; } - // Remove any repeated "/document" prefixes while (preg_match('#^/document(/|$)#', $s) === 1) { $s = substr($s, strlen('/document')); if ($s === '') { @@ -122,7 +135,6 @@ private static function normalizeDocumentPathString(string $raw): string $s = preg_replace('#/+#', '/', $s) ?: $s; - // Remove "/{host}/{courseCode}/" when it clearly matches $trim = trim($s, '/'); if ($trim === '') { return '/'; @@ -148,7 +160,6 @@ private static function normalizeDocumentPathString(string $raw): string $s = preg_replace('#/+#', '/', $s) ?: $s; - // Always return a rooted path return '/'.ltrim($s, '/'); } @@ -157,13 +168,11 @@ private static function normalizeDocumentPathString(string $raw): string */ private static function extractDocumentNumericId($resource, $fallbackKey): ?int { - // Already numeric key if (is_int($fallbackKey) || (is_string($fallbackKey) && ctype_digit($fallbackKey))) { $id = (int) $fallbackKey; return $id > 0 ? $id : null; } - // Direct properties foreach (['iid', 'id', 'document_id'] as $prop) { if (is_object($resource) && isset($resource->{$prop}) && (is_int($resource->{$prop}) || ctype_digit((string) $resource->{$prop}))) { $id = (int) $resource->{$prop}; @@ -171,7 +180,6 @@ private static function extractDocumentNumericId($resource, $fallbackKey): ?int } } - // Under ->obj if (is_object($resource) && isset($resource->obj)) { $obj = $resource->obj; @@ -216,17 +224,14 @@ private static function normalizeDocumentLabel(Document $doc): string $path = str_replace('\\', '/', $path); $path = preg_replace('#/+#', '/', $path) ?: $path; - // Build a label candidate $label = $path; if ($title !== '') { $p = rtrim($label, '/'); - // If it's a pseudo root bucket, display title as folder if ($p === '' || $p === '/' || $p === '/document' || $p === '/document/document') { $label = '/'.$title; } else { - // Otherwise, append title if not already present if (!self::endsWith($label, '/'.$title) && !self::endsWith($label, $title)) { $label = rtrim($label, '/').'/'.$title; } @@ -238,6 +243,148 @@ private static function normalizeDocumentLabel(Document $doc): string return $label; } + private static function documentDepth(string $normalizedPath): int + { + $trim = trim($normalizedPath, '/'); + if ($trim === '') { + return 0; + } + return substr_count($trim, '/'); + } + + private static function safeStr($s): string + { + return htmlspecialchars((string) $s, ENT_QUOTES, api_get_system_encoding()); + } + + /** + * Extract a best-effort title from generic resource wrappers (stdClass, arrays, etc.). + * This is especially useful for RESOURCE_WORK where the objects can be stdClass with ->params. + */ + private static function extractGenericTitle($resource, $fallbackId = ''): string + { + // params['title'] + if (is_object($resource) && isset($resource->params) && is_array($resource->params) && !empty($resource->params['title'])) { + $t = trim((string) $resource->params['title']); + if ($t !== '') { + return $t; + } + } + + if (is_array($resource) && !empty($resource['params']['title'])) { + $t = trim((string) $resource['params']['title']); + if ($t !== '') { + return $t; + } + } + + // direct common fields + if (is_object($resource)) { + foreach (['title', 'name', 'tool', 'path'] as $prop) { + if (isset($resource->{$prop}) && (string) $resource->{$prop} !== '') { + return trim((string) $resource->{$prop}); + } + } + } + + if (is_array($resource)) { + foreach (['title', 'name', 'tool', 'path'] as $k) { + if (!empty($resource[$k])) { + return trim((string) $resource[$k]); + } + } + } + + // fallback id + $fallbackId = trim((string) $fallbackId); + if ($fallbackId !== '') { + return $fallbackId; + } + + return get_lang('Untitled'); + } + + /** + * Render a consistent label block for the selection list. + */ + private static function renderLabelHtml(int $type, $resource, $id): string + { + // Documents: show basename + parent path in monospace. + if ($type === RESOURCE_DOCUMENT && $resource instanceof Document) { + $full = self::normalizeDocumentLabel($resource); + $full = $full !== '' ? $full : '/'; + + $base = basename($full); + $dir = dirname($full); + + // If it is a folder-like path ending with '/', basename() can be empty. + if ($base === '' || $base === '/' || $base === '.') { + $base = trim($full, '/'); + if ($base === '') { + $base = '/'; + } + $dir = ''; + } else { + if ($dir === '/' || $dir === '.') { + $dir = ''; + } + } + + $depth = self::documentDepth($full); + $pad = min(7, max(0, $depth)) * 14; // px + $padStyle = 'style="padding-left: '.$pad.'px"'; + + $html = '
'; + $html .= ' '.self::safeStr($base).''; + if ($dir !== '') { + $html .= ' '.self::safeStr($dir).''; + } + $html .= '
'; + + return $html; + } + + // Works: stdClass wrapper -> use params['title'] when possible. + if ($type === RESOURCE_WORK) { + $t = self::extractGenericTitle($resource, (string) $id); + + $html = '
'; + $html .= ' '.self::safeStr($t).''; + + // Optional: show deadline hints if present. + $expires = null; + if (is_object($resource) && isset($resource->params) && is_array($resource->params) && !empty($resource->params['expires_on'])) { + $expires = (string) $resource->params['expires_on']; + } elseif (is_array($resource) && !empty($resource['params']['expires_on'])) { + $expires = (string) $resource['params']['expires_on']; + } + + if ($expires) { + $html .= ' '.self::safeStr(get_lang('Deadline')).': '.self::safeStr($expires).''; + } + + $html .= '
'; + + return $html; + } + + // If resource has show(), capture it (it often echoes HTML). + if (is_object($resource) && method_exists($resource, 'show')) { + ob_start(); + $resource->show(); + $out = trim((string) ob_get_clean()); + + if ($out !== '') { + return '
'.$out.'
'; + } + } + + // Generic fallback. + $label = self::extractGenericTitle($resource, (string) $id); + + return '
'.self::safeStr($label).'
'; + } + /** * Try to resolve a document iid/id by course and normalized path. */ @@ -250,7 +397,6 @@ private static function resolveDocumentIdByPath(int $courseRealId, string $norma $p1 = $normalizedPath; $p2 = ltrim($normalizedPath, '/'); - // Try with leading slash, then without $pathsToTry = array_values(array_unique([$p1, $p2])); foreach ($pathsToTry as $p) { @@ -295,7 +441,6 @@ private static function normalizePostedDocumentSelection(array $selection, array $normalized = []; foreach ($selection as $k => $v) { - // Keep numeric keys if (is_int($k) || (is_string($k) && ctype_digit($k))) { $id = (int) $k; if ($id > 0) { @@ -304,7 +449,6 @@ private static function normalizePostedDocumentSelection(array $selection, array continue; } - // Try to resolve path-like keys $rawKey = (string) $k; $path = self::normalizeDocumentPathString($rawKey); $id = self::resolveDocumentIdByPath($courseRealId, $path); @@ -360,12 +504,6 @@ public static function getResourceTitleList() /** * Display the form. - * - * @param array $course - * @param array $hidden_fields hidden fields to add to the form - * @param bool $avoidSerialize the document array will be serialize. - * This is used in the course_copy.php file - * @param bool $avoidCourseInForm */ public static function display_form( $course, @@ -375,13 +513,11 @@ public static function display_form( ) { global $charset; - // These need to be global because parseResources() fills them. global $forum_categories, $forums, $forum_topics; $forum_categories = []; $forums = []; $forum_topics = []; - // Cache course metadata for document normalization self::$docCourseCode = isset($course->code) ? (string) $course->code : ''; self::$docCourseRealId = 0; if (isset($course->info) && is_array($course->info) && isset($course->info['real_id'])) { @@ -516,7 +652,6 @@ function check_category(obj) { var catCheckbox = document.getElementById('resource_Forum_Category_' + my_id); var checked = catCheckbox ? catCheckbox.checked === true : false; - // Forums under this category var forums = document.querySelectorAll('.resource_forum'); for (var i = 0; i < forums.length; i++) { var f = forums[i]; @@ -525,7 +660,6 @@ function check_category(obj) { } } - // Topics under this category var topics = document.querySelectorAll('.resource_topic'); for (var j = 0; j < topics.length; j++) { var t = topics[j]; @@ -558,7 +692,6 @@ function check_topic(obj) { echo '
'; - // Destination course header if (!empty($hidden_fields['destination_course'])) { $sessionTitle = !empty($hidden_fields['destination_session']) ? ' ('.api_get_session_name($hidden_fields['destination_session']).')' @@ -587,7 +720,6 @@ function check_topic(obj) { echo '
'; echo ''; - // Hidden fields (single pass, avoid duplicates) if (is_array($hidden_fields)) { foreach ($hidden_fields as $key => $value) { echo ''; @@ -604,10 +736,9 @@ function check_topic(obj) { 'avoidSerialize' => (bool) $avoidSerialize, ]); - /** - * Forums special ordering (kept for legacy behavior). - * parseResources() fills global arrays ($forum_categories/$forums/$forum_topics). - */ + // Forums special ordering (kept for legacy behavior). + global $forum_categories, $forums, $forum_topics; + if (!empty($forum_categories)) { $type = RESOURCE_FORUMCATEGORY; @@ -715,10 +846,6 @@ class="resource_topic '.self::twCheckbox().'" } if ($avoidSerialize) { - /** - * Documents are avoided due to memory usage when serializing huge folder trees. - * Known limitation of PHP serialize on very large arrays. - */ if (isset($course->resources) && is_array($course->resources)) { $course->resources[RESOURCE_DOCUMENT] = null; self::trace('UI: documents bucket nulled before serializing course snapshot.'); @@ -726,7 +853,6 @@ class="resource_topic '.self::twCheckbox().'" } if (false === $avoidCourseInForm) { - // Course class is expected to exist in this namespace in legacy CourseCopy. $courseSerialized = base64_encode(Course::serialize($course)); echo ''; } @@ -736,13 +862,14 @@ class="resource_topic '.self::twCheckbox().'" echo '
'.Display::return_message(get_lang('No data available'), 'warning').'
'; } else { $confirm = addslashes(api_htmlentities(get_lang('Please confirm your choice'), ENT_QUOTES)); + echo '
'; - $btnStyle = 'background: rgb(var(--color-primary-base)); color: rgb(var(--color-primary-button-text));'; + if (!empty($hidden_fields['destination_session'])) { echo ''; @@ -750,15 +877,15 @@ class="inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-semibold if ($recycleOption) { echo ''; } else { echo ''; @@ -770,10 +897,11 @@ class="inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-semibold self::display_hidden_quiz_questions($course); self::display_hidden_scorm_directories($course); + echo ''; echo '
'; echo '
'; - echo '
'; // space-y-4 wrapper + echo '
'; } /** @@ -817,7 +945,6 @@ public static function parseResources( case RESOURCE_FORUM: foreach ($resources as $id => $resource) { - // Guard against missing obj/category (avoid notices) $catId = null; if (is_object($resource) && isset($resource->obj) && is_object($resource->obj) && isset($resource->obj->forum_category)) { $catId = $resource->obj->forum_category; @@ -857,20 +984,22 @@ public static function parseResources( default: $typeDom = $domKey($type); + if ($showHeader) { $title = isset($resource_titles[$type]) ? $resource_titles[$type] : (string) $type; + $count = is_array($resources) ? count($resources) : 0; echo '
'; echo '
'; echo '
'; echo ' '; echo ' '.$title.''; + echo ' '.$count.''; echo '
'; echo '
'; echo ' '; // card + echo '
'; + echo ''; - // Keep legacy behavior: auto-toggle using exp() with raw type key. echo ''; } @@ -982,9 +1082,6 @@ class="'.self::twCheckbox().'" return $element_count; } - /** - * @param $course - */ public static function display_hidden_quiz_questions($course) { if (!isset($course->resources) || !is_array($course->resources)) { @@ -1007,9 +1104,6 @@ public static function display_hidden_quiz_questions($course) } } - /** - * @param $course - */ public static function display_hidden_scorm_directories($course) { if (!isset($course->resources) || !is_array($course->resources)) { @@ -1034,13 +1128,6 @@ public static function display_hidden_scorm_directories($course) /** * Get the posted course (selected resources only). - * - * @param string $from - * @param int $session_id - * @param string $course_code - * @param Course $postedCourse - * - * @return Course|false */ public static function get_posted_course( $from = '', @@ -1065,20 +1152,16 @@ public static function get_posted_course( return false; } - // Trace selection counts early $selCounts = []; foreach ($postResource as $t => $ids) { $selCounts[$t] = is_array($ids) ? count($ids) : 0; } self::trace('get_posted_course(): selection counts.', $selCounts); - // CRITICAL FIX: normalize document selection keys to numeric ids if (isset($postResource[RESOURCE_DOCUMENT]) && is_array($postResource[RESOURCE_DOCUMENT]) && !empty($postResource[RESOURCE_DOCUMENT])) { $beforeKeys = array_slice(array_keys($postResource[RESOURCE_DOCUMENT]), 0, 10); $postResource[RESOURCE_DOCUMENT] = self::normalizePostedDocumentSelection($postResource[RESOURCE_DOCUMENT], $course_info); - - // Re-write $_POST too, because later filters rely on $_POST selection $_POST['resource'][RESOURCE_DOCUMENT] = $postResource[RESOURCE_DOCUMENT]; $afterKeys = array_slice(array_keys($postResource[RESOURCE_DOCUMENT]), 0, 10); @@ -1090,7 +1173,6 @@ public static function get_posted_course( ]); } - // Build only what the user selected (types are keys of the selection map). $typesToExport = array_keys($postResource); self::trace('get_posted_course(): start build.', [ @@ -1104,7 +1186,7 @@ public static function get_posted_course( if (array_key_exists('copy_only_session_items', $_POST)) { $copyOnlySessionItemsRaw = $_POST['copy_only_session_items'] ?? ''; $copyOnlySessionItems = in_array((string) $copyOnlySessionItemsRaw, ['1', 'on', 'true'], true); - // If copy_only_session_items is enabled => do NOT include base content + $withBaseContent = !$copyOnlySessionItems; self::trace('get_posted_course(): base content flag resolved.', [ @@ -1119,7 +1201,6 @@ public static function get_posted_course( if (empty($course) || !isset($course->resources) || !is_array($course->resources)) { self::trace('get_posted_course(): builder returned empty course/resources.'); - // Fallback to postedCourse if available (legacy safety) if ($postedCourse instanceof Course && isset($postedCourse->resources) && is_array($postedCourse->resources)) { self::trace('get_posted_course(): falling back to postedCourse snapshot.'); $course = $postedCourse; @@ -1128,7 +1209,6 @@ public static function get_posted_course( } } - // Trace resource counts by type $counts = []; foreach ($course->resources as $t => $list) { $counts[$t] = is_array($list) ? count($list) : 0; @@ -1314,7 +1394,7 @@ function checkLearnPath(message) { $btnStyle = 'background: rgb(var(--color-primary-base)); color: rgb(var(--color-primary-button-text));'; echo '
'; - echo '