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 '
'; // 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 '