diff --git a/assets/css/scss/_course_maintenance.scss b/assets/css/scss/_course_maintenance.scss new file mode 100644 index 00000000000..604e4f895dd --- /dev/null +++ b/assets/css/scss/_course_maintenance.scss @@ -0,0 +1,40 @@ + +.cm-root { width: 100%; } +:host, .cm-root { overflow-x: hidden; } +.cm-layout { + .cm-tab { + @apply inline-flex items-center rounded-lg px-3 py-2 text-gray-90 + hover:bg-gray-15 + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-25; + } + .cm-tab__icon { @apply text-base mr-2; } + .cm-tab--active { + @apply ring-1 font-semibold; + color: rgb(var(--color-primary-base)); + border-color: rgb(var(--color-primary-base)); + background-color: color-mix(in oklab, rgb(var(--color-primary-base)) 14%, #fff); + } + .cm-tab--active .cm-tab__icon { + color: rgb(var(--color-primary-base)); + } + .cm-tab--danger { @apply text-danger; } + .cm-tab--danger:hover { @apply bg-support-6; } + .cm-tab--danger.cm-tab--active { + color: rgb(var(--color-danger-base)); + border-color: rgb(var(--color-danger-base)); + background-color: color-mix(in oklab, rgb(var(--color-danger-base)) 14%, #fff); + } + .btn-primary{ + @apply inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed; + } + .btn-secondary{ + @apply inline-flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-gray-90 ring-1 ring-gray-25 hover:bg-gray-15 disabled:opacity-50 disabled:cursor-not-allowed; + } + .btn-danger{ + @apply inline-flex items-center gap-2 rounded-md bg-danger px-4 py-2 text-sm font-medium text-white hover:bg-danger/90 disabled:opacity-50 disabled:cursor-not-allowed; + } + .chk-success { accent-color: rgb(var(--color-success-base)); } + .opacity-60:hover { opacity: .85; } + .link {color: #0ea5e9;text-decoration: none;cursor: pointer;} + .link:hover {text-decoration: underline;} +} diff --git a/assets/css/scss/index.scss b/assets/css/scss/index.scss index fce4211a263..7bd45e7d6bf 100755 --- a/assets/css/scss/index.scss +++ b/assets/css/scss/index.scss @@ -102,5 +102,6 @@ @include meta.load-css('survey'); @include meta.load-css('chat'); @include meta.load-css('blog'); +@include meta.load-css('course_maintenance'); @include meta.load-css("libs/mediaelementjs/styles"); diff --git a/assets/vue/components/coursemaintenance/CourseMaintenanceLayout.vue b/assets/vue/components/coursemaintenance/CourseMaintenanceLayout.vue new file mode 100644 index 00000000000..4eb7c875488 --- /dev/null +++ b/assets/vue/components/coursemaintenance/CourseMaintenanceLayout.vue @@ -0,0 +1,131 @@ + + + diff --git a/assets/vue/components/coursemaintenance/ResourceSelector.vue b/assets/vue/components/coursemaintenance/ResourceSelector.vue new file mode 100644 index 00000000000..ed43867f5e0 --- /dev/null +++ b/assets/vue/components/coursemaintenance/ResourceSelector.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/assets/vue/composables/coursemaintenance/useResourceSelection.js b/assets/vue/composables/coursemaintenance/useResourceSelection.js new file mode 100644 index 00000000000..7effbac6ebf --- /dev/null +++ b/assets/vue/composables/coursemaintenance/useResourceSelection.js @@ -0,0 +1,167 @@ +import { ref, computed } from "vue" + +/** Normalize, filter and manage a resource-selection tree */ +export default function useResourceSelection() { + // state + const tree = ref([]) // groups from backend + const selections = ref({}) // { [type]: { [id]: 1 } } + const query = ref("") + const forceOpen = ref(null) // true/false or null (release) + + // --- helpers --------------------------------------------------------------- + const singular = (t) => + ({ + documents: "document", + links: "link", + announcements: "announcement", + events: "event", + forums: "forum", + forum_category: "forum_category", + thread: "thread", + post: "post", + course_descriptions: "course_description", + quizzes: "quiz", + survey: "survey", + surveys: "survey", + learnpaths: "learnpath", + scorm_documents: "document", + tool_intro: "tool_intro", + })[t] || (t ? t.replace(/s$/, "") : "document") + + const getKids = (n) => (Array.isArray(n?.children) ? n.children : Array.isArray(n?.items) ? n.items : []) + + const resolveId = (node) => { + const raw = node?.id ?? node?.iid ?? node?.uuid ?? node?.source_id ?? null + if (raw == null) return null + const n = Number(raw) + return Number.isFinite(n) ? n : raw + } + + /** Ensure each leaf has {id,type,selectable}, and move items -> children */ + function normalizeTreeForSelection(groups) { + const walk = (node, itemTypeFromParent) => { + if (!node || typeof node !== "object") return + const kids = getKids(node) + const ownType = node.type || node.titleType || null + const nextItemType = kids.length ? singular(ownType || node.itemType || "documents") : itemTypeFromParent + + if (!kids.length) { + const id = resolveId(node) + if (id != null) node.id = id + if (!node.type) node.type = nextItemType || "document" + if (typeof node.selectable === "undefined") node.selectable = true + } else { + for (const child of kids) walk(child, singular(ownType || nextItemType || "documents")) + // Unify child container + if (!Array.isArray(node.children) && Array.isArray(node.items)) node.children = node.items + } + } + ;(groups || []).forEach((g) => walk(g, null)) + return groups + } + + const isNodeCheckable = (n) => !!(n && n.id != null && n.selectable !== false) + const isChecked = (n) => !!(n && isNodeCheckable(n) && selections.value?.[n.type]?.[n.id]) + + // --- SELECTION: bulk apply + bump ----------------------------------------- + function markOne(n, checked) { + if (!selections.value[n.type]) selections.value[n.type] = {} + if (checked) selections.value[n.type][n.id] = 1 + else delete selections.value[n.type][n.id] + } + + function applyDeep(n, checked) { + if (!n) return + if (isNodeCheckable(n)) markOne(n, checked) + const kids = getKids(n) + for (const c of kids) applyDeep(c, checked) + } + + /** Re-assign to force render (immutable update) */ + function bump() { + selections.value = { ...selections.value } + } + + /** Toggle a node (and its descendants) with a single bump */ + function toggleNode(n, checked) { + applyDeep(n, checked) + bump() + } + + /** Select/Deselect everything: iterate groups and do a single bump */ + function checkAll(all) { + for (const g of tree.value) applyDeep(g, all) + bump() + } + + /** Expand/Collapse all */ + function expandAll(v) { + forceOpen.value = v + } + + // --- search/filter --------------------------------------------------------- + function matchNode(node, q) { + if (!q) return true + const t = (node.label || node.title || "").toLowerCase() + const p = (node.meta || "").toLowerCase() + const s = q.toLowerCase() + return t.includes(s) || p.includes(s) + } + function deepFilterNode(node, q) { + if (matchNode(node, q)) return node + const kids = getKids(node) + .map((c) => deepFilterNode(c, q)) + .filter(Boolean) + if (kids.length > 0) return { ...node, children: kids } + return null + } + function deepFilterGroup(group, q) { + if (!q) return group + const kids = getKids(group) + .map((c) => deepFilterNode(c, q)) + .filter(Boolean) + if (kids.length > 0) return { ...group, children: kids } + return null + } + const filteredGroups = computed(() => { + const q = query.value.trim() + if (!q) return tree.value + return tree.value.map((g) => deepFilterGroup(g, q)).filter(Boolean) + }) + + const selectedTotal = computed(() => + Object.values(selections.value).reduce((a, g) => a + Object.keys(g || {}).length, 0), + ) + function countSelected(group) { + const map = selections.value || {} + const pool = getKids(group) + let n = 0 + for (const c of pool) n += countSelectedRecursive(c, map) + return n + } + function countSelectedRecursive(n, map) { + let x = isNodeCheckable(n) && map?.[n.type]?.[n.id] ? 1 : 0 + for (const c of getKids(n)) x += countSelectedRecursive(c, map) + return x + } + + return { + // state + tree, + selections, + query, + forceOpen, + // normalize + normalizeTreeForSelection, + // filters + ui + filteredGroups, + selectedTotal, + countSelected, + // actions + isNodeCheckable, + isChecked, + toggleNode, + checkAll, + expandAll, + } +} diff --git a/assets/vue/router/coursemaintenance.js b/assets/vue/router/coursemaintenance.js new file mode 100644 index 00000000000..0c63b3bace1 --- /dev/null +++ b/assets/vue/router/coursemaintenance.js @@ -0,0 +1,45 @@ +export default { + path: "/resources/course_maintenance/:node(\\d+)", + meta: { requiresAuth: true, showBreadcrumb: true }, + name: "course_maintenance", + component: () => import("../components/coursemaintenance/CourseMaintenanceLayout.vue"), + redirect: (to) => ({ name: "CMImportBackup", params: to.params, query: to.query }), + children: [ + { + name: "CMImportBackup", + path: "import", + component: () => import("../views/coursemaintenance/ImportBackup.vue"), + meta: { breadcrumb: "Importar backup" }, + }, + { + name: "CMCreateBackup", + path: "create", + component: () => import("../views/coursemaintenance/CreateBackup.vue"), + meta: { breadcrumb: "Crear backup" }, + }, + { + name: "CMCopyCourse", + path: "copy", + component: () => import("../views/coursemaintenance/CopyCourse.vue"), + meta: { breadcrumb: "Copiar curso" }, + }, + { + name: "CMCc13", + path: "cc13", + component: () => import("../views/coursemaintenance/Cc13.vue"), + meta: { breadcrumb: "IMS CC 1.3" }, + }, + { + name: "CMRecycle", + path: "recycle", + component: () => import("../views/coursemaintenance/RecycleCourse.vue"), + meta: { breadcrumb: "Reciclar curso" }, + }, + { + name: "CMDelete", + path: "delete", + component: () => import("../views/coursemaintenance/DeleteCourse.vue"), + meta: { breadcrumb: "Eliminar curso" }, + }, + ], +} diff --git a/assets/vue/router/index.js b/assets/vue/router/index.js index adbbceb38ff..b89fc3e339e 100644 --- a/assets/vue/router/index.js +++ b/assets/vue/router/index.js @@ -27,6 +27,7 @@ import lpRoutes from "./lp" import dropboxRoutes from "./dropbox" import blogRoutes from "./blog" import blogAdminRoute from "./blogAdmin" +import courseMaintenanceRoute from "./coursemaintenance" import catalogue from "./catalogue" import { useSecurityStore } from "../store/securityStore" import MyCourseList from "../views/user/courses/List.vue" @@ -244,6 +245,7 @@ const router = createRouter({ dropboxRoutes, blogRoutes, blogAdminRoute, + courseMaintenanceRoute, accountRoutes, personalFileRoutes, messageRoutes, @@ -267,7 +269,7 @@ router.beforeEach(async (to, from, next) => { sessionStorage.clear() } - const preservedParams = ['origin', 'isStudentView'] + const preservedParams = ["origin", "isStudentView"] const mergedQuery = { ...to.query } let shouldRedirect = false diff --git a/assets/vue/services/courseMaintenance.js b/assets/vue/services/courseMaintenance.js new file mode 100644 index 00000000000..da379085d09 --- /dev/null +++ b/assets/vue/services/courseMaintenance.js @@ -0,0 +1,306 @@ +import axios from "axios" +import { useCidReqStore } from "../store/cidReq" + +// If the frontend is served from the same origin, this keeps requests relative. +const http = axios.create({ + baseURL: "/", // same-origin root +}) + +/** Read current course/session/group context from Pinia store with graceful fallbacks */ +function courseContextParams() { + const store = useCidReqStore() + + const fromStore = { + cid: Number(store?.course?.id) || null, + sid: Number(store?.session?.id) || null, + gid: Number(store?.group?.id) || null, + } + + // Fallback to querystring (useful when reloading or opening deep links) + const qs = new URLSearchParams(window.location.search) + const pickNum = (...names) => { + for (const n of names) { + const v = qs.get(n) + if (v !== null && v !== undefined && !Number.isNaN(Number(v))) return Number(v) + } + return null + } + + return { + ...(fromStore.cid ? { cid: fromStore.cid } : {}), + ...(fromStore.sid ? { sid: fromStore.sid } : {}), + ...(fromStore.gid ? { gid: fromStore.gid } : {}), + ...(fromStore.cid ? {} : pickNum("cid", "cidReq") ? { cid: pickNum("cid", "cidReq") } : {}), + ...(fromStore.sid ? {} : pickNum("sid", "id_session") ? { sid: pickNum("sid", "id_session") } : {}), + ...(fromStore.gid ? {} : pickNum("gid", "gidReq") ? { gid: pickNum("gid", "gidReq") } : {}), + } +} + +/** Merge current context and extra params (also preserve gradebook/origin from URL if present) */ +function withCourseParams(params = {}) { + const merged = { ...courseContextParams(), ...params } + const qs = new URLSearchParams(window.location.search) + for (const k of ["gradebook", "origin"]) { + if (qs.has(k) && merged[k] === undefined) merged[k] = qs.get(k) + } + return merged +} + +/** Extract a numeric or string ID from an API Platform item or IRI-looking string */ +function extractId(item) { + if (!item) return null + if (item.iid) return item.iid + if (item.id) return item.id + const iriVal = item["@id"] || (typeof item === "string" ? item : null) + if (typeof iriVal === "string") { + const segs = iriVal.split("/") + const last = segs.pop() || segs.pop() + return Number(last) || last + } + return null +} + +/** Returns a relative IRI like "/resource/1" if you ever need it (kept for compatibility) */ +function iri(resource, id) { + const res = String(resource).replace(/^\//, "") + return `/${res}/${id}` +} + +/** Resolve :node from the current route path (or ?node, or window.chamilo) */ +function resolveNodeFromPath() { + // /resources/course_maintenance/{node}/... + let m = window.location.pathname.match(/\/resources\/course_maintenance\/(\d+)/) + if (m && m[1]) { + const v = Number(m[1]) + if (!Number.isNaN(v) && v > 0) return v + } + // /course_maintenance/{node}/... + m = window.location.pathname.match(/\/course_maintenance\/(\d+)/) + if (m && m[1]) { + const v = Number(m[1]) + if (!Number.isNaN(v) && v > 0) return v + } + // ?node=... + const qs = new URLSearchParams(window.location.search) + const qv = Number(qs.get("node")) + if (!Number.isNaN(qv) && qv > 0) return qv + // global + const gv = Number(window?.chamilo?.course?.resourceNodeId) + if (!Number.isNaN(gv) && gv > 0) return gv + return null +} + +const base = { + // Import backup + options: (node) => `/course_maintenance/${node}/import/options`, + upload: (node) => `/course_maintenance/${node}/import/upload`, + serverPick: (node) => `/course_maintenance/${node}/import/server`, + resources: (node, backupId) => `/course_maintenance/${node}/import/${backupId}/resources`, + restore: (node, backupId) => `/course_maintenance/${node}/import/${backupId}/restore`, + + // Copy/backup/otros + createBackup: (node) => `/course_maintenance/${node}/backup`, + + // NEW: Modern Copy Course UI + copyOptions: (node) => `/course_maintenance/${node}/copy/options`, + copyResources: (node) => `/course_maintenance/${node}/copy/resources`, + copyExecute: (node) => `/course_maintenance/${node}/copy/execute`, + + // Export Moodle (.mbz) + moodleExportOptions: (node) => `/course_maintenance/${node}/moodle/export/options`, + moodleExportResources:(node) => `/course_maintenance/${node}/moodle/export/resources`, + moodleExportExecute: (node) => `/course_maintenance/${node}/moodle/export/execute`, + + recycleCourse: (node) => `/course_maintenance/${node}/recycle`, + recycleOptions: (node) => `/course_maintenance/${node}/recycle/options`, + recycleResources: (node) => `/course_maintenance/${node}/recycle/resources`, + recycleExecute: (node) => `/course_maintenance/${node}/recycle/execute`, + + deleteCourse: (node) => `/course_maintenance/${node}/delete`, + + cc13ExportOptions: (node) => `/course_maintenance/${node}/cc13/export/options`, + cc13ExportResources: (node) => `/course_maintenance/${node}/cc13/export/resources`, + cc13ExportExecute: (node) => `/course_maintenance/${node}/cc13/export/execute`, + cc13Import: (node) => `/course_maintenance/${node}/cc13/import`, +} + +/* ========================= + Import + ========================= */ +async function getOptions(node = resolveNodeFromPath()) { + const resp = await http.get(base.options(node), { params: withCourseParams() }) + return resp.data +} +async function uploadFile(node = resolveNodeFromPath(), file) { + const fd = new FormData() + fd.append("file", file, file.name || "backup.zip") + const resp = await http.post(base.upload(node), fd, { + headers: { "Content-Type": "multipart/form-data" }, + params: withCourseParams(), + }) + return resp.data +} +async function chooseServerFile(node = resolveNodeFromPath(), filename) { + const resp = await http.post(base.serverPick(node), { filename }, { params: withCourseParams() }) + return resp.data +} +async function fetchResources(node = resolveNodeFromPath(), backupId) { + const resp = await http.get(base.resources(node, backupId), { params: withCourseParams() }) + return resp.data +} +async function restoreBackup(node = resolveNodeFromPath(), backupId, { importOption, sameFileNameOption, resources }) { + const payload = { importOption, sameFileNameOption } + if (importOption === "select_items") payload.resources = resources || {} + const resp = await http.post(base.restore(node, backupId), payload, { params: withCourseParams() }) + return resp.data +} + +/* ========================= + Copy course + ========================= */ +/** Return courses list (excluding current) and defaults */ +async function getCopyOptions(node = resolveNodeFromPath()) { + const resp = await http.get(base.copyOptions(node), { + params: withCourseParams(), + }) + return resp.data +} + +/** Return resource tree of the source course, same shape as import/resources */ +async function fetchCopyResources(node = resolveNodeFromPath(), sourceCourseId) { + const resp = await http.get(base.copyResources(node), { + params: withCourseParams({ sourceCourseId }), + }) + return resp.data +} + +/** Execute course copy into current course */ +async function copyFromCourse(node = resolveNodeFromPath(), payload) { + const resp = await http.post(base.copyExecute(node), payload, { + params: withCourseParams(), + }) + return resp.data +} + +/* ========================= + Recycle course + ========================= */ +async function getRecycleOptions(node) { + const r = await http.get(base.recycleOptions(node), { params: withCourseParams() }) + return r.data +} +async function fetchRecycleResources(node) { + const r = await http.get(base.recycleResources(node), { params: withCourseParams() }) + return r.data +} +async function recycleExecute(node, payload) { + const r = await http.post(base.recycleExecute(node), payload, { params: withCourseParams() }) + return r.data +} + +/* ========================= + Other endpoints + ========================= */ +async function createBackup(node = resolveNodeFromPath(), scope = "full") { + const resp = await http.post(base.createBackup(node), { scope }, { params: withCourseParams() }) + return resp.data +} + +/** Compatibility: prior version that POSTed raw payload to /copy */ +async function copyCourse(node = resolveNodeFromPath(), payload) { + const resp = await http.post(`/course_maintenance/${node}/copy`, payload, { params: withCourseParams() }) + return resp.data +} + +async function recycleCourse(node = resolveNodeFromPath(), payload) { + const resp = await http.post(base.recycleCourse(node), payload, { params: withCourseParams() }) + return resp.data +} +async function deleteCourse(node = resolveNodeFromPath(), confirmText) { + const resp = await http.post(base.deleteCourse(node), { confirm: confirmText }, { params: withCourseParams() }) + return resp.data +} + +// -------- Moodle export -------- +async function moodleExportOptions(node = resolveNodeFromPath()) { + const resp = await http.get(base.moodleExportOptions(node), { params: withCourseParams() }) + return resp.data +} + +async function moodleExportResources(node = resolveNodeFromPath()) { + const resp = await http.get(base.moodleExportResources(node), { params: withCourseParams() }) + return resp.data +} + +async function moodleExportExecute(node = resolveNodeFromPath(), payload) { + const resp = await http.post(base.moodleExportExecute(node), payload, { params: withCourseParams() }) + return resp.data +} + +// CC 1.3 export +async function cc13ExportOptions(node = resolveNodeFromPath()) { + const resp = await http.get(base.cc13ExportOptions(node), { params: withCourseParams() }) + return resp.data +} +async function cc13ExportResources(node = resolveNodeFromPath()) { + const resp = await http.get(base.cc13ExportResources(node), { params: withCourseParams() }) + return resp.data +} +async function cc13ExportExecute(node = resolveNodeFromPath(), payload) { + const resp = await http.post(base.cc13ExportExecute(node), payload, { params: withCourseParams() }) + return resp.data +} + +// CC 1.3 import +async function cc13Import(node = resolveNodeFromPath(), fileOrOptions) { + if (typeof File !== "undefined" && fileOrOptions instanceof File) { + const fd = new FormData() + fd.append("file", fileOrOptions, fileOrOptions.name) + const resp = await http.post(base.cc13Import(node), fd, { + headers: { "Content-Type": "multipart/form-data" }, + params: withCourseParams(), + }) + return resp.data + } + const resp = await http.post(base.cc13Import(node), fileOrOptions || {}, { params: withCourseParams() }) + return resp.data +} + +/* ========================= + Export + ========================= */ +export { withCourseParams, courseContextParams, extractId, iri, resolveNodeFromPath } + +export default { + // Import + getOptions, + uploadFile, + chooseServerFile, + fetchResources, + restoreBackup, + + // Copy + getCopyOptions, + fetchCopyResources, + copyFromCourse, + copyCourse, + + // Recycle + getRecycleOptions, + fetchRecycleResources, + recycleExecute, + + // Others + createBackup, + recycleCourse, + deleteCourse, + + moodleExportOptions, + moodleExportResources, + moodleExportExecute, + + cc13ExportOptions, + cc13ExportResources, + cc13ExportExecute, + cc13Import, +} diff --git a/assets/vue/views/coursemaintenance/Cc13.vue b/assets/vue/views/coursemaintenance/Cc13.vue new file mode 100644 index 00000000000..1857a8f3b3d --- /dev/null +++ b/assets/vue/views/coursemaintenance/Cc13.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/assets/vue/views/coursemaintenance/CopyCourse.vue b/assets/vue/views/coursemaintenance/CopyCourse.vue new file mode 100644 index 00000000000..d247985bbd0 --- /dev/null +++ b/assets/vue/views/coursemaintenance/CopyCourse.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/assets/vue/views/coursemaintenance/CreateBackup.vue b/assets/vue/views/coursemaintenance/CreateBackup.vue new file mode 100644 index 00000000000..27ba7984a97 --- /dev/null +++ b/assets/vue/views/coursemaintenance/CreateBackup.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/assets/vue/views/coursemaintenance/DeleteCourse.vue b/assets/vue/views/coursemaintenance/DeleteCourse.vue new file mode 100644 index 00000000000..3e433826d12 --- /dev/null +++ b/assets/vue/views/coursemaintenance/DeleteCourse.vue @@ -0,0 +1,76 @@ + + + diff --git a/assets/vue/views/coursemaintenance/ImportBackup.vue b/assets/vue/views/coursemaintenance/ImportBackup.vue new file mode 100644 index 00000000000..1fc0ca214e5 --- /dev/null +++ b/assets/vue/views/coursemaintenance/ImportBackup.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/assets/vue/views/coursemaintenance/RecycleCourse.vue b/assets/vue/views/coursemaintenance/RecycleCourse.vue new file mode 100644 index 00000000000..200c63580b2 --- /dev/null +++ b/assets/vue/views/coursemaintenance/RecycleCourse.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/public/main/lp/scorm.class.php b/public/main/lp/scorm.class.php index 7912b6c9b99..4574e7e0063 100644 --- a/public/main/lp/scorm.class.php +++ b/public/main/lp/scorm.class.php @@ -454,14 +454,12 @@ public function import_local_package($file_path, $currentDir = '') /** * Imports a zip file into the Chamilo structure. * - * @param string $zipFileInfo Zip file info as given by $_FILES['userFile'] - * @param string $currentDir - * @param array $courseInfo - * @param bool $updateDirContents - * @param learnpath $lpToCheck - * @param bool $allowHtaccess + * If $currentDir is provided (absolute path or repository-relative folder that + * actually contains imsmanifest.xml), it will be used to set $this->current_dir. + * Otherwise, we derive $this->current_dir from AssetRepository::getFolder($asset) + * plus the top-level dir and the manifest subfolder. * - * @return string $current_dir Absolute path to the imsmanifest.xml file or empty string on error + * @return Asset|false */ public function import_package( $zipFileInfo, @@ -473,33 +471,33 @@ public function import_package( ) { $this->debug = 100; if ($this->debug) { - error_log( - 'In scorm::import_package('.print_r($zipFileInfo, true).',"'.$currentDir.'") method' - ); + error_log('In scorm::import_package('.print_r($zipFileInfo, true).',"'.$currentDir.'") method'); } $zipFilePath = $zipFileInfo['tmp_name']; $zipFileName = $zipFileInfo['name']; - $currentDir = api_replace_dangerous_char(trim($currentDir)); // Current dir we are in, inside scorm/ + $currentDir = str_replace('\\', '/', trim((string)$currentDir)); + $currentDir = preg_replace('#/{2,}#', '/', $currentDir); + $currentDir = rtrim($currentDir, '/'); if ($this->debug > 1) { error_log('import_package() - current_dir = '.$currentDir, 0); } - // Get name of the zip file without the extension. - $fileInfo = pathinfo($zipFileName); - $filename = $fileInfo['basename']; - $extension = $fileInfo['extension']; - $fileBaseName = str_replace('.'.$extension, '', $filename); // Filename without its extension. - $this->zipname = $fileBaseName; // Save for later in case we don't have a title. + $fileInfo = pathinfo($zipFileName); + $filename = $fileInfo['basename'] ?? $zipFileName; + $extension = $fileInfo['extension'] ?? ''; + $fileBaseName = $extension !== '' ? str_replace('.'.$extension, '', $filename) : $filename; + $this->zipname = $fileBaseName; $newDir = api_replace_dangerous_char(trim($fileBaseName)); $this->subdir = $newDir; + if ($this->debug) { error_log('$zipFileName: '.$zipFileName); error_log('Received zip file name: '.$zipFilePath); - error_log("subdir is first set to : ".$this->subdir); - error_log("base file name is : ".$fileBaseName); + error_log('subdir is first set to : '.$this->subdir); + error_log('base file name is : '.$fileBaseName); } $zipFile = new ZipFile(); @@ -516,35 +514,30 @@ public function import_package( $file = $fileName; $this->set_error_msg("File $file contains a PHP script"); } elseif (stristr($fileName, 'imsmanifest.xml')) { - if ($fileName == basename($fileName)) { - } else { - if ($this->debug) { - error_log("subdir is now ".$this->subdir); - } - } $packageType = 'scorm'; $manifestList[] = $fileName; } $realFileSize += $size; } - // Now get the shortest path (basically, the imsmanifest that is the closest to the root). - $shortestPath = $manifestList[0]; - $slashCount = substr_count($shortestPath, '/'); - foreach ($manifestList as $manifestPath) { - $tmpSlashCount = substr_count($manifestPath, '/'); - if ($tmpSlashCount < $slashCount) { - $shortestPath = $manifestPath; - $slashCount = $tmpSlashCount; + $shortestPath = $manifestList[0] ?? ''; + if (!empty($manifestList)) { + $slashCount = substr_count($shortestPath, '/'); + foreach ($manifestList as $manifestPath) { + $tmpSlashCount = substr_count($manifestPath, '/'); + if ($tmpSlashCount < $slashCount) { + $shortestPath = $manifestPath; + $slashCount = $tmpSlashCount; + } } } $firstDir = $this->subdir; - $this->subdir .= '/'.dirname($shortestPath); // Do not concatenate because already done above. + $this->subdir .= '/'.dirname($shortestPath); if ($this->debug) { - error_log("subdir is now (2): ".$this->subdir); + error_log('subdir is now (2): '.$this->subdir); } - $this->manifestToString = $zipFile->getEntryContents($shortestPath); + $this->manifestToString = $shortestPath ? $zipFile->getEntryContents($shortestPath) : ''; if ($this->debug) { error_log("Package type is now: '$packageType'"); @@ -558,52 +551,10 @@ public function import_package( return false; } - // Todo check filesize - /*if (!enough_size($realFileSize, $courseSysDir, $maxFilledSpace)) { - if ($this->debug > 1) { - error_log('Not enough space to store package'); - } - Display::addFlash( - Display::return_message( - get_lang( - 'The upload has failed. Either you have exceeded your maximum quota, or there is not enough disk space.' - ) - ) - ); - - return false; - }*/ - - /*if ($updateDirContents && $lpToCheck) { - $originalPath = str_replace('/.', '', $lpToCheck->path); - if ($originalPath != $newDir) { - Display::addFlash(Display::return_message(get_lang('The file to upload is not valid.'))); - - return false; - } - } - - // It happens on Linux that $newDir sometimes doesn't start with '/' - if ('/' !== $newDir[0]) { - $newDir = '/'.$newDir; - } - - if ('/' === $newDir[strlen($newDir) - 1]) { - $newDir = substr($newDir, 0, -1); - }*/ - /* Uncompressing phase */ - /* - We need to process each individual file in the zip archive to - - add it to the database - - parse & change relative html links - - make sure the filenames are secure (filter funny characters or php extensions) - */ - - // 1. Upload zip file $request = Container::getRequest(); $uploadFile = null; - if ($request->files->has('user_file')) { + if ($request && $request->files->has('user_file')) { $uploadFile = $request->files->get('user_file'); } @@ -611,16 +562,42 @@ public function import_package( $asset = (new Asset()) ->setCategory(Asset::SCORM) ->setTitle($zipFileName) - ->setFile($uploadFile) - ->setCompressed(true) - ; - $repo->update($asset); + ->setCompressed(true); + + if ($uploadFile) { + $asset->setFile($uploadFile); + $repo->update($asset); + } else { + $repo->createFromRequest($asset, $zipFileInfo); + } - // 2. Unzip file $repo->unZipFile($asset, $firstDir); $this->asset = $asset; - return $asset; + if (!empty($currentDir) && @is_file($currentDir.'/imsmanifest.xml')) { + $this->current_dir = $currentDir; + if ($this->debug) { + error_log('Using caller-provided current_dir: '.$this->current_dir); + } + return true; + } + + $baseFolder = rtrim((string) $repo->getFolder($asset), '/'); + $manifestDir = dirname($shortestPath); + + $resolved = $baseFolder.'/'.$firstDir; + if ($manifestDir !== '.' && $manifestDir !== DIRECTORY_SEPARATOR) { + $resolved .= '/'.$manifestDir; + } + $resolved = preg_replace('#/{2,}#', '/', str_replace('\\', '/', $resolved)); + $this->current_dir = rtrim($resolved, '/'); + + if ($this->debug) { + error_log('Resolved current_dir: '.$this->current_dir); + error_log('Expected imsmanifest.xml at: '.$this->current_dir.'/imsmanifest.xml'); + } + + return true; } /** diff --git a/src/CoreBundle/Controller/CourseMaintenanceController.php b/src/CoreBundle/Controller/CourseMaintenanceController.php new file mode 100644 index 00000000000..a45f54b02bb --- /dev/null +++ b/src/CoreBundle/Controller/CourseMaintenanceController.php @@ -0,0 +1,1766 @@ + '\d+'] +)] +class CourseMaintenanceController extends AbstractController +{ + /** @var bool Debug flag (true by default). Toggle via ?debug=0|1 or X-Debug: 0|1 */ + private bool $debug = true; + + #[Route('/import/options', name: 'import_options', methods: ['GET'])] + public function importOptions(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + $this->logDebug('[importOptions] called', ['node' => $node, 'debug' => $this->debug]); + + return $this->json([ + 'sources' => ['local', 'server'], + 'importOptions' => ['full_backup', 'select_items'], + 'sameName' => ['skip', 'rename', 'overwrite'], + 'defaults' => [ + 'importOption' => 'full_backup', + 'sameName' => 'rename', + 'sameFileNameOption' => 2, + ], + ]); + } + + #[Route('/import/upload', name: 'import_upload', methods: ['POST'])] + public function importUpload(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + $file = $req->files->get('file'); + if (!$file) { + $this->logDebug('[importUpload] missing file'); + return $this->json(['error' => 'Missing file'], 400); + } + + $this->logDebug('[importUpload] received', [ + 'original_name' => $file->getClientOriginalName(), + 'size' => $file->getSize(), + 'mime' => $file->getClientMimeType(), + ]); + + $backupId = CourseArchiver::importUploadedFile($file->getRealPath()); + if ($backupId === false) { + $this->logDebug('[importUpload] archive dir not writable'); + return $this->json(['error' => 'Archive directory is not writable'], 500); + } + + $this->logDebug('[importUpload] stored', ['backupId' => $backupId]); + + return $this->json([ + 'backupId' => $backupId, + 'filename' => $file->getClientOriginalName(), + ]); + } + + #[Route('/import/server', name: 'import_server_pick', methods: ['POST'])] + public function importServerPick(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + $payload = json_decode($req->getContent() ?: "{}", true); + $filename = $payload['filename'] ?? null; + if (!$filename) { + $this->logDebug('[importServerPick] missing filename'); + return $this->json(['error' => 'Missing filename'], 400); + } + + $path = rtrim(CourseArchiver::getBackupDir(), '/').'/'.$filename; + if (!is_file($path)) { + $this->logDebug('[importServerPick] file not found', ['path' => $path]); + return $this->json(['error' => 'File not found'], 404); + } + + $this->logDebug('[importServerPick] ok', ['backupId' => $filename]); + + return $this->json(['backupId' => $filename, 'filename' => $filename]); + } + + #[Route( + '/import/{backupId}/resources', + name: 'import_resources', + requirements: ['backupId' => '.+'], + methods: ['GET'] + )] + public function importResources(int $node, string $backupId, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + $this->logDebug('[importResources] begin', ['node' => $node, 'backupId' => $backupId]); + + try { + /** @var Course $course */ + $course = CourseArchiver::readCourse($backupId, false); + + $this->logDebug('[importResources] course loaded', [ + 'has_resources' => is_array($course->resources ?? null), + 'keys' => array_keys((array) ($course->resources ?? [])), + ]); + $this->logDebug('[importResources] resources snapshot', $this->snapshotResources($course)); + $this->logDebug('[importResources] forum counts', $this->snapshotForumCounts($course)); + + $tree = $this->buildResourceTreeForVue($course); + $this->logDebug( + '[importResources] UI tree groups', + array_map(fn($g) => ['type' => $g['type'], 'title' => $g['title'], 'items' => count($g['items'] ?? [])], $tree) + ); + + if ($this->debug && $req->query->getBoolean('debug')) { + $base = $this->getParameter('kernel.project_dir').'/var/log/course_backup_debug'; + @mkdir($base, 0775, true); + @file_put_contents( + $base.'/'.preg_replace('/[^a-zA-Z0-9._-]/', '_', $backupId).'.json', + json_encode([ + 'tree' => $tree, + 'resources_keys' => array_keys((array) ($course->resources ?? [])), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + ); + $this->logDebug('[importResources] wrote debug snapshot to var/log/course_backup_debug'); + } + + $warnings = []; + if (empty($tree)) { + $warnings[] = 'Backup has no selectable resources.'; + } + + return $this->json([ + 'tree' => $tree, + 'warnings' => $warnings, + ]); + } catch (\Throwable $e) { + $this->logDebug('[importResources] exception', ['message' => $e->getMessage()]); + return $this->json([ + 'tree' => [], + 'warnings' => ['Error reading backup: '.$e->getMessage()], + ], 200); + } + } + + #[Route( + '/import/{backupId}/restore', + name: 'import_restore', + requirements: ['backupId' => '.+'], + methods: ['POST'] + )] + public function importRestore(int $node, string $backupId, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + $this->logDebug('[importRestore] begin', ['node' => $node, 'backupId' => $backupId]); + + try { + $payload = json_decode($req->getContent() ?: "{}", true); + $importOption = (string) ($payload['importOption'] ?? 'full_backup'); + $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); + $selectedResources = (array) ($payload['resources'] ?? []); + + $this->logDebug('[importRestore] input', [ + 'importOption' => $importOption, + 'sameFileNameOption' => $sameFileNameOption, + 'selectedTypes' => array_keys($selectedResources), + ]); + + $backupDir = CourseArchiver::getBackupDir(); + $this->logDebug('[importRestore] backup dir', $backupDir); + $path = rtrim($backupDir, '/').'/'.$backupId; + $this->logDebug('[importRestore] path exists?', [ + 'path' => $path, + 'exists' => is_file($path), + 'readable' => is_readable($path), + ]); + + /** @var Course $course */ + $course = CourseArchiver::readCourse($backupId, false); + + if (!is_object($course) || empty($course->resources) || !is_array($course->resources)) { + $this->logDebug('[importRestore] course empty resources'); + return $this->json(['error' => 'Backup has no resources'], 400); + } + + $this->logDebug('[importRestore] BEFORE filter keys', array_keys($course->resources)); + $this->logDebug('[importRestore] BEFORE forum counts', $this->snapshotForumCounts($course)); + + if ($importOption === 'select_items') { + $hasAny = false; + foreach ($selectedResources as $t => $ids) { + if (is_array($ids) && !empty($ids)) { + $hasAny = true; + break; + } + } + if (!$hasAny) { + $this->logDebug('[importRestore] empty selection'); + return $this->json(['error' => 'No resources selected'], 400); + } + + $course = $this->filterLegacyCourseBySelection($course, $selectedResources); + + if (empty($course->resources) || count((array) $course->resources) === 0) { + $this->logDebug('[importRestore] selection produced no resources'); + return $this->json(['error' => 'Selection produced no resources to restore'], 400); + } + } + + $this->logDebug('[importRestore] AFTER filter keys', array_keys($course->resources)); + $this->logDebug('[importRestore] AFTER forum counts', $this->snapshotForumCounts($course)); + $this->logDebug('[importRestore] AFTER resources snapshot', $this->snapshotResources($course)); + + $restorer = new CourseRestorer($course); + $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption)); + if (method_exists($restorer, 'setDebug')) { + $restorer->setDebug($this->debug); + $this->logDebug('[importRestore] restorer debug forwarded', ['debug' => $this->debug]); + } + + $this->logDebug('[importRestore] calling restore()'); + $restorer->restore(); + $this->logDebug('[importRestore] restore() finished', [ + 'dest_course_id' => $restorer->destination_course_info['real_id'] ?? null, + ]); + + CourseArchiver::cleanBackupDir(); + + $courseId = (int) ($restorer->destination_course_info['real_id'] ?? 0); + $sessionId = 0; + $groupId = 0; + $redirectUrl = sprintf('/course/%d/home?sid=%d&gid=%d', $courseId, $sessionId, $groupId); + + $this->logDebug('[importRestore] done, redirect', ['url' => $redirectUrl]); + + return $this->json([ + 'ok' => true, + 'message' => 'Import finished', + 'redirectUrl' => $redirectUrl, + ]); + } catch (\Throwable $e) { + $this->logDebug('[importRestore] exception', [ + 'message' => $e->getMessage(), + 'file' => $e->getFile().':'.$e->getLine(), + ]); + return $this->json([ + 'error' => 'Restore failed: '.$e->getMessage(), + 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, + ], 500); + } + } + + #[Route('/copy/options', name: 'copy_options', methods: ['GET'])] + public function copyOptions(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + $current = api_get_course_info(); + $courseList = CourseManager::getCoursesFollowedByUser( + api_get_user_id(), + COURSEMANAGER, + null, null, null, null, false, null, null, false, + 'ORDER BY c.title' + ); + + $courses = []; + foreach ($courseList as $c) { + if ((int) $c['real_id'] === (int) $current['real_id']) { + continue; + } + $courses[] = ['id' => (string) $c['code'], 'code' => $c['code'], 'title' => $c['title']]; + } + + return $this->json([ + 'courses' => $courses, + 'defaults' => [ + 'copyOption' => 'full_copy', + 'includeUsers' => false, + 'resetDates' => true, + 'sameFileNameOption' => 2, + ], + ]); + } + + #[Route('/copy/resources', name: 'copy_resources', methods: ['GET'])] + public function copyResources(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + $sourceCourseCode = trim((string) $req->query->get('sourceCourseId', '')); + if ($sourceCourseCode === '') { + return $this->json(['error' => 'Missing sourceCourseId'], 400); + } + + $cb = new CourseBuilder(); + $cb->set_tools_to_build([ + 'documents', + 'forums', + 'tool_intro', + 'links', + 'quizzes', + 'quiz_questions', + 'assets', + 'surveys', + 'survey_questions', + 'announcements', + 'events', + 'course_descriptions', + 'glossary', + 'wiki', + 'thematic', + 'attendance', + 'works', + 'gradebook', + 'learnpath_category', + 'learnpaths', + ]); + + $course = $cb->build(0, $sourceCourseCode); + + $tree = $this->buildResourceTreeForVue($course); + + $warnings = []; + if (empty($tree)) { + $warnings[] = 'Source course has no resources.'; + } + + return $this->json(['tree' => $tree, 'warnings' => $warnings]); + } + + #[Route('/copy/execute', name: 'copy_execute', methods: ['POST'])] + public function copyExecute(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + try { + $payload = json_decode($req->getContent() ?: "{}", true); + + $sourceCourseId = (string) ($payload['sourceCourseId'] ?? ''); + $copyOption = (string) ($payload['copyOption'] ?? 'full_copy'); + $sameFileNameOption = (int) ($payload['sameFileNameOption'] ?? 2); + $selectedResourcesMap = (array) ($payload['resources'] ?? []); + + if ($sourceCourseId === '') { + return $this->json(['error' => 'Missing sourceCourseId'], 400); + } + + $cb = new CourseBuilder('partial'); + $cb->set_tools_to_build([ + 'documents', + 'forums', + 'tool_intro', + 'links', + 'quizzes', + 'quiz_questions', + 'assets', + 'surveys', + 'survey_questions', + 'announcements', + 'events', + 'course_descriptions', + 'glossary', + 'wiki', + 'thematic', + 'attendance', + 'works', + 'gradebook', + 'learnpath_category', + 'learnpaths', + ]); + $legacyCourse = $cb->build(0, $sourceCourseId); + + if ($copyOption === 'select_items') { + $legacyCourse = $this->filterLegacyCourseBySelection($legacyCourse, $selectedResourcesMap); + + if (empty($legacyCourse->resources) || !is_array($legacyCourse->resources)) { + return $this->json(['error' => 'Selection produced no resources to copy'], 400); + } + } + + error_log('$legacyCourse :::: '.print_r($legacyCourse, true)); + + $restorer = new CourseRestorer($legacyCourse); + $restorer->set_file_option($this->mapSameNameOption($sameFileNameOption)); + if (method_exists($restorer, 'setDebug')) { + $restorer->setDebug($this->debug); + } + $restorer->restore(); + + $dest = api_get_course_info(); + $redirectUrl = sprintf('/course/%d/home', (int) $dest['real_id']); + + return $this->json([ + 'ok' => true, + 'message' => 'Copy finished', + 'redirectUrl' => $redirectUrl, + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Copy failed: '.$e->getMessage(), + 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, + ], 500); + } + } + + #[Route('/recycle/options', name: 'recycle_options', methods: ['GET'])] + public function recycleOptions(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + // current course only + $defaults = [ + 'recycleOption' => 'select_items', // 'full_recycle' | 'select_items' + 'confirmNeeded' => true, // show code-confirm input when full + ]; + + return $this->json(['defaults' => $defaults]); + } + + #[Route('/recycle/resources', name: 'recycle_resources', methods: ['GET'])] + public function recycleResources(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + // Build legacy Course from CURRENT course (not “source”) + $cb = new CourseBuilder(); + $cb->set_tools_to_build([ + 'documents','forums','tool_intro','links','quizzes','quiz_questions','assets','surveys', + 'survey_questions','announcements','events','course_descriptions','glossary','wiki', + 'thematic','attendance','works','gradebook','learnpath_category','learnpaths', + ]); + $course = $cb->build(0, api_get_course_id()); + + $tree = $this->buildResourceTreeForVue($course); + $warnings = empty($tree) ? ['This course has no resources.'] : []; + + return $this->json(['tree' => $tree, 'warnings' => $warnings]); + } + + #[Route('/recycle/execute', name: 'recycle_execute', methods: ['POST'])] + public function recycleExecute(Request $req, EntityManagerInterface $em): JsonResponse + { + try { + $p = json_decode($req->getContent() ?: "{}", true); + $recycleOption = (string)($p['recycleOption'] ?? 'select_items'); // 'full_recycle' | 'select_items' + $resourcesMap = (array) ($p['resources'] ?? []); + $confirmCode = (string)($p['confirm'] ?? ''); + + $type = $recycleOption === 'full_recycle' ? 'full_backup' : 'select_items'; + + if ($type === 'full_backup') { + if ($confirmCode !== api_get_course_id()) { + return $this->json(['error' => 'Course code confirmation mismatch'], 400); + } + } else { + if (empty($resourcesMap)) { + return $this->json(['error' => 'No resources selected'], 400); + } + } + + $courseCode = api_get_course_id(); + $courseInfo = api_get_course_info($courseCode); + $courseId = (int)($courseInfo['real_id'] ?? 0); + if ($courseId <= 0) { + return $this->json(['error' => 'Invalid course id'], 400); + } + + $recycler = new CourseRecycler( + $em, + $courseCode, + $courseId + ); + + $recycler->recycle($type, $resourcesMap); + + return $this->json([ + 'ok' => true, + 'message' => 'Recycle finished', + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Recycle failed: '.$e->getMessage(), + ], 500); + } + } + + #[Route('/delete', name: 'delete', methods: ['POST'])] + public function deleteCourse(int $node, Request $req): JsonResponse + { + // Basic permission gate (adjust roles to your policy if needed) + if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_TEACHER') && !$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')) { + return $this->json(['error' => 'You are not allowed to delete this course'], 403); + } + + try { + $payload = json_decode($req->getContent() ?: "{}", true); + $confirm = trim((string)($payload['confirm'] ?? '')); + + if ($confirm === '') { + return $this->json(['error' => 'Missing confirmation value'], 400); + } + + // Current course + $courseInfo = api_get_course_info(); + if (empty($courseInfo)) { + return $this->json(['error' => 'Unable to resolve current course'], 400); + } + + $officialCode = (string)($courseInfo['official_code'] ?? ''); + $runtimeCode = (string)api_get_course_id(); // often equals official code + $sysCode = (string)($courseInfo['sysCode'] ?? ''); // used by legacy delete + + if ($sysCode === '') { + return $this->json(['error' => 'Invalid course system code'], 400); + } + + // Accept either official_code or api_get_course_id() as confirmation + $matches = \hash_equals($officialCode, $confirm) || \hash_equals($runtimeCode, $confirm); + if (!$matches) { + return $this->json(['error' => 'Course code confirmation mismatch'], 400); + } + + // Legacy delete (removes course data + unregisters members in this course) + // Throws on failure or returns void + \CourseManager::delete_course($sysCode); + + // Best-effort cleanup of legacy course session flags + try { + $ses = $req->getSession(); + $ses?->remove('_cid'); + $ses?->remove('_real_cid'); + } catch (\Throwable) { + // swallow — not critical + } + + // Decide where to send the user afterwards + // You can use '/index.php' or a landing page + $redirectUrl = '/index.php'; + + return $this->json([ + 'ok' => true, + 'message' => 'Course deleted successfully', + 'redirectUrl' => $redirectUrl, + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Failed to delete course: '.$e->getMessage(), + 'details' => method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null, + ], 500); + } + } + + #[Route('/moodle/export/options', name: 'moodle_export_options', methods: ['GET'])] + public function moodleExportOptions(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + // Defaults for the UI + $payload = [ + 'versions' => [ + ['value' => '3', 'label' => 'Moodle 3.x'], + ['value' => '4', 'label' => 'Moodle 4.x'], + ], + 'defaults' => [ + 'moodleVersion' => '4', + 'scope' => 'full', // 'full' | 'selected' + ], + // Optional friendly note until real export is implemented + 'message' => 'Moodle export endpoints are under construction.', + ]; + + return $this->json($payload); + } + + #[Route('/moodle/export/resources', name: 'moodle_export_resources', methods: ['GET'])] + public function moodleExportResources(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + // Build legacy Course from CURRENT course (same approach as recycle) + $cb = new CourseBuilder(); + $cb->set_tools_to_build([ + 'documents','forums','tool_intro','links','quizzes','quiz_questions','assets','surveys', + 'survey_questions','announcements','events','course_descriptions','glossary','wiki', + 'thematic','attendance','works','gradebook','learnpath_category','learnpaths', + ]); + $course = $cb->build(0, api_get_course_id()); + + $tree = $this->buildResourceTreeForVue($course); + $warnings = empty($tree) ? ['This course has no resources.'] : []; + + return $this->json(['tree' => $tree, 'warnings' => $warnings]); + } + + #[Route('/moodle/export/execute', name: 'moodle_export_execute', methods: ['POST'])] + public function moodleExportExecute(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + // Read payload (basic validation) + $p = json_decode($req->getContent() ?: "{}", true); + $moodleVersion = (string)($p['moodleVersion'] ?? '4'); // '3' | '4' + $scope = (string)($p['scope'] ?? 'full'); // 'full' | 'selected' + $adminId = trim((string)($p['adminId'] ?? '')); + $adminLogin = trim((string)($p['adminLogin'] ?? '')); + $adminEmail = trim((string)($p['adminEmail'] ?? '')); + $resources = (array)($p['resources'] ?? []); + + if ($adminId === '' || $adminLogin === '' || $adminEmail === '') { + return $this->json(['error' => 'Missing required fields (adminId, adminLogin, adminEmail)'], 400); + } + if ($scope === 'selected' && empty($resources)) { + return $this->json(['error' => 'No resources selected'], 400); + } + if (!in_array($moodleVersion, ['3','4'], true)) { + return $this->json(['error' => 'Unsupported Moodle version'], 400); + } + + // Stub response while implementation is in progress + // Use 202 so the frontend can show a notice without treating it as a failure. + return new JsonResponse([ + 'ok' => false, + 'message' => 'Moodle export is under construction. No .mbz file was generated.', + // you may also return a placeholder downloadUrl later + // 'downloadUrl' => null, + ], 202); + } + + #[Route('/cc13/export/options', name: 'cc13_export_options', methods: ['GET'])] + public function cc13ExportOptions(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + return $this->json([ + 'defaults' => [ + 'scope' => 'full', // 'full' | 'selected' + ], + 'message' => 'Common Cartridge 1.3 export is under construction. You can already pick items and submit.', + ]); + } + + #[Route('/cc13/export/resources', name: 'cc13_export_resources', methods: ['GET'])] + public function cc13ExportResources(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + $cb = new CourseBuilder(); + $cb->set_tools_to_build([ + 'documents','forums','tool_intro','links','quizzes','quiz_questions','assets','surveys', + 'survey_questions','announcements','events','course_descriptions','glossary','wiki', + 'thematic','attendance','works','gradebook','learnpath_category','learnpaths', + ]); + $course = $cb->build(0, api_get_course_id()); + + $tree = $this->buildResourceTreeForVue($course); + $warnings = empty($tree) ? ['This course has no resources.'] : []; + + return $this->json(['tree' => $tree, 'warnings' => $warnings]); + } + + #[Route('/cc13/export/execute', name: 'cc13_export_execute', methods: ['POST'])] + public function cc13ExportExecute(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + $p = json_decode($req->getContent() ?: "{}", true); + $scope = (string)($p['scope'] ?? 'full'); // 'full' | 'selected' + $resources= (array)($p['resources'] ?? []); + + if (!in_array($scope, ['full', 'selected'], true)) { + return $this->json(['error' => 'Unsupported scope'], 400); + } + if ($scope === 'selected' && empty($resources)) { + return $this->json(['error' => 'No resources selected'], 400); + } + + // TODO: Generate IMS CC 1.3 cartridge (.imscc or .zip) + // For now, return an informative 202 “under construction”. + return new JsonResponse([ + 'ok' => false, + 'message' => 'Common Cartridge 1.3 export is under construction. No file was generated.', + // 'downloadUrl' => null, // set when implemented + ], 202); + } + + #[Route('/cc13/import', name: 'cc13_import', methods: ['POST'])] + public function cc13Import(int $node, Request $req): JsonResponse + { + $this->setDebugFromRequest($req); + + $file = $req->files->get('file'); + if (!$file) { + return $this->json(['error' => 'Missing file'], 400); + } + $ext = strtolower(pathinfo($file->getClientOriginalName() ?? '', PATHINFO_EXTENSION)); + if (!in_array($ext, ['imscc','zip'], true)) { + return $this->json(['error' => 'Unsupported file type. Please upload .imscc or .zip'], 400); + } + + // TODO: Parse/restore CC 1.3. For now, just acknowledge. + // You can temporarily move the uploaded file into a working dir if useful. + return $this->json([ + 'ok' => true, + 'message' => 'CC 1.3 import endpoint is under construction. File received successfully.', + ]); + } + + // -------------------------------------------------------------------------------- + // Helpers to build the Vue-ready resource tree + // -------------------------------------------------------------------------------- + + /** + * Build a Vue-friendly tree from legacy Course. + * + * @param object $course + * @return array + */ + private function buildResourceTreeForVue(object $course): array + { + if ($this->debug) { + $this->logDebug('[buildResourceTreeForVue] start'); + } + + $resources = is_object($course) && isset($course->resources) && is_array($course->resources) + ? $course->resources + : []; + + $legacyTitles = []; + if (class_exists(CourseSelectForm::class) && method_exists(CourseSelectForm::class, 'getResourceTitleList')) { + /** @var array $legacyTitles */ + $legacyTitles = CourseSelectForm::getResourceTitleList(); + } + $fallbackTitles = $this->getDefaultTypeTitles(); + $skipTypes = $this->getSkipTypeKeys(); + + $tree = []; + + // Forums block + $hasForumData = + (!empty($resources['forum']) || !empty($resources['Forum'])) || + (!empty($resources['forum_category']) || !empty($resources['Forum_Category'])) || + (!empty($resources['forum_topic']) || !empty($resources['ForumTopic'])) || + (!empty($resources['thread']) || !empty($resources['post']) || !empty($resources['forum_post'])); + + if ($hasForumData) { + $tree[] = $this->buildForumTreeForVue( + $course, + $legacyTitles['forum'] ?? ($fallbackTitles['forum'] ?? 'Forums') + ); + $skipTypes['forum'] = true; + $skipTypes['forum_category'] = true; + $skipTypes['forum_topic'] = true; + $skipTypes['forum_post'] = true; + $skipTypes['thread'] = true; + $skipTypes['post'] = true; + } + + // Other tools + foreach ($resources as $rawType => $items) { + if (!is_array($items) || empty($items)) { + continue; + } + $typeKey = $this->normalizeTypeKey($rawType); + if (isset($skipTypes[$typeKey])) { + continue; + } + + $groupTitle = $legacyTitles[$typeKey] ?? ($fallbackTitles[$typeKey] ?? ucfirst($typeKey)); + $group = [ + 'type' => $typeKey, + 'title' => (string) $groupTitle, + 'items' => [], + ]; + + if ($typeKey === 'gradebook') { + $group['items'][] = [ + 'id' => 'all', + 'label' => 'Gradebook (all)', + 'extra' => new \stdClass(), + 'selectable' => true, + ]; + $tree[] = $group; + continue; + } + + foreach ($items as $id => $obj) { + if (!is_object($obj)) { + continue; + } + + $idKey = is_numeric($id) ? (int) $id : (string) $id; + if ((is_int($idKey) && $idKey <= 0) || (is_string($idKey) && $idKey === '')) { + continue; + } + + if (!$this->isSelectableItem($typeKey, $obj)) { + continue; + } + + $label = $this->resolveItemLabel($typeKey, $obj, is_int($idKey) ? $idKey : 0); + if ($typeKey === 'tool_intro' && $label === '#0' && is_string($idKey)) { + $label = $idKey; + } + + $extra = $this->buildExtra($typeKey, $obj); + + $group['items'][] = [ + 'id' => $idKey, + 'label' => $label, + 'extra' => $extra ?: new \stdClass(), + 'selectable' => true, + ]; + } + + if (!empty($group['items'])) { + usort( + $group['items'], + static fn($a, $b) => strcasecmp((string) $a['label'], (string) $b['label']) + ); + $tree[] = $group; + } + } + + // Preferred order + $preferredOrder = [ + 'announcement','document','course_description','learnpath','quiz','forum','glossary','link', + 'survey','thematic','work','attendance','wiki','calendar_event','tool_intro','gradebook', + ]; + usort($tree, static function ($a, $b) use ($preferredOrder) { + $ia = array_search($a['type'], $preferredOrder, true); + $ib = array_search($b['type'], $preferredOrder, true); + if ($ia !== false && $ib !== false) { + return $ia <=> $ib; + } + if ($ia !== false) { + return -1; + } + if ($ib !== false) { + return 1; + } + return strcasecmp($a['title'], $b['title']); + }); + + if ($this->debug) { + $this->logDebug( + '[buildResourceTreeForVue] end groups', + array_map(fn($g) => ['type' => $g['type'], 'items' => count($g['items'] ?? [])], $tree) + ); + } + + return $tree; + } + + /** + * Build forum tree (Category → Forum → Topic). + * + * @param object $course + * @param string $groupTitle + * @return array + */ + private function buildForumTreeForVue(object $course, string $groupTitle): array + { + $this->logDebug('[buildForumTreeForVue] start'); + + $res = is_array($course->resources ?? null) ? $course->resources : []; + + $catRaw = $res['forum_category'] ?? $res['Forum_Category'] ?? []; + $forumRaw = $res['forum'] ?? $res['Forum'] ?? []; + $topicRaw = $res['forum_topic'] ?? $res['ForumTopic'] ?? ($res['thread'] ?? []); + $postRaw = $res['forum_post'] ?? $res['Forum_Post'] ?? ($res['post'] ?? []); + + $this->logDebug('[buildForumTreeForVue] raw counts', [ + 'categories' => is_array($catRaw) ? count($catRaw) : 0, + 'forums' => is_array($forumRaw) ? count($forumRaw) : 0, + 'topics' => is_array($topicRaw) ? count($topicRaw) : 0, + 'posts' => is_array($postRaw) ? count($postRaw) : 0, + ]); + + $cats = []; + $forums = []; + $topics = []; + $postCountByTopic = []; + + foreach ($catRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !is_object($obj)) { + continue; + } + $label = $this->resolveItemLabel('forum_category', $this->objectEntity($obj), $id); + $cats[$id] = [ + 'id' => $id, + 'type' => 'forum_category', + 'label' => $label, + 'selectable' => false, + 'children' => [], + ]; + } + + foreach ($forumRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !is_object($obj)) { + continue; + } + $forums[$id] = $this->objectEntity($obj); + } + + foreach ($topicRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !is_object($obj)) { + continue; + } + $topics[$id] = $this->objectEntity($obj); + } + + foreach ($postRaw as $id => $obj) { + $id = (int) $id; + if ($id <= 0 || !is_object($obj)) { + continue; + } + $e = $this->objectEntity($obj); + $tid = (int) ($e->thread_id ?? 0); + if ($tid > 0) { + $postCountByTopic[$tid] = ($postCountByTopic[$tid] ?? 0) + 1; + } + } + + $uncatKey = -9999; + if (!isset($cats[$uncatKey])) { + $cats[$uncatKey] = [ + 'id' => $uncatKey, + 'type' => 'forum_category', + 'label' => 'Uncategorized', + 'selectable' => false, + 'children' => [], + '_virtual' => true, + ]; + } + + foreach ($forums as $fid => $f) { + $catId = (int) ($f->forum_category ?? 0); + if (!isset($cats[$catId])) { + $catId = $uncatKey; + } + + $forumNode = [ + 'id' => $fid, + 'type' => 'forum', + 'label' => $this->resolveItemLabel('forum', $f, $fid), + 'extra' => $this->buildExtra('forum', $f) ?: new \stdClass(), + 'selectable' => true, + 'children' => [], + ]; + + foreach ($topics as $tid => $t) { + if ((int) ($t->forum_id ?? 0) !== $fid) { + continue; + } + + $author = (string) ($t->thread_poster_name ?? $t->poster_name ?? ''); + $date = (string) ($t->thread_date ?? ''); + $nPosts = (int) ($postCountByTopic[$tid] ?? 0); + + $topicLabel = $this->resolveItemLabel('forum_topic', $t, $tid); + $meta = []; + if ($author !== '') { + $meta[] = $author; + } + if ($date !== '') { + $meta[] = $date; + } + if ($meta) { + $topicLabel .= ' ('.implode(', ', $meta).')'; + } + if ($nPosts > 0) { + $topicLabel .= ' — '.$nPosts.' post'.($nPosts === 1 ? '' : 's'); + } + + $forumNode['children'][] = [ + 'id' => $tid, + 'type' => 'forum_topic', + 'label' => $topicLabel, + 'extra' => new \stdClass(), + 'selectable' => true, + ]; + } + + if ($forumNode['children']) { + usort($forumNode['children'], static fn($a, $b) => strcasecmp($a['label'], $b['label'])); + } + + $cats[$catId]['children'][] = $forumNode; + } + + $catNodes = array_values(array_filter($cats, static function ($c) { + if (!empty($c['_virtual']) && empty($c['children'])) { + return false; + } + return true; + })); + + foreach ($catNodes as &$c) { + if (!empty($c['children'])) { + usort($c['children'], static fn($a, $b) => strcasecmp($a['label'], $b['label'])); + } + } + unset($c); + usort($catNodes, static fn($a, $b) => strcasecmp($a['label'], $b['label'])); + + $this->logDebug('[buildForumTreeForVue] end', ['categories' => count($catNodes)]); + + return [ + 'type' => 'forum', + 'title' => $groupTitle, + 'items' => $catNodes, + ]; + } + + /** + * Normalize a raw type to a lowercase key. + * + * @param int|string $raw + * @return string + */ + private function normalizeTypeKey(int|string $raw): string + { + if (is_int($raw)) { + return (string) $raw; + } + + $s = strtolower(str_replace(['\\', ' '], ['/', '_'], (string) $raw)); + + $map = [ + 'forum_category' => 'forum_category', + 'forumtopic' => 'forum_topic', + 'forum_topic' => 'forum_topic', + 'forum_post' => 'forum_post', + 'thread' => 'forum_topic', + 'post' => 'forum_post', + 'exercise_question' => 'exercise_question', + 'surveyquestion' => 'survey_question', + 'surveyinvitation' => 'survey_invitation', + 'SurveyQuestion' => 'survey_question', + 'SurveyInvitation' => 'survey_invitation', + 'Survey' => 'survey', + 'link_category' => 'link_category', + 'coursecopylearnpath' => 'learnpath', + 'coursecopytestcategory' => 'test_category', + 'coursedescription' => 'course_description', + 'session_course' => 'session_course', + 'gradebookbackup' => 'gradebook', + 'scormdocument' => 'scorm', + 'tool/introduction' => 'tool_intro', + 'tool_introduction' => 'tool_intro', + ]; + + return $map[$s] ?? $s; + } + + /** + * Keys to skip as top-level groups in UI. + * + * @return array + */ + private function getSkipTypeKeys(): array + { + return [ + 'forum_category' => true, + 'forum_topic' => true, + 'forum_post' => true, + 'thread' => true, + 'post' => true, + 'exercise_question' => true, + 'survey_question' => true, + 'survey_invitation' => true, + 'session_course' => true, + 'scorm' => true, + 'asset' => true, + ]; + } + + /** + * Default labels for groups. + * + * @return array + */ + private function getDefaultTypeTitles(): array + { + return [ + 'announcement' => 'Announcements', + 'document' => 'Documents', + 'glossary' => 'Glossaries', + 'calendar_event' => 'Calendar events', + 'event' => 'Calendar events', + 'link' => 'Links', + 'course_description' => 'Course descriptions', + 'learnpath' => 'Parcours', + 'learnpath_category' => 'Learning path categories', + 'forum' => 'Forums', + 'forum_category' => 'Forum categories', + 'quiz' => 'Exercices', + 'test_category' => 'Test categories', + 'wiki' => 'Wikis', + 'thematic' => 'Thematics', + 'attendance' => 'Attendances', + 'work' => 'Works', + 'session_course' => 'Session courses', + 'gradebook' => 'Gradebook', + 'scorm' => 'SCORM packages', + 'survey' => 'Surveys', + 'survey_question' => 'Survey questions', + 'survey_invitation' => 'Survey invitations', + 'asset' => 'Assets', + 'tool_intro' => 'Tool introductions', + ]; + } + + /** + * Decide if an item is selectable (UI). + * + * @param string $type + * @param object $obj + * @return bool + */ + private function isSelectableItem(string $type, object $obj): bool + { + if ($type === 'document') { + return true; + } + return true; + } + + /** + * Resolve label for an item with fallbacks. + * + * @param string $type + * @param object $obj + * @param int $fallbackId + * @return string + */ + private function resolveItemLabel(string $type, object $obj, int $fallbackId): string + { + $entity = $this->objectEntity($obj); + + foreach (['title', 'name', 'subject', 'question', 'display', 'code', 'description'] as $k) { + if (isset($entity->$k) && is_string($entity->$k) && trim($entity->$k) !== '') { + return trim((string) $entity->$k); + } + } + + if (isset($obj->params) && is_array($obj->params)) { + foreach (['title', 'name', 'subject', 'display', 'description'] as $k) { + if (!empty($obj->params[$k]) && is_string($obj->params[$k])) { + return (string) $obj->params[$k]; + } + } + } + + switch ($type) { + case 'document': + if (!empty($obj->title)) { + return (string) $obj->title; + } + if (!empty($obj->path)) { + $base = basename((string) $obj->path); + return $base !== '' ? $base : (string) $obj->path; + } + break; + + case 'course_description': + if (!empty($obj->title)) { + return (string) $obj->title; + } + $t = (int) ($obj->description_type ?? 0); + $names = [ + 1 => 'Description', + 2 => 'Objectives', + 3 => 'Topics', + 4 => 'Methodology', + 5 => 'Course material', + 6 => 'Resources', + 7 => 'Assessment', + 8 => 'Custom', + ]; + return $names[$t] ?? ('#'.$fallbackId); + + case 'announcement': + if (!empty($obj->title)) { + return (string) $obj->title; + } + break; + + case 'forum': + if (!empty($entity->forum_title)) { + return (string) $entity->forum_title; + } + break; + + case 'forum_category': + if (!empty($entity->cat_title)) { + return (string) $entity->cat_title; + } + break; + + case 'link': + if (!empty($obj->title)) { + return (string) $obj->title; + } + if (!empty($obj->url)) { + return (string) $obj->url; + } + break; + + case 'survey': + if (!empty($obj->title)) { + return trim((string) $obj->title); + } + break; + + case 'learnpath': + if (!empty($obj->name)) { + return (string) $obj->name; + } + break; + + case 'thematic': + if (isset($obj->params['title']) && is_string($obj->params['title'])) { + return (string) $obj->params['title']; + } + break; + + case 'quiz': + if (!empty($entity->title)) { + return (string) $entity->title; + } + break; + + case 'forum_topic': + if (!empty($entity->thread_title)) { + return (string) $entity->thread_title; + } + break; + } + + return '#'.$fallbackId; + } + + /** + * Extract wrapped entity (->obj) or the object itself. + * + * @param object $resource + * @return object + */ + private function objectEntity(object $resource): object + { + if (isset($resource->obj) && is_object($resource->obj)) { + return $resource->obj; + } + return $resource; + } + + /** + * Extra payload per item for UI (optional). + * + * @param string $type + * @param object $obj + * @return array + */ + private function buildExtra(string $type, object $obj): array + { + $extra = []; + + $get = static function (object $o, string $k, $default = null) { + return (isset($o->$k) && (is_string($o->$k) || is_numeric($o->$k))) ? $o->$k : $default; + }; + + switch ($type) { + case 'document': + $extra['path'] = (string) ($get($obj, 'path', '') ?? ''); + $extra['filetype'] = (string) ($get($obj, 'file_type', '') ?? ''); + $extra['size'] = (string) ($get($obj, 'size', '') ?? ''); + break; + + case 'link': + $extra['url'] = (string) ($get($obj, 'url', '') ?? ''); + $extra['target'] = (string) ($get($obj, 'target', '') ?? ''); + break; + + case 'forum': + $entity = $this->objectEntity($obj); + $extra['category_id'] = (string) ($entity->forum_category ?? ''); + $extra['default_view'] = (string) ($entity->default_view ?? ''); + break; + + case 'learnpath': + $extra['name'] = (string) ($get($obj, 'name', '') ?? ''); + $extra['items'] = isset($obj->items) && is_array($obj->items) ? array_map(static function ($i) { + return [ + 'id' => (int) ($i['id'] ?? 0), + 'title' => (string) ($i['title'] ?? ''), + 'type' => (string) ($i['item_type'] ?? ''), + 'path' => (string) ($i['path'] ?? ''), + ]; + }, $obj->items) : []; + break; + + case 'thematic': + if (isset($obj->params) && is_array($obj->params)) { + $extra['active'] = (string) ($obj->params['active'] ?? ''); + } + break; + + case 'quiz': + $entity = $this->objectEntity($obj); + $extra['question_ids'] = isset($entity->question_ids) && is_array($entity->question_ids) + ? array_map('intval', $entity->question_ids) + : []; + break; + + case 'survey': + $entity = $this->objectEntity($obj); + $extra['question_ids'] = isset($entity->question_ids) && is_array($entity->question_ids) + ? array_map('intval', $entity->question_ids) + : []; + break; + } + + return array_filter($extra, static fn($v) => !($v === '' || $v === null || $v === [])); + } + + // -------------------------------------------------------------------------------- + // Selection filtering (used by partial restore) + // -------------------------------------------------------------------------------- + + /** + * Get first existing key from candidates. + * + * @param array $orig + * @param array $candidates + * @return string|null + */ + private function firstExistingKey(array $orig, array $candidates): ?string + { + foreach ($candidates as $k) { + if (isset($orig[$k]) && is_array($orig[$k]) && !empty($orig[$k])) { + return $k; + } + } + return null; + } + + /** + * Filter legacy Course by UI selections (and pull dependencies). + * + * @param object $course + * @param array $selected [type => [id => true]] + * @return object + */ + private function filterLegacyCourseBySelection(object $course, array $selected): object + { + $this->logDebug('[filterSelection] start', ['selected_types' => array_keys($selected)]); + + if (empty($course->resources) || !is_array($course->resources)) { + $this->logDebug('[filterSelection] course has no resources'); + return $course; + } + $orig = $course->resources; + + // Forums flow + $selForums = array_fill_keys(array_map('strval', array_keys($selected['forum'] ?? [])), true); + if (!empty($selForums)) { + $forums = $orig['forum'] ?? []; + $catsToKeep = []; + foreach ($forums as $fid => $f) { + if (!isset($selForums[(string) $fid])) { + continue; + } + $e = isset($f->obj) && is_object($f->obj) ? $f->obj : $f; + $cid = (int) ($e->forum_category ?? 0); + if ($cid > 0) { + $catsToKeep[$cid] = true; + } + } + + $threads = $orig['thread'] ?? []; + $threadToKeep = []; + foreach ($threads as $tid => $t) { + $e = isset($t->obj) && is_object($t->obj) ? $t->obj : $t; + if (isset($selForums[(string) ($e->forum_id ?? '')])) { + $threadToKeep[(int) $tid] = true; + } + } + + $posts = $orig['post'] ?? []; + $postToKeep = []; + foreach ($posts as $pid => $p) { + $e = isset($p->obj) && is_object($p->obj) ? $p->obj : $p; + if (isset($threadToKeep[(int) ($e->thread_id ?? 0)])) { + $postToKeep[(int) $pid] = true; + } + } + + $out = []; + foreach ($selected as $type => $ids) { + if (!is_array($ids) || empty($ids)) { + continue; + } + if (!empty($orig[$type])) { + $out[$type] = array_intersect_key($orig[$type], $ids); + } + } + + if (!empty($orig['Forum_Category'])) { + $out['Forum_Category'] = array_intersect_key( + $orig['Forum_Category'], + array_fill_keys(array_map('strval', array_keys($catsToKeep)), true) + ); + } + if (!empty($orig['forum'])) { + $out['forum'] = array_intersect_key($orig['forum'], $selForums); + } + if (!empty($orig['thread'])) { + $out['thread'] = array_intersect_key( + $orig['thread'], + array_fill_keys(array_map('strval', array_keys($threadToKeep)), true) + ); + } + if (!empty($orig['post'])) { + $out['post'] = array_intersect_key( + $orig['post'], + array_fill_keys(array_map('strval', array_keys($postToKeep)), true) + ); + } + + if (!empty($out['forum']) && empty($out['Forum_Category']) && !empty($orig['Forum_Category'])) { + $out['Forum_Category'] = $orig['Forum_Category']; + } + + $course->resources = array_filter($out); + + $this->logDebug('[filterSelection] end', [ + 'kept_types' => array_keys($course->resources), + 'forum_counts' => [ + 'Forum_Category' => is_array($course->resources['Forum_Category'] ?? null) ? count($course->resources['Forum_Category']) : 0, + 'forum' => is_array($course->resources['forum'] ?? null) ? count($course->resources['forum']) : 0, + 'thread' => is_array($course->resources['thread'] ?? null) ? count($course->resources['thread']) : 0, + 'post' => is_array($course->resources['post'] ?? null) ? count($course->resources['post']) : 0, + ], + ]); + + return $course; + } + + // Generic + quiz/survey/gradebook flows + $alias = [ + 'tool_intro' => 'Tool introduction', + ]; + + $keep = []; + foreach ($selected as $type => $ids) { + if (!is_array($ids) || empty($ids)) { + continue; + } + + $legacyKey = $type; + if (!isset($orig[$legacyKey]) && isset($alias[$type])) { + $legacyKey = $alias[$type]; + } + + if (!empty($orig[$legacyKey])) { + $keep[$legacyKey] = array_intersect_key($orig[$legacyKey], $ids); + } + } + + // Gradebook bucket + $gbKey = $this->firstExistingKey($orig, ['gradebook', 'Gradebook', 'GradebookBackup', 'gradebookbackup']); + if ($gbKey && !empty($selected['gradebook'])) { + $selIds = array_keys(array_filter((array) $selected['gradebook'])); + $firstItem = is_array($orig[$gbKey]) ? reset($orig[$gbKey]) : null; + + if (in_array('all', $selIds, true) || !is_object($firstItem)) { + $keep[$gbKey] = $orig[$gbKey]; + $this->logDebug('[filterSelection] kept full gradebook bucket', ['key' => $gbKey, 'count' => is_array($orig[$gbKey]) ? count($orig[$gbKey]) : 0]); + } else { + $keep[$gbKey] = array_intersect_key($orig[$gbKey], array_fill_keys(array_map('strval', $selIds), true)); + $this->logDebug('[filterSelection] kept partial gradebook bucket', ['key' => $gbKey, 'count' => is_array($keep[$gbKey]) ? count($keep[$gbKey]) : 0]); + } + } + + // Quizzes → questions (+ images) + $quizKey = $this->firstExistingKey($orig, ['quiz', 'Quiz']); + if ($quizKey && !empty($keep[$quizKey])) { + $questionKey = $this->firstExistingKey($orig, ['Exercise_Question', 'exercise_question', (defined('RESOURCE_QUIZQUESTION') ? RESOURCE_QUIZQUESTION : '')]); + if ($questionKey) { + $qids = []; + foreach ($keep[$quizKey] as $qid => $qwrap) { + $q = (isset($qwrap->obj) && is_object($qwrap->obj)) ? $qwrap->obj : $qwrap; + if (!empty($q->question_ids) && is_array($q->question_ids)) { + foreach ($q->question_ids as $sid) { + $qids[(string) $sid] = true; + } + } + } + + if (!empty($qids)) { + $selQ = array_intersect_key($orig[$questionKey], $qids); + if (!empty($selQ)) { + $keep[$questionKey] = $selQ; + $this->logDebug('[filterSelection] pulled question bucket for quizzes', [ + 'quiz_count' => count($keep[$quizKey]), + 'question_key' => $questionKey, + 'questions_kept' => count($keep[$questionKey]), + ]); + + $docKey = $this->firstExistingKey($orig, ['document', 'Document', (defined('RESOURCE_DOCUMENT') ? RESOURCE_DOCUMENT : '')]); + if ($docKey) { + $imageQuizBucket = $orig[$docKey]['image_quiz'] ?? null; + if (is_array($imageQuizBucket) && !empty($imageQuizBucket)) { + $needed = []; + foreach ($keep[$questionKey] as $qid => $qwrap) { + $q = (isset($qwrap->obj) && is_object($qwrap->obj)) ? $qwrap->obj : $qwrap; + $pic = (string) ($q->picture ?? ''); + if ($pic !== '' && isset($imageQuizBucket[$pic])) { + $needed[$pic] = true; + } + } + if (!empty($needed)) { + if (!isset($keep[$docKey]) || !is_array($keep[$docKey])) { + $keep[$docKey] = []; + } + if (!isset($keep[$docKey]['image_quiz']) || !is_array($keep[$docKey]['image_quiz'])) { + $keep[$docKey]['image_quiz'] = []; + } + $keep[$docKey]['image_quiz'] = array_intersect_key($imageQuizBucket, $needed); + $this->logDebug('[filterSelection] included image_quiz docs for questions', [ + 'count' => count($keep[$docKey]['image_quiz']), + ]); + } + } + } + } + } + } else { + $this->logDebug('[filterSelection] quizzes selected but no question bucket found in backup'); + } + } + + // Surveys → questions (+ invitations) + $surveyKey = $this->firstExistingKey($orig, ['survey', 'Survey']); + if ($surveyKey && !empty($keep[$surveyKey])) { + $surveyQuestionKey = $this->firstExistingKey($orig, ['Survey_Question', 'survey_question', (defined('RESOURCE_SURVEYQUESTION') ? RESOURCE_SURVEYQUESTION : '')]); + $surveyInvitationKey = $this->firstExistingKey($orig, ['Survey_Invitation', 'survey_invitation', (defined('RESOURCE_SURVEYINVITATION') ? RESOURCE_SURVEYINVITATION : '')]); + + if ($surveyQuestionKey) { + $neededQids = []; + $selSurveyIds = array_map('strval', array_keys($keep[$surveyKey])); + + foreach ($keep[$surveyKey] as $sid => $sWrap) { + $s = (isset($sWrap->obj) && is_object($sWrap->obj)) ? $sWrap->obj : $sWrap; + if (!empty($s->question_ids) && is_array($s->question_ids)) { + foreach ($s->question_ids as $qid) { + $neededQids[(string) $qid] = true; + } + } + } + + if (empty($neededQids) && is_array($orig[$surveyQuestionKey])) { + foreach ($orig[$surveyQuestionKey] as $qid => $qWrap) { + $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; + $qSurveyId = (string) ($q->survey_id ?? ''); + if ($qSurveyId !== '' && in_array($qSurveyId, $selSurveyIds, true)) { + $neededQids[(string) $qid] = true; + } + } + } + + if (!empty($neededQids)) { + $keep[$surveyQuestionKey] = array_intersect_key($orig[$surveyQuestionKey], $neededQids); + $this->logDebug('[filterSelection] pulled question bucket for surveys', [ + 'survey_count' => count($keep[$surveyKey]), + 'question_key' => $surveyQuestionKey, + 'questions_kept' => count($keep[$surveyQuestionKey]), + ]); + } else { + $this->logDebug('[filterSelection] surveys selected but no matching questions found'); + } + } else { + $this->logDebug('[filterSelection] surveys selected but no question bucket found in backup'); + } + + if ($surveyInvitationKey && !empty($orig[$surveyInvitationKey])) { + $neededInv = []; + foreach ($orig[$surveyInvitationKey] as $iid => $invWrap) { + $inv = (isset($invWrap->obj) && is_object($invWrap->obj)) ? $invWrap->obj : $invWrap; + $sid = (string) ($inv->survey_id ?? ''); + if ($sid !== '' && isset($keep[$surveyKey][$sid])) { + $neededInv[(string) $iid] = true; + } + } + if (!empty($neededInv)) { + $keep[$surveyInvitationKey] = array_intersect_key($orig[$surveyInvitationKey], $neededInv); + $this->logDebug('[filterSelection] included survey invitations', [ + 'invitations_kept' => count($keep[$surveyInvitationKey]), + ]); + } + } + } + + $course->resources = array_filter($keep); + $this->logDebug('[filterSelection] non-forum flow end', [ + 'kept_types' => array_keys($course->resources), + ]); + + return $course; + } + + /** + * Map UI options (1/2/3) to legacy file policy. + * + * @param int $opt + * @return int + */ + private function mapSameNameOption(int $opt): int + { + $opt = in_array($opt, [1, 2, 3], true) ? $opt : 2; + + if (!defined('FILE_SKIP')) { + define('FILE_SKIP', 1); + } + if (!defined('FILE_RENAME')) { + define('FILE_RENAME', 2); + } + if (!defined('FILE_OVERWRITE')) { + define('FILE_OVERWRITE', 3); + } + + return match ($opt) { + 1 => FILE_SKIP, + 3 => FILE_OVERWRITE, + default => FILE_RENAME, + }; + } + + /** + * Set debug mode from Request (query/header). + * + * @param Request|null $req + * @return void + */ + private function setDebugFromRequest(?Request $req): void + { + if (!$req) { + return; + } + // Query param wins + if ($req->query->has('debug')) { + $this->debug = $req->query->getBoolean('debug'); + return; + } + // Fallback to header + $hdr = $req->headers->get('X-Debug'); + if ($hdr !== null) { + $val = trim((string) $hdr); + $this->debug = ($val !== '' && $val !== '0' && strcasecmp($val, 'false') !== 0); + } + } + + /** + * Debug logger with stage + compact JSON payload. + * + * @param string $stage + * @param mixed $payload + * @return void + */ + private function logDebug(string $stage, mixed $payload = null): void + { + if (!$this->debug) { + return; + } + $prefix = 'COURSE_DEBUG'; + if ($payload === null) { + error_log("$prefix: $stage"); + return; + } + // Safe/short json + $json = null; + try { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json !== null && strlen($json) > 8000) { + $json = substr($json, 0, 8000).'…(truncated)'; + } + } catch (\Throwable $e) { + $json = '[payload_json_error: '.$e->getMessage().']'; + } + error_log("$prefix: $stage -> $json"); + } + + /** + * Snapshot of resources bag for quick inspection. + * + * @param object $course + * @param int $maxTypes + * @param int $maxItemsPerType + * @return array + */ + private function snapshotResources(object $course, int $maxTypes = 20, int $maxItemsPerType = 3): array + { + $out = []; + $res = is_array($course->resources ?? null) ? $course->resources : []; + $i = 0; + foreach ($res as $type => $bag) { + if ($i++ >= $maxTypes) { + $out['__notice'] = 'types truncated'; + break; + } + $snap = ['count' => is_array($bag) ? count($bag) : 0, 'sample' => []]; + if (is_array($bag)) { + $j = 0; + foreach ($bag as $id => $obj) { + if ($j++ >= $maxItemsPerType) { + $snap['sample'][] = ['__notice' => 'truncated']; + break; + } + $entity = (is_object($obj) && isset($obj->obj) && is_object($obj->obj)) ? $obj->obj : $obj; + $snap['sample'][] = [ + 'id' => (string) $id, + 'cls' => is_object($obj) ? get_class($obj) : gettype($obj), + 'entity_keys' => is_object($entity) ? array_slice(array_keys((array) $entity), 0, 12) : [], + ]; + } + } + $out[(string) $type] = $snap; + } + return $out; + } + + /** + * Snapshot of forum-family counters. + * + * @param object $course + * @return array + */ + private function snapshotForumCounts(object $course): array + { + $r = is_array($course->resources ?? null) ? $course->resources : []; + $get = fn($a, $b) => is_array(($r[$a] ?? $r[$b] ?? null)) ? count($r[$a] ?? $r[$b]) : 0; + return [ + 'Forum_Category' => $get('Forum_Category', 'forum_category'), + 'forum' => $get('forum', 'Forum'), + 'thread' => $get('thread', 'forum_topic'), + 'post' => $get('post', 'forum_post'), + ]; + } +} diff --git a/src/CoreBundle/Entity/GradebookCategory.php b/src/CoreBundle/Entity/GradebookCategory.php index 17fe361e7e8..1357debc5c3 100644 --- a/src/CoreBundle/Entity/GradebookCategory.php +++ b/src/CoreBundle/Entity/GradebookCategory.php @@ -380,12 +380,12 @@ public function getIsRequirement() return $this->isRequirement; } - public function getGradeBooksToValidateInDependence(): int + public function getGradeBooksToValidateInDependence(): ?int { return $this->gradeBooksToValidateInDependence; } - public function setGradeBooksToValidateInDependence(int $value): self + public function setGradeBooksToValidateInDependence(?int $value): self { $this->gradeBooksToValidateInDependence = $value; diff --git a/src/CoreBundle/Helpers/ChamiloHelper.php b/src/CoreBundle/Helpers/ChamiloHelper.php index e9a8569f83e..5c72c3e9920 100644 --- a/src/CoreBundle/Helpers/ChamiloHelper.php +++ b/src/CoreBundle/Helpers/ChamiloHelper.php @@ -6,7 +6,10 @@ namespace Chamilo\CoreBundle\Helpers; +use Chamilo\CoreBundle\Entity\AbstractResource; +use Chamilo\CoreBundle\Entity\Asset; use Chamilo\CoreBundle\Framework\Container; +use Chamilo\CoreBundle\Repository\ResourceRepository; use ChamiloSession as Session; use Database; use DateInterval; @@ -19,6 +22,7 @@ use FormValidator; use LegalManager; use MessageManager; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Template; use UserManager; @@ -673,4 +677,280 @@ public static function displayLegalTermsPage(string $returnUrl = '/home', bool $ exit; } + + /** + * Try to add a legacy file to a Resource using the repository's addFile() API. + * Falls back to attachLegacyFileToResource() if addFile() is not available. + * + * Returns true on success, false otherwise. Logs in English. + */ + public static function addLegacyFileToResource( + string $filePath, + ResourceRepository $repo, + AbstractResource $resource, + string $fileName = '', + string $description = '' + ): bool { + $class = $resource::class; + $basename = basename($filePath); + + if (!self::legacyFileUsable($filePath)) { + error_log("LEGACY_FILE: Cannot attach to {$class} – file not found or unreadable: {$basename}"); + return false; + } + + // If the repository doesn't expose addFile(), use the Asset flow. + if (!method_exists($repo, 'addFile')) { + error_log("LEGACY_FILE: Repository ".get_class($repo)." has no addFile(), falling back to Asset flow"); + return self::attachLegacyFileToResource($filePath, $resource, $fileName); + } + + try { + $mimeType = self::legacyDetectMime($filePath); + $finalName = $fileName !== '' ? $fileName : $basename; + + // UploadedFile in "test mode" (last arg true) avoids PHP upload checks. + $uploaded = new UploadedFile($filePath, $finalName, $mimeType, null, true); + $repo->addFile($resource, $uploaded, $description); + + return true; + } catch (\Throwable $e) { + error_log("LEGACY_FILE EXCEPTION (addFile): ".$e->getMessage()); + return false; + } + } + + /** + * Create an Asset for a legacy file and attach it to the resource's node. + * Generic path that works for any AbstractResource with a ResourceNode. + * + * Returns true on success, false otherwise. Logs in English. + */ + public static function attachLegacyFileToResource( + string $filePath, + AbstractResource $resource, + string $fileName = '' + ): bool { + $class = $resource::class; + $basename = basename($filePath); + + if (!self::legacyFileUsable($filePath)) { + error_log("LEGACY_FILE: Cannot attach Asset to {$class} – file not found or unreadable: {$basename}"); + return false; + } + + if (!method_exists($resource, 'getResourceNode') || null === $resource->getResourceNode()) { + error_log("LEGACY_FILE: Resource has no ResourceNode – cannot attach Asset (class: {$class})"); + return false; + } + + try { + $assetRepo = Container::getAssetRepository(); + + // Prefer a dedicated helper if available. + if (method_exists($assetRepo, 'createFromLocalPath')) { + $asset = $assetRepo->createFromLocalPath( + $filePath, + $fileName !== '' ? $fileName : $basename + ); + } else { + // Fallback: simulate an upload-like array for createFromRequest(). + $mimeType = self::legacyDetectMime($filePath); + $fakeUpload = [ + 'tmp_name' => $filePath, + 'name' => $fileName !== '' ? $fileName : $basename, + 'type' => $mimeType, + 'size' => @filesize($filePath) ?: null, + 'error' => 0, + ]; + + $asset = (new Asset()) + ->setTitle($fakeUpload['name']) + ->setCompressed(false); + + // AssetRepository::createFromRequest(Asset $asset, array $uploadLike) + $assetRepo->createFromRequest($asset, $fakeUpload); + } + + // Attach to the resource's node. + if (method_exists($assetRepo, 'attachToNode')) { + $assetRepo->attachToNode($asset, $resource->getResourceNode()); + return true; + } + + // If the resource repository exposes a direct helper: + $repo = self::guessResourceRepository($resource); + if ($repo && method_exists($repo, 'attachAssetToResource')) { + $repo->attachAssetToResource($resource, $asset); + return true; + } + + error_log("LEGACY_FILE: No method to attach Asset to node (missing attachToNode/attachAssetToResource)"); + return false; + } catch (\Throwable $e) { + error_log("LEGACY_FILE EXCEPTION (Asset attach): ".$e->getMessage()); + return false; + } + } + + /** + * Pick the first path that exists (file) from a candidate list. + * Returns the absolute path or null. + */ + public static function firstExistingPath(array $candidates): ?string + { + foreach ($candidates as $p) { + if (self::legacyFileUsable($p)) { + return $p; + } + } + return null; + } + + private static function legacyFileUsable(string $filePath): bool + { + return is_file($filePath) && is_readable($filePath); + } + + private static function legacyDetectMime(string $filePath): string + { + $mime = @mime_content_type($filePath); + return $mime ?: 'application/octet-stream'; + } + + /** + * Best-effort guess to find the resource repository via Doctrine. + * Returns null if the repo is not a ResourceRepository. + */ + private static function guessResourceRepository(AbstractResource $resource): ?ResourceRepository + { + try { + $em = \Database::getManager(); + $repo = $em->getRepository(get_class($resource)); + return $repo instanceof ResourceRepository ? $repo : null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * Attach a legacy file as Asset to an AbstractResource and return public URL. + * Returns ['ok'=>bool, 'asset'=>?Asset, 'url'=>?string, 'error'=>?string]. + */ + public static function attachLegacyFileWithPublicUrl( + string $filePath, + AbstractResource $ownerResource, + string $fileName = '' + ): array { + $basename = $fileName !== '' ? $fileName : basename($filePath); + + if (!self::legacyFileUsable($filePath)) { + return ['ok' => false, 'asset' => null, 'url' => null, 'error' => "File not found or unreadable: $basename"]; + } + + try { + $assetRepo = Container::getAssetRepository(); + + // Prefer helper if exists + if (method_exists($assetRepo, 'createFromLocalPath')) { + $asset = $assetRepo->createFromLocalPath($filePath, $basename); + } else { + $mimeType = self::legacyDetectMime($filePath); + $fakeUpload = [ + 'tmp_name' => $filePath, + 'name' => $basename, + 'type' => $mimeType, + 'size' => @filesize($filePath) ?: null, + 'error' => 0, + ]; + $asset = (new Asset())->setTitle($basename)->setCompressed(false); + $assetRepo->createFromRequest($asset, $fakeUpload); + } + + if (!method_exists($ownerResource, 'getResourceNode') || null === $ownerResource->getResourceNode()) { + return ['ok' => false, 'asset' => null, 'url' => null, 'error' => "Owner resource has no ResourceNode"]; + } + + if (method_exists($assetRepo, 'attachToNode')) { + $assetRepo->attachToNode($asset, $ownerResource->getResourceNode()); + } else { + $repo = self::guessResourceRepository($ownerResource); + if (!$repo || !method_exists($repo, 'attachAssetToResource')) { + return ['ok' => false, 'asset' => $asset, 'url' => null, 'error' => "No way to attach asset to node"]; + } + $repo->attachAssetToResource($ownerResource, $asset); + } + + // Get a public URL if the repo/asset exposes one + $url = null; + if (method_exists($assetRepo, 'getPublicUrl')) { + $url = $assetRepo->getPublicUrl($asset); + } elseif (method_exists($asset, 'getPublicPath')) { + $url = $asset->getPublicPath(); + } + + return ['ok' => true, 'asset' => $asset, 'url' => $url, 'error' => null]; + + } catch (\Throwable $e) { + return ['ok' => false, 'asset' => null, 'url' => null, 'error' => $e->getMessage()]; + } + } + + /** + * Rewrite legacy course URLs (like "document/...") inside $html to Asset URLs. + * Each found local file is attached to $ownerResource as an Asset. + */ + public static function rewriteLegacyCourseUrlsToAssets( + string $html, + AbstractResource $ownerResource, + string $backupRoot, + array $extraRoots = [] + ): string { + if ($html === '') { + return $html; + } + + $sources = \DocumentManager::get_resources_from_source_html($html, false, TOOL_DOCUMENT, 1); + if (empty($sources)) { + return $html; + } + + $roots = array_values(array_unique(array_filter(array_merge([$backupRoot], $extraRoots)))); + + foreach ($sources as $s) { + [$realUrl, $scope, $kind] = $s; + if ($scope !== 'local') { + continue; + } + + $pos = strpos($realUrl, 'document/'); + if ($pos === false) { + continue; + } + + $relAfterDocument = ltrim(substr($realUrl, $pos + strlen('document/')), '/'); + + $candidates = []; + foreach ($roots as $root) { + $base = rtrim($root, '/'); + $candidates[] = $base.'/document/'.$relAfterDocument; + $candidates[] = $base.'/courses/document/'.$relAfterDocument; + } + + $filePath = self::firstExistingPath($candidates); + if (!$filePath) { + continue; + } + + $attached = self::attachLegacyFileWithPublicUrl($filePath, $ownerResource, basename($filePath)); + if (!$attached['ok'] || empty($attached['url'])) { + error_log("LEGACY_REWRITE: failed for $realUrl (".$attached['error'].")"); + continue; + } + + $html = str_replace($realUrl, $attached['url'], $html); + } + + return $html; + } } diff --git a/src/CoreBundle/Resources/views/LearnPath/scorm_list.html.twig b/src/CoreBundle/Resources/views/LearnPath/scorm_list.html.twig index 56038534012..d41dad8c166 100644 --- a/src/CoreBundle/Resources/views/LearnPath/scorm_list.html.twig +++ b/src/CoreBundle/Resources/views/LearnPath/scorm_list.html.twig @@ -11,7 +11,7 @@ {% endif %} {% set itemLevel = 'level_' ~ (item.lvl - 1) %} - {% set itemStatusClass = status_list[item.iid] %} + {% set itemStatusClass = status_list[item.iid]|default('') %} {% set accordionClass = item.itemType == 'dir' and 'true' == chamilo_settings_get('lp.lp_view_accordion') ? 'accordion' : '' %}
diff --git a/src/CoreBundle/Tool/Maintenance.php b/src/CoreBundle/Tool/Maintenance.php index e94bf560253..d4115b17693 100644 --- a/src/CoreBundle/Tool/Maintenance.php +++ b/src/CoreBundle/Tool/Maintenance.php @@ -20,7 +20,7 @@ public function getIcon(): string public function getLink(): string { - return '/main/course_info/maintenance.php'; + return '/resources/course_maintenance/:nodeId/'; } public function getCategory(): string diff --git a/src/CourseBundle/Component/CourseCopy/Course.php b/src/CourseBundle/Component/CourseCopy/Course.php index 01754a93922..024724138d1 100644 --- a/src/CourseBundle/Component/CourseCopy/Course.php +++ b/src/CourseBundle/Component/CourseCopy/Course.php @@ -227,6 +227,20 @@ public function to_system_encoding() case RESOURCE_FORUM: case RESOURCE_QUIZ: case RESOURCE_FORUMCATEGORY: + if (isset($resource->title)) { + $resource->title = api_to_system_encoding($resource->title, $this->encoding); + } + if (isset($resource->description)) { + $resource->description = api_to_system_encoding($resource->description, $this->encoding); + } + if (isset($resource->obj)) { + foreach (['cat_title','cat_comment','title','description'] as $f) { + if (isset($resource->obj->$f) && is_string($resource->obj->$f)) { + $resource->obj->$f = api_to_system_encoding($resource->obj->$f, $this->encoding); + } + } + } + break; case RESOURCE_LINK: case RESOURCE_LINKCATEGORY: case RESOURCE_TEST_CATEGORY: @@ -235,15 +249,15 @@ public function to_system_encoding() break; case RESOURCE_FORUMPOST: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->text = api_to_system_encoding($resource->text, $this->encoding); - $resource->poster_name = api_to_system_encoding($resource->poster_name, $this->encoding); - + if (isset($resource->title)) { $resource->title = api_to_system_encoding($resource->title, $this->encoding); } + if (isset($resource->text)) { $resource->text = api_to_system_encoding($resource->text, $this->encoding); } + if (isset($resource->poster_name)) { $resource->poster_name = api_to_system_encoding($resource->poster_name, $this->encoding); } break; + case RESOURCE_FORUMTOPIC: - $resource->title = api_to_system_encoding($resource->title, $this->encoding); - $resource->topic_poster_name = api_to_system_encoding($resource->topic_poster_name, $this->encoding); - $resource->title_qualify = api_to_system_encoding($resource->title_qualify, $this->encoding); + if (isset($resource->title)) { $resource->title = api_to_system_encoding($resource->title, $this->encoding); } + if (isset($resource->topic_poster_name)) { $resource->topic_poster_name = api_to_system_encoding($resource->topic_poster_name, $this->encoding); } + if (isset($resource->title_qualify)) { $resource->title_qualify = api_to_system_encoding($resource->title_qualify, $this->encoding); } break; case RESOURCE_GLOSSARY: diff --git a/src/CourseBundle/Component/CourseCopy/CourseArchiver.php b/src/CourseBundle/Component/CourseCopy/CourseArchiver.php index 20c48495530..f030315bc08 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseArchiver.php +++ b/src/CourseBundle/Component/CourseCopy/CourseArchiver.php @@ -9,6 +9,7 @@ use DateTime; use PclZip; use Symfony\Component\Filesystem\Filesystem; +use ZipArchive; /** * Some functions to write a course-object to a zip-file and to read a course- @@ -270,16 +271,214 @@ public static function importUploadedFile($file) } /** - * Read a course-object from a zip-file. - * - * @param string $filename - * @param bool $delete Delete the file after reading the course? - * - * @return Course The course + * Read a legacy course backup (.zip) and return a Course object. + * - Extracts the zip into a temp dir. + * - Finds and decodes course_info.dat (base64 + serialize). + * - Registers legacy aliases/stubs BEFORE unserialize (critical). + * - Normalizes common identifier fields to int after unserialize. + */ + public static function readCourse(string $filename, bool $delete = false): false|Course + { + // Clean temp backup dirs and ensure backup dir exists + self::cleanBackupDir(); + self::createBackupDir(); + + $backupDir = rtrim(self::getBackupDir(), '/').'/'; + $zipPath = $backupDir.$filename; + + if (!is_file($zipPath)) { + throw new \RuntimeException('Backup file not found: '.$filename); + } + + // 1) Extract zip into a temp directory + $tmp = $backupDir.'CourseArchiver_'.uniqid('', true).'/'; + (new Filesystem())->mkdir($tmp); + + $zip = new ZipArchive(); + if (true !== $zip->open($zipPath)) { + throw new \RuntimeException('Cannot open zip: '.$filename); + } + if (!$zip->extractTo($tmp)) { + $zip->close(); + throw new \RuntimeException('Cannot extract zip: '.$filename); + } + $zip->close(); + + // 2) Read and decode course_info.dat (base64 + serialize) + $courseInfoDat = $tmp.'course_info.dat'; + if (!is_file($courseInfoDat)) { + // Fallback: search nested locations + $rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmp)); + foreach ($rii as $f) { + if ($f->isFile() && $f->getFilename() === 'course_info.dat') { + $courseInfoDat = $f->getPathname(); + break; + } + } + if (!is_file($courseInfoDat)) { + throw new \RuntimeException('course_info.dat not found in backup'); + } + } + + $raw = file_get_contents($courseInfoDat); + $payload = base64_decode($raw, true); + if ($payload === false) { + throw new \RuntimeException('course_info.dat is not valid base64'); + } + + // 3) Coerce numeric-string identifiers to integers *before* unserialize + // This prevents "Cannot assign string to property ... of type int" + // on typed properties (handles public/protected/private names). + $payload = self::coerceNumericStringsInSerialized($payload); + + // 4) Register legacy aliases BEFORE unserialize (critical for v1 backups) + self::registerLegacyAliases(); + + // 5) Unserialize using UnserializeApi if present (v1-compatible) + if (class_exists('UnserializeApi')) { + /** @var Course $course */ + $course = \UnserializeApi::unserialize('course', $payload); + } else { + /** @var Course|false $course */ + $course = @unserialize($payload, ['allowed_classes' => true]); + } + + if (!is_object($course)) { + throw new \RuntimeException('Could not unserialize legacy course'); + } + + // 6) Normalize common numeric identifiers after unserialize (extra safety) + self::normalizeIds($course); + + // 7) Optionally delete uploaded file (compat with v1) + if ($delete && is_file($zipPath)) { + @unlink($zipPath); + } + + // Keep temp dir until restore phase if files are needed later (compat with v1) + return $course; + } + + /** + * Convert selected numeric-string fields to integers inside the serialized payload + * to avoid "Cannot assign string to property ... of type int" on typed properties. * - * @todo Check if the archive is a correct Chamilo-export + * It handles public, protected ("\0*\0key") and private ("\0Class\0key") property names. + * We only coerce known identifier keys to keep it safe. */ - public static function readCourse($filename, $delete = false) + private static function coerceNumericStringsInSerialized(string $ser): string + { + // Identifier keys that must be integers + $keys = [ + 'id','iid','c_id','parent_id','thematic_id','attendance_id', + 'room_id','display_order','session_id','category_id', + ]; + + /** + * Build a pattern that matches any of these name encodings: + * - public: "id" + * - protected:"\0*\0id" + * - private: "\0SomeClass\0id" + * + * We don't touch the key itself (so its s:N length stays valid). + * We only replace the *value* part: s:M:"123" => i:123 + */ + $alternatives = []; + foreach ($keys as $k) { + // public + $alternatives[] = preg_quote($k, '/'); + // protected + $alternatives[] = "\x00\*\x00".preg_quote($k, '/'); + // private (class-specific; we accept any class name between NULs) + $alternatives[] = "\x00[^\x00]+\x00".preg_quote($k, '/'); + } + $nameAlt = '(?:'.implode('|', $alternatives).')'; + + // Full pattern: + // (s:\d+:"";) s:\d+:"()"; + // Note: we *must not* use /u because of NUL bytes; keep binary-safe regex. + $pattern = '/(s:\d+:"'.$nameAlt.'";)s:\d+:"(\d+)";/s'; + + return preg_replace_callback( + $pattern, + static fn($m) => $m[1].'i:'.$m[2].';', + $ser + ); + } + + + /** + * Recursively cast common identifier fields to int after unserialize. + * Safe to call on arrays/objects/stdClass/legacy resource objects. + */ + private static function normalizeIds(mixed &$node): void + { + $castKeys = [ + 'id','iid','c_id','parent_id','thematic_id','attendance_id', + 'room_id','display_order','session_id','category_id', + ]; + + if (is_array($node)) { + foreach ($node as $k => &$v) { + if (is_string($k) && in_array($k, $castKeys, true) && (is_string($v) || is_numeric($v))) { + $v = (int) $v; + } else { + self::normalizeIds($v); + } + } + return; + } + + if (is_object($node)) { + foreach (get_object_vars($node) as $k => $v) { + if (in_array($k, $castKeys, true) && (is_string($v) || is_numeric($v))) { + $node->$k = (int) $v; + continue; + } + self::normalizeIds($node->$k); + } + } + } + + + /** Keep the old alias map so unserialize works exactly like v1 */ + private static function registerLegacyAliases(): void { + $aliases = [ + 'Chamilo\CourseBundle\Component\CourseCopy\Course' => 'Course', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Announcement' => 'Announcement', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Attendance' => 'Attendance', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\CalendarEvent'=> 'CalendarEvent', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseCopyLearnpath' => 'CourseCopyLearnpath', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseCopyTestCategory' => 'CourseCopyTestCategory', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseDescription' => 'CourseDescription', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseSession' => 'CourseSession', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Document' => 'Document', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Forum' => 'Forum', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\ForumCategory'=> 'ForumCategory', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\ForumPost' => 'ForumPost', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\ForumTopic' => 'ForumTopic', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Glossary' => 'Glossary', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\GradeBookBackup' => 'GradeBookBackup', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Link' => 'Link', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\LinkCategory' => 'LinkCategory', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Quiz' => 'Quiz', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\QuizQuestion' => 'QuizQuestion', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\QuizQuestionOption' => 'QuizQuestionOption', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\ScormDocument'=> 'ScormDocument', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Survey' => 'Survey', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\SurveyInvitation' => 'SurveyInvitation', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\SurveyQuestion'=> 'SurveyQuestion', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Thematic' => 'Thematic', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\ToolIntro' => 'ToolIntro', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Wiki' => 'Wiki', + 'Chamilo\CourseBundle\Component\CourseCopy\Resources\Work' => 'Work', + ]; + + foreach ($aliases as $fqcn => $alias) { + if (!class_exists($alias)) { + class_alias($fqcn, $alias); + } + } } } diff --git a/src/CourseBundle/Component/CourseCopy/CourseBuilder.php b/src/CourseBundle/Component/CourseCopy/CourseBuilder.php index f80dcfc9bbe..f9d5ef8edff 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseBuilder.php +++ b/src/CourseBundle/Component/CourseCopy/CourseBuilder.php @@ -1,319 +1,448 @@ + * CourseBuilder focused on Doctrine/ResourceNode export (keeps legacy orchestration). */ class CourseBuilder { - /** @var Course */ + /** @var Course Legacy course container used by the exporter */ public $course; - /* With this array you can filter the tools you want to be parsed by - default all tools are included */ - public $tools_to_build = [ - 'announcements', - 'attendance', - 'course_descriptions', - 'documents', - 'events', - 'forum_category', - 'forums', - 'forum_topics', - 'glossary', - 'quizzes', - 'test_category', - 'learnpath_category', - 'learnpaths', - 'links', - 'surveys', - 'tool_intro', - 'thematic', - 'wiki', - 'works', - 'gradebook', + /** @var array Only the tools to build (defaults kept) */ + public array $tools_to_build = [ + 'documents', 'forums', 'tool_intro', 'links', 'quizzes', 'quiz_questions', + 'assets', 'surveys', 'survey_questions', 'announcements', 'events', + 'course_descriptions', 'glossary', 'wiki', 'thematic', 'attendance', 'works', + 'gradebook', 'learnpath_category', 'learnpaths', ]; - public $toolToName = [ - 'announcements' => RESOURCE_ANNOUNCEMENT, - 'attendance' => RESOURCE_ATTENDANCE, - 'course_descriptions' => RESOURCE_COURSEDESCRIPTION, - 'documents' => RESOURCE_DOCUMENT, - 'events' => RESOURCE_EVENT, - 'forum_category' => RESOURCE_FORUMCATEGORY, - 'forums' => RESOURCE_FORUM, - 'forum_topics' => RESOURCE_FORUMTOPIC, - 'glossary' => RESOURCE_GLOSSARY, - 'quizzes' => RESOURCE_QUIZ, - 'test_category' => RESOURCE_TEST_CATEGORY, + /** @var array Legacy constant map (extend as you add tools) */ + public array $toolToName = [ + 'documents' => RESOURCE_DOCUMENT, + 'forums' => RESOURCE_FORUM, + 'tool_intro' => RESOURCE_TOOL_INTRO, + 'links' => RESOURCE_LINK, + 'quizzes' => RESOURCE_QUIZ, + 'quiz_questions' => RESOURCE_QUIZQUESTION, + 'assets' => 'asset', + 'surveys' => RESOURCE_SURVEY, + 'survey_questions' => RESOURCE_SURVEYQUESTION, + 'announcements' => RESOURCE_ANNOUNCEMENT, + 'events' => RESOURCE_EVENT, + 'course_descriptions'=> RESOURCE_COURSEDESCRIPTION, + 'glossary' => RESOURCE_GLOSSARY, + 'wiki' => RESOURCE_WIKI, + 'thematic' => RESOURCE_THEMATIC, + 'attendance' => RESOURCE_ATTENDANCE, + 'works' => RESOURCE_WORK, + 'gradebook' => RESOURCE_GRADEBOOK, + 'learnpaths' => RESOURCE_LEARNPATH, 'learnpath_category' => RESOURCE_LEARNPATH_CATEGORY, - 'learnpaths' => RESOURCE_LEARNPATH, - 'links' => RESOURCE_LINK, - 'surveys' => RESOURCE_SURVEY, - 'tool_intro' => RESOURCE_TOOL_INTRO, - 'thematic' => RESOURCE_THEMATIC, - 'wiki' => RESOURCE_WIKI, - 'works' => RESOURCE_WORK, - 'gradebook' => RESOURCE_GRADEBOOK, ]; - /* With this array you can filter wich elements of the tools are going - to be added in the course obj (only works with LPs) */ - public $specific_id_list = []; - public $documentsAddedInText = []; + /** @var array> Optional whitelist of IDs per tool */ + public array $specific_id_list = []; + + /** @var array Documents referenced inside HTML */ + public array $documentsAddedInText = []; + + /** Doctrine services */ + private $em = null; // Doctrine EntityManager + private $docRepo = null; // CDocumentRepository /** - * Create a new CourseBuilder. + * Constructor (keeps legacy init; wires Doctrine repositories). * - * @param string $type - * @param null $course + * @param string $type 'partial'|'complete' + * @param array|null $course Optional course info array */ public function __construct($type = '', $course = null) { + // Legacy behavior preserved $_course = api_get_course_info(); - if (!empty($course['official_code'])) { $_course = $course; } - $this->course = new Course(); - $this->course->code = $_course['code']; - $this->course->type = $type; - // $this->course->path = api_get_path(SYS_COURSE_PATH).$_course['path'].'/'; - // $this->course->backup_path = api_get_path(SYS_COURSE_PATH).$_course['path']; + $this->course = new Course(); + $this->course->code = $_course['code']; + $this->course->type = $type; $this->course->encoding = api_get_system_encoding(); - $this->course->info = $_course; + $this->course->info = $_course; + + $this->em = Database::getManager(); + $this->docRepo = Container::getDocumentRepository(); + + // Use $this->em / $this->docRepo in build_documents() when needed. } /** - * @param array $list + * Merge a parsed list of document refs into memory. + * + * @param array $list */ - public function addDocumentList($list) + public function addDocumentList(array $list): void { foreach ($list as $item) { - if (!in_array($item[0], $this->documentsAddedInText)) { + if (!in_array($item[0], $this->documentsAddedInText, true)) { $this->documentsAddedInText[$item[0]] = $item; } } } /** - * @param string $text + * Parse HTML and collect referenced course documents. + * + * @param string $html HTML content */ - public function findAndSetDocumentsInText($text) + public function findAndSetDocumentsInText(string $html = ''): void { - $documentList = DocumentManager::get_resources_from_source_html($text); + if ($html === '') { + return; + } + $documentList = \DocumentManager::get_resources_from_source_html($html); $this->addDocumentList($documentList); } /** - * Parse documents added in the documentsAddedInText variable. + * Resolve collected HTML links to CDocument iids via the ResourceNode tree and build them. + * + * @return void */ - public function restoreDocumentsFromList() + public function restoreDocumentsFromList(): void { - if (!empty($this->documentsAddedInText)) { - $list = []; - $courseInfo = api_get_course_info(); - foreach ($this->documentsAddedInText as $item) { - // Get information about source url - $url = $item[0]; // url - $scope = $item[1]; // scope (local, remote) - $type = $item[2]; // type (rel, abs, url) - - $origParseUrl = parse_url($url); - $realOrigPath = isset($origParseUrl['path']) ? $origParseUrl['path'] : null; - - if ('local' == $scope) { - if ('abs' == $type || 'rel' == $type) { - $documentFile = strstr($realOrigPath, 'document'); - if (false !== strpos($realOrigPath, $documentFile)) { - $documentFile = str_replace('document', '', $documentFile); - $itemDocumentId = DocumentManager::get_document_id($courseInfo, $documentFile); - // Document found! Add it to the list - if ($itemDocumentId) { - $list[] = $itemDocumentId; - } - } - } - } + if (empty($this->documentsAddedInText)) { + return; + } + + $courseInfo = api_get_course_info(); + $courseCode = (string) ($courseInfo['code'] ?? ''); + if ($courseCode === '') { + return; + } + + /** @var CourseEntity|null $course */ + $course = $this->em->getRepository(CourseEntity::class)->findOneBy(['code' => $courseCode]); + if (!$course instanceof CourseEntity) { + return; + } + + // Documents root under the course + $root = $this->docRepo->getCourseDocumentsRootNode($course); + if (!$root instanceof ResourceNode) { + return; + } + + $iids = []; + + foreach ($this->documentsAddedInText as $item) { + [$url, $scope, $type] = $item; // url, scope(local/remote), type(rel/abs/url) + if ($scope !== 'local' || !\in_array($type, ['rel', 'abs'], true)) { + continue; + } + + $segments = $this->extractDocumentSegmentsFromUrl((string) $url); + if (!$segments) { + continue; } + // Walk the ResourceNode tree by matching child titles + $node = $this->resolveNodeBySegments($root, $segments); + if (!$node) { + continue; + } + + $resource = $this->docRepo->getResourceByResourceNode($node); + if ($resource instanceof CDocument && is_int($resource->getIid())) { + $iids[] = $resource->getIid(); + } + } + + $iids = array_values(array_unique($iids)); + if ($iids) { $this->build_documents( api_get_session_id(), - api_get_course_int_id(), + (int) $course->getId(), true, - $list + $iids ); } } /** - * @param array $array + * Extract path segments after "/document". + * + * @param string $url + * @return array */ - public function set_tools_to_build($array) + private function extractDocumentSegmentsFromUrl(string $url): array + { + $decoded = urldecode($url); + if (!preg_match('#/document(/.*)$#', $decoded, $m)) { + return []; + } + $tail = trim($m[1], '/'); // e.g. "Folder/Sub/file.pdf" + if ($tail === '') { + return []; + } + + $parts = array_values(array_filter(explode('/', $tail), static fn($s) => $s !== '')); + return array_map(static fn($s) => trim($s), $parts); + } + + /** + * Walk children by title from a given parent node. + * + * @param ResourceNode $parent + * @param array $segments + * @return ResourceNode|null + */ + private function resolveNodeBySegments(ResourceNode $parent, array $segments): ?ResourceNode + { + $node = $parent; + foreach ($segments as $title) { + $child = $this->docRepo->findChildNodeByTitle($node, $title); + if (!$child instanceof ResourceNode) { + return null; + } + $node = $child; + } + return $node; + } + + /** + * Set tools to build. + * + * @param array $array + */ + public function set_tools_to_build(array $array): void { $this->tools_to_build = $array; } /** - * @param array $array + * Set specific id list per tool. + * + * @param array> $array */ - public function set_tools_specific_id_list($array) + public function set_tools_specific_id_list(array $array): void { $this->specific_id_list = $array; } /** - * Get the created course. + * Get legacy Course container. * - * @return course The course + * @return Course */ - public function get_course() + public function get_course(): Course { return $this->course; } /** - * Build the course-object. - * - * @param int $session_id - * @param string $courseCode - * @param bool $withBaseContent true if you want to get the elements that exists in the course and - * in the session, (session_id = 0 or session_id = X) - * @param array $parseOnlyToolList - * @param array $toolsFromPost + * Build the course (documents already repo-based; other tools preserved). * - * @return Course The course object structure + * @param int $session_id + * @param string $courseCode + * @param bool $withBaseContent + * @param array $parseOnlyToolList + * @param array $toolsFromPost + * @return Course */ public function build( - $session_id = 0, - $courseCode = '', - $withBaseContent = false, - $parseOnlyToolList = [], - $toolsFromPost = [] - ) { - $course = api_get_course_info($courseCode); - $courseId = $course['real_id']; - foreach ($this->tools_to_build as $tool) { - if (!empty($parseOnlyToolList) && !in_array($this->toolToName[$tool], $parseOnlyToolList)) { - continue; - } - $function_build = 'build_'.$tool; - $specificIdList = isset($this->specific_id_list[$tool]) ? $this->specific_id_list[$tool] : null; - $buildOrphanQuestions = true; - if ('quizzes' === $tool) { - if (!isset($toolsFromPost[RESOURCE_QUIZ][-1])) { - $buildOrphanQuestions = false; + int $session_id = 0, + string $courseCode = '', + bool $withBaseContent = false, + array $parseOnlyToolList = [], + array $toolsFromPost = [] + ): Course { + /** @var CourseEntity|null $courseEntity */ + $courseEntity = $courseCode !== '' + ? $this->em->getRepository(CourseEntity::class)->findOneBy(['code' => $courseCode]) + : $this->em->getRepository(CourseEntity::class)->find(api_get_course_int_id()); + + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $session_id + ? $this->em->getRepository(SessionEntity::class)->find($session_id) + : null; + + // Legacy DTO where resources[...] are built + $legacyCourse = $this->course; + + foreach ($this->tools_to_build as $toolKey) { + if (!empty($parseOnlyToolList)) { + $const = $this->toolToName[$toolKey] ?? null; + if ($const !== null && !in_array($const, $parseOnlyToolList, true)) { + continue; } + } - // Force orphan load - if ('complete' === $this->course->type) { - $buildOrphanQuestions = true; - } + if ($toolKey === 'documents') { + $ids = $this->specific_id_list['documents'] ?? []; + $this->build_documents_with_repo($courseEntity, $sessionEntity, $withBaseContent, $ids); + } + + if ($toolKey === 'forums' || $toolKey === 'forum') { + $ids = $this->specific_id_list['forums'] ?? $this->specific_id_list['forum'] ?? []; + $this->build_forum_category($legacyCourse, $courseEntity, $sessionEntity, $ids); + $this->build_forums($legacyCourse, $courseEntity, $sessionEntity, $ids); + $this->build_forum_topics($legacyCourse, $courseEntity, $sessionEntity, $ids); + $this->build_forum_posts($legacyCourse, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'tool_intro') { + $this->build_tool_intro($legacyCourse, $courseEntity, $sessionEntity); + } - $this->build_quizzes( - $session_id, - $courseId, - $withBaseContent, - $specificIdList, - $buildOrphanQuestions + if ($toolKey === 'links') { + $ids = $this->specific_id_list['links'] ?? []; + $this->build_links($legacyCourse, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'quizzes' || $toolKey === 'quiz') { + $ids = $this->specific_id_list['quizzes'] ?? $this->specific_id_list['quiz'] ?? []; + $neededQuestionIds = $this->build_quizzes($legacyCourse, $courseEntity, $sessionEntity, $ids); + // Always export question bucket required by the quizzes + $this->build_quiz_questions($legacyCourse, $courseEntity, $sessionEntity, $neededQuestionIds); + error_log( + 'COURSE_BUILD: quizzes='.count($legacyCourse->resources[RESOURCE_QUIZ] ?? []). + ' quiz_questions='.count($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? []) ); - } else { - $this->$function_build( - $session_id, - $courseId, - $withBaseContent, - $specificIdList + } + + if ($toolKey === 'quiz_questions') { + $ids = $this->specific_id_list['quiz_questions'] ?? []; + $this->build_quiz_questions($legacyCourse, $courseEntity, $sessionEntity, $ids); + error_log( + 'COURSE_BUILD: explicit quiz_questions='.count($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? []) ); } - } - // Add asset - /*if ($course['course_image_source'] && basename($course['course_image_source']) !== 'course.png') { - // Add course image courses/XXX/course-pic85x85.png - $asset = new Asset( - $course['course_image_source'], - basename($course['course_image_source']), - basename($course['course_image_source']) - ); - $this->course->add_resource($asset); + if ($toolKey === 'surveys' || $toolKey === 'survey') { + $ids = $this->specific_id_list['surveys'] ?? $this->specific_id_list['survey'] ?? []; + $neededQ = $this->build_surveys($this->course, $courseEntity, $sessionEntity, $ids); + $this->build_survey_questions($this->course, $courseEntity, $sessionEntity, $neededQ); + } - $asset = new Asset( - $course['course_image_large_source'], - basename($course['course_image_large_source']), - basename($course['course_image_large_source']) - ); - $this->course->add_resource($asset); - }*/ - - // Once we've built the resources array a bit more, try to get items - // from the item_property table and order them in the "resources" array - $table = Database::get_course_table(TABLE_ITEM_PROPERTY); - foreach ($this->course->resources as $type => $resources) { - if (!empty($parseOnlyToolList) && !in_array($this->toolToName[$tool], $parseOnlyToolList)) { - continue; + if ($toolKey === 'survey_questions') { + $this->build_survey_questions($this->course, $courseEntity, $sessionEntity, []); } - foreach ($resources as $id => $resource) { - if ($resource) { - $tool = $resource->get_tool(); - if (null != $tool) { - $sql = "SELECT * FROM $table - WHERE - c_id = $courseId AND - tool = '".$tool."' AND - ref = '".$resource->get_id()."'"; - $res = Database::query($sql); - $properties = []; - while ($property = Database::fetch_array($res)) { - $properties[] = $property; - } - $this->course->resources[$type][$id]->item_properties = $properties; - } - } + + if ($toolKey === 'announcements') { + $ids = $this->specific_id_list['announcements'] ?? []; + $this->build_announcements($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'events') { + $ids = $this->specific_id_list['events'] ?? []; + $this->build_events($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'course_descriptions') { + $ids = $this->specific_id_list['course_descriptions'] ?? []; + $this->build_course_descriptions($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'glossary') { + $ids = $this->specific_id_list['glossary'] ?? []; + $this->build_glossary($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'wiki') { + $ids = $this->specific_id_list['wiki'] ?? []; + $this->build_wiki($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'thematic') { + $ids = $this->specific_id_list['thematic'] ?? []; + $this->build_thematic($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'attendance') { + $ids = $this->specific_id_list['attendance'] ?? []; + $this->build_attendance($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'works') { + $ids = $this->specific_id_list['works'] ?? []; + $this->build_works($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'gradebook') { + $this->build_gradebook($this->course, $courseEntity, $sessionEntity); + } + + if ($toolKey === 'learnpath_category') { + $ids = $this->specific_id_list['learnpath_category'] ?? []; + $this->build_learnpath_category($this->course, $courseEntity, $sessionEntity, $ids); + } + + if ($toolKey === 'learnpaths') { + $ids = $this->specific_id_list['learnpaths'] ?? []; + $this->build_learnpaths($this->course, $courseEntity, $sessionEntity, $ids, true); } } @@ -321,1586 +450,1860 @@ public function build( } /** - * Build the documents. + * Export Learnpath categories (CLpCategory). * - * @param int $session_id - * @param int $courseId - * @param bool $withBaseContent - * @param array $idList + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_documents( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - $table_doc = Database::get_course_table(TABLE_DOCUMENT); - $table_prop = Database::get_course_table(TABLE_ITEM_PROPERTY); - - // Remove chat_files and shared_folder files - $avoid_paths = " - path NOT LIKE '/shared_folder%' AND - path NOT LIKE '/chat_files%' AND - path NOT LIKE '/../exercises/%' - "; - $documentCondition = ''; - if (!empty($idList)) { - $idList = array_unique($idList); - $idList = array_map('intval', $idList); - $documentCondition = ' d.iid IN ("'.implode('","', $idList).'") AND '; - } - - if (!empty($courseId) && !empty($session_id)) { - $session_id = (int) $session_id; - if ($withBaseContent) { - $session_condition = api_get_session_condition( - $session_id, - true, - true, - 'd.session_id' - ); - } else { - $session_condition = api_get_session_condition( - $session_id, - true, - false, - 'd.session_id' - ); - } + private function build_learnpath_category( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } - if (!empty($this->course->type) && 'partial' == $this->course->type) { - $sql = "SELECT d.iid, d.path, d.comment, d.title, d.filetype, d.size - FROM $table_doc d - INNER JOIN $table_prop p - ON (p.ref = d.id AND d.c_id = p.c_id) - WHERE - d.c_id = $courseId AND - p.c_id = $courseId AND - tool = '".TOOL_DOCUMENT."' AND - $documentCondition - p.visibility != 2 AND - path NOT LIKE '/images/gallery%' AND - $avoid_paths - $session_condition - ORDER BY path"; - } else { - $sql = "SELECT d.iid, d.path, d.comment, d.title, d.filetype, d.size - FROM $table_doc d - INNER JOIN $table_prop p - ON (p.ref = d.id AND d.c_id = p.c_id) - WHERE - d.c_id = $courseId AND - p.c_id = $courseId AND - tool = '".TOOL_DOCUMENT."' AND - $documentCondition - $avoid_paths AND - p.visibility != 2 $session_condition - ORDER BY path"; - } + $repo = Container::getLpCategoryRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $doc = new Document( - $obj->iid, - $obj->path, - $obj->comment, - $obj->title, - $obj->filetype, - $obj->size - ); - $this->course->add_resource($doc); - } - } else { - if (!empty($this->course->type) && 'partial' == $this->course->type) { - $sql = "SELECT d.iid, d.path, d.comment, d.title, d.filetype, d.size - FROM $table_doc d - INNER JOIN $table_prop p - ON (p.ref = d.id AND d.c_id = p.c_id) - WHERE - d.c_id = $courseId AND - p.c_id = $courseId AND - tool = '".TOOL_DOCUMENT."' AND - $documentCondition - p.visibility != 2 AND - path NOT LIKE '/images/gallery%' AND - $avoid_paths AND - (d.session_id = 0 OR d.session_id IS NULL) - ORDER BY path"; - } else { - $sql = "SELECT d.iid, d.path, d.comment, d.title, d.filetype, d.size - FROM $table_doc d - INNER JOIN $table_prop p - ON (p.ref = d.id AND d.c_id = p.c_id) - WHERE - d.c_id = $courseId AND - p.c_id = $courseId AND - tool = '".TOOL_DOCUMENT."' AND - $documentCondition - p.visibility != 2 AND - $avoid_paths AND - (d.session_id = 0 OR d.session_id IS NULL) - ORDER BY path"; - } + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); + } - $result = Database::query($sql); - while ($obj = Database::fetch_object($result)) { - $doc = new Document( - $obj->iid, - $obj->path, - $obj->comment, - $obj->title, - $obj->filetype, - $obj->size - ); - $this->course->add_resource($doc); - } + /** @var CLpCategory[] $rows */ + $rows = $qb->getQuery()->getResult(); + + foreach ($rows as $cat) { + $iid = (int) $cat->getIid(); + $title = (string) $cat->getTitle(); + + $payload = [ + 'id' => $iid, + 'title' => $title, + ]; + + $legacyCourse->resources[RESOURCE_LEARNPATH_CATEGORY][$iid] = + $this->mkLegacyItem(RESOURCE_LEARNPATH_CATEGORY, $iid, $payload); } } /** - * Build the forums. + * Export Learnpaths (CLp) + items, with optional SCORM folder packing. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $idList + * @param bool $addScormFolder + * @return void */ - public function build_forums( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - $table = Database::get_course_table(TABLE_FORUM); - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); + private function build_learnpaths( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $idList = [], + bool $addScormFolder = true + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } + + $lpRepo = Container::getLpRepository(); + $qb = $lpRepo->getResourcesByCourse($courseEntity, $sessionEntity); - $idCondition = ''; if (!empty($idList)) { - $idList = array_unique($idList); - $idList = array_map('intval', $idList); - $idCondition = ' AND iid IN ("'.implode('","', $idList).'") '; + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $idList)))); } - $sql = "SELECT * FROM $table WHERE c_id = $courseId $sessionCondition $idCondition"; - $sql .= ' ORDER BY forum_title, forum_category'; - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $forum = new Forum($obj); - $this->course->add_resource($forum); + /** @var CLp[] $lps */ + $lps = $qb->getQuery()->getResult(); + + foreach ($lps as $lp) { + $iid = (int) $lp->getIid(); + $lpType = (int) $lp->getLpType(); // 1=LP, 2=SCORM, 3=AICC + + $items = []; + /** @var CLpItem $it */ + foreach ($lp->getItems() as $it) { + $items[] = [ + 'id' => (int) $it->getIid(), + 'item_type' => (string) $it->getItemType(), + 'ref' => (string) $it->getRef(), + 'title' => (string) $it->getTitle(), + 'name' => (string) $lp->getTitle(), + 'description' => (string) ($it->getDescription() ?? ''), + 'path' => (string) $it->getPath(), + 'min_score' => (float) $it->getMinScore(), + 'max_score' => $it->getMaxScore() !== null ? (float) $it->getMaxScore() : null, + 'mastery_score' => $it->getMasteryScore() !== null ? (float) $it->getMasteryScore() : null, + 'parent_item_id' => (int) $it->getParentItemId(), + 'previous_item_id' => $it->getPreviousItemId() !== null ? (int) $it->getPreviousItemId() : null, + 'next_item_id' => $it->getNextItemId() !== null ? (int) $it->getNextItemId() : null, + 'display_order' => (int) $it->getDisplayOrder(), + 'prerequisite' => (string) ($it->getPrerequisite() ?? ''), + 'parameters' => (string) ($it->getParameters() ?? ''), + 'launch_data' => (string) $it->getLaunchData(), + 'audio' => (string) ($it->getAudio() ?? ''), + ]; + } + + $payload = [ + 'id' => $iid, + 'lp_type' => $lpType, + 'title' => (string) $lp->getTitle(), + 'path' => (string) $lp->getPath(), + 'ref' => (string) ($lp->getRef() ?? ''), + 'description' => (string) ($lp->getDescription() ?? ''), + 'content_local' => (string) $lp->getContentLocal(), + 'default_encoding' => (string) $lp->getDefaultEncoding(), + 'default_view_mod' => (string) $lp->getDefaultViewMod(), + 'prevent_reinit' => (bool) $lp->getPreventReinit(), + 'force_commit' => (bool) $lp->getForceCommit(), + 'content_maker' => (string) $lp->getContentMaker(), + 'display_order' => (int) $lp->getDisplayNotAllowedLp(), + 'js_lib' => (string) $lp->getJsLib(), + 'content_license' => (string) $lp->getContentLicense(), + 'debug' => (bool) $lp->getDebug(), + 'visibility' => '1', + 'author' => (string) $lp->getAuthor(), + 'use_max_score' => (int) $lp->getUseMaxScore(), + 'autolaunch' => (int) $lp->getAutolaunch(), + 'created_on' => $this->fmtDate($lp->getCreatedOn()), + 'modified_on' => $this->fmtDate($lp->getModifiedOn()), + 'published_on' => $this->fmtDate($lp->getPublishedOn()), + 'expired_on' => $this->fmtDate($lp->getExpiredOn()), + 'session_id' => (int) ($sessionEntity?->getId() ?? 0), + 'category_id' => (int) ($lp->getCategory()?->getIid() ?? 0), + 'items' => $items, + ]; + + $legacyCourse->resources[RESOURCE_LEARNPATH][$iid] = + $this->mkLegacyItem(RESOURCE_LEARNPATH, $iid, $payload, ['items']); + } + + // Optional: pack “scorm” folder (legacy parity) + if ($addScormFolder && isset($this->course->backup_path)) { + $scormDir = rtrim((string) $this->course->backup_path, '/') . '/scorm'; + if (is_dir($scormDir) && ($dh = @opendir($scormDir))) { + $i = 1; + while (false !== ($file = readdir($dh))) { + if ($file === '.' || $file === '..') { + continue; + } + if (is_dir($scormDir . '/' . $file)) { + $payload = ['path' => '/' . $file, 'name' => (string) $file]; + $legacyCourse->resources['scorm'][$i] = + $this->mkLegacyItem('scorm', $i, $payload); + $i++; + } + } + closedir($dh); + } } } /** - * Build a forum-category. + * Export Gradebook (categories + evaluations + links). * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @return void */ - public function build_forum_category( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - $table = Database::get_course_table(TABLE_FORUM_CATEGORY); - - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); + private function build_gradebook( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } - $idCondition = ''; - if (!empty($idList)) { - $idList = array_unique($idList); - $idList = array_map('intval', $idList); - $idCondition = ' AND iid IN ("'.implode('","', $idList).'") '; + /** @var EntityManagerInterface $em */ + $em = \Database::getManager(); + $catRepo = $em->getRepository(GradebookCategory::class); + + $criteria = ['course' => $courseEntity]; + if ($sessionEntity) { + $criteria['session'] = $sessionEntity; } - $sql = "SELECT * FROM $table - WHERE c_id = $courseId $sessionCondition $idCondition - ORDER BY title"; + /** @var GradebookCategory[] $cats */ + $cats = $catRepo->findBy($criteria); + if (!$cats) { + return; + } - $result = Database::query($sql); - while ($obj = Database::fetch_object($result)) { - $forumCategory = new ForumCategory($obj); - $this->course->add_resource($forumCategory); + $payloadCategories = []; + foreach ($cats as $cat) { + $payloadCategories[] = $this->serializeGradebookCategory($cat); } + + $backup = new GradeBookBackup($payloadCategories); + $legacyCourse->add_resource($backup); } /** - * Build the forum-topics. + * Serialize GradebookCategory (and nested parts) to array for restore. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs + * @param GradebookCategory $c + * @return array */ - public function build_forum_topics( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - $table = Database::get_course_table(TABLE_FORUM_THREAD); - - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); + private function serializeGradebookCategory(GradebookCategory $c): array + { + $arr = [ + 'id' => (int) $c->getId(), + 'title' => (string) $c->getTitle(), + 'description' => (string) ($c->getDescription() ?? ''), + 'weight' => (float) $c->getWeight(), + 'visible' => (bool) $c->getVisible(), + 'locked' => (int) $c->getLocked(), + 'parent_id' => $c->getParent() ? (int) $c->getParent()->getId() : 0, + 'generate_certificates' => (bool) $c->getGenerateCertificates(), + 'certificate_validity_period'=> $c->getCertificateValidityPeriod(), + 'is_requirement' => (bool) $c->getIsRequirement(), + 'default_lowest_eval_exclude'=> (bool) $c->getDefaultLowestEvalExclude(), + 'minimum_to_validate' => $c->getMinimumToValidate(), + 'gradebooks_to_validate_in_dependence' => $c->getGradeBooksToValidateInDependence(), + 'allow_skills_by_subcategory'=> $c->getAllowSkillsBySubcategory(), + // camelCase duplicates (future-proof) + 'generateCertificates' => (bool) $c->getGenerateCertificates(), + 'certificateValidityPeriod' => $c->getCertificateValidityPeriod(), + 'isRequirement' => (bool) $c->getIsRequirement(), + 'defaultLowestEvalExclude' => (bool) $c->getDefaultLowestEvalExclude(), + 'minimumToValidate' => $c->getMinimumToValidate(), + 'gradeBooksToValidateInDependence' => $c->getGradeBooksToValidateInDependence(), + 'allowSkillsBySubcategory' => $c->getAllowSkillsBySubcategory(), + ]; - $idCondition = ''; - if (!empty($idList)) { - $idList = array_map('intval', $idList); - $idCondition = ' AND iid IN ("'.implode('","', $idList).'") '; + if ($c->getGradeModel()) { + $arr['grade_model_id'] = (int) $c->getGradeModel()->getId(); } - $sql = "SELECT * FROM $table WHERE c_id = $courseId - $sessionCondition - $idCondition - ORDER BY title "; - $result = Database::query($sql); + // Evaluations + $arr['evaluations'] = []; + foreach ($c->getEvaluations() as $e) { + /** @var GradebookEvaluation $e */ + $arr['evaluations'][] = [ + 'title' => (string) $e->getTitle(), + 'description' => (string) ($e->getDescription() ?? ''), + 'weight' => (float) $e->getWeight(), + 'max' => (float) $e->getMax(), + 'type' => (string) $e->getType(), + 'visible' => (int) $e->getVisible(), + 'locked' => (int) $e->getLocked(), + 'best_score' => $e->getBestScore(), + 'average_score' => $e->getAverageScore(), + 'score_weight' => $e->getScoreWeight(), + 'min_score' => $e->getMinScore(), + ]; + } - while ($obj = Database::fetch_object($result)) { - $forumTopic = new ForumTopic($obj); - $this->course->add_resource($forumTopic); - $this->build_forum_posts($courseId, $obj->thread_id, $obj->forum_id); + // Links + $arr['links'] = []; + foreach ($c->getLinks() as $l) { + /** @var GradebookLink $l */ + $arr['links'][] = [ + 'type' => (int) $l->getType(), + 'ref_id' => (int) $l->getRefId(), + 'weight' => (float) $l->getWeight(), + 'visible' => (int) $l->getVisible(), + 'locked' => (int) $l->getLocked(), + 'best_score' => $l->getBestScore(), + 'average_score' => $l->getAverageScore(), + 'score_weight' => $l->getScoreWeight(), + 'min_score' => $l->getMinScore(), + ]; } + + return $arr; } /** - * Build the forum-posts - * TODO: All tree structure of posts should be built, attachments for example. + * Export Works (root folders only; include assignment params). * - * @param int $courseId Internal course ID - * @param int $thread_id Internal thread ID - * @param int $forum_id Internal forum ID - * @param array $idList + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_forum_posts( - $courseId = 0, - $thread_id = null, - $forum_id = null, - $idList = [] - ) { - $table = Database::get_course_table(TABLE_FORUM_POST); - $courseId = (int) $courseId; - $sql = "SELECT * FROM $table WHERE c_id = $courseId "; - if (!empty($thread_id) && !empty($forum_id)) { - $forum_id = (int) $forum_id; - $thread_id = (int) $thread_id; - $sql .= " AND thread_id = $thread_id AND forum_id = $forum_id "; + private function build_works( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; } - if (!empty($idList)) { - $idList = array_map('intval', $idList); - $sql .= ' AND iid IN ("'.implode('","', $idList).'") '; + $repo = Container::getStudentPublicationRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); + + $qb + ->andWhere('resource.publicationParent IS NULL') + ->andWhere('resource.filetype = :ft')->setParameter('ft', 'folder') + ->andWhere('resource.active = 1'); + + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); } - $sql .= ' ORDER BY post_id ASC LIMIT 1'; - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $forum_post = new ForumPost($obj); - $this->course->add_resource($forum_post); + /** @var CStudentPublication[] $rows */ + $rows = $qb->getQuery()->getResult(); + + foreach ($rows as $row) { + $iid = (int) $row->getIid(); + $title = (string) $row->getTitle(); + $desc = (string) ($row->getDescription() ?? ''); + + // Detect documents linked in description + $this->findAndSetDocumentsInText($desc); + + $asgmt = $row->getAssignment(); + $expiresOn = $asgmt?->getExpiresOn()?->format('Y-m-d H:i:s'); + $endsOn = $asgmt?->getEndsOn()?->format('Y-m-d H:i:s'); + $addToCal = $asgmt && $asgmt->getEventCalendarId() > 0 ? 1 : 0; + $enableQ = (bool) ($asgmt?->getEnableQualification() ?? 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, + ]; + + $legacy = new Work($params); + $legacyCourse->add_resource($legacy); } } /** - * Build the links. + * Export Attendance + calendars. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_links( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - $categories = LinkManager::getLinkCategories( - $courseId, - $session_id, - $withBaseContent - ); + private function build_attendance( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } - // Adding empty category - $categories[] = ['id' => 0]; + $repo = Container::getAttendanceRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); - foreach ($categories as $category) { - $this->build_link_category($category); + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); + } - $links = LinkManager::getLinksPerCategory( - $category['id'], - $courseId, - $session_id, - $withBaseContent - ); + /** @var CAttendance[] $rows */ + $rows = $qb->getQuery()->getResult(); + + foreach ($rows as $row) { + $iid = (int) $row->getIid(); + $title = (string) $row->getTitle(); + $desc = (string) ($row->getDescription() ?? ''); + $active = (int) $row->getActive(); + + $this->findAndSetDocumentsInText($desc); + + $params = [ + 'id' => $iid, + 'title' => $title, + 'description' => $desc, + 'active' => $active, + 'attendance_qualify_title' => (string) ($row->getAttendanceQualifyTitle() ?? ''), + 'attendance_qualify_max' => (int) $row->getAttendanceQualifyMax(), + 'attendance_weight' => (float) $row->getAttendanceWeight(), + 'locked' => (int) $row->getLocked(), + 'name' => $title, + ]; + + $legacy = new Attendance($params); + + /** @var CAttendanceCalendar $cal */ + foreach ($row->getCalendars() as $cal) { + $calArr = [ + 'id' => (int) $cal->getIid(), + 'attendance_id' => $iid, + 'date_time' => $cal->getDateTime()?->format('Y-m-d H:i:s') ?? '', + 'done_attendance' => (bool) $cal->getDoneAttendance(), + 'blocked' => (bool) $cal->getBlocked(), + 'duration' => $cal->getDuration() !== null ? (int) $cal->getDuration() : null, + ]; + $legacy->add_attendance_calendar($calArr); + } - foreach ($links as $item) { - if (!empty($idList)) { - if (!in_array($item['id'], $idList)) { - continue; + $legacyCourse->add_resource($legacy); + } + } + + /** + * Export Thematic + advances + plans (and collect linked docs). + * + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void + */ + private function build_thematic( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } + + $repo = Container::getThematicRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); + + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); + } + + /** @var CThematic[] $rows */ + $rows = $qb->getQuery()->getResult(); + + foreach ($rows as $row) { + $iid = (int) $row->getIid(); + $title = (string) $row->getTitle(); + $content = (string) ($row->getContent() ?? ''); + $active = (bool) $row->getActive(); + + $this->findAndSetDocumentsInText($content); + + $params = [ + 'id' => $iid, + 'title' => $title, + 'content' => $content, + 'active' => $active, + ]; + + $legacy = new Thematic($params); + + /** @var CThematicAdvance $adv */ + foreach ($row->getAdvances() as $adv) { + $attendanceId = 0; + try { + $refAtt = new \ReflectionProperty(CThematicAdvance::class, 'attendance'); + if ($refAtt->isInitialized($adv)) { + $att = $adv->getAttendance(); + if ($att) { + $attendanceId = (int) $att->getIid(); + } } + } catch (\Throwable) { + // keep $attendanceId = 0 } - $link = new Link( - $item['id'], - $item['title'], - $item['url'], - $item['description'], - $item['category_id'], - $item['on_homepage'] - ); - $link->target = $item['target']; - $this->course->add_resource($link); - $this->course->resources[RESOURCE_LINK][$item['id']]->add_linked_resource( - RESOURCE_LINKCATEGORY, - $item['category_id'] - ); + $advArr = [ + 'id' => (int) $adv->getIid(), + 'thematic_id' => (int) $row->getIid(), + 'content' => (string) ($adv->getContent() ?? ''), + 'start_date' => $adv->getStartDate()?->format('Y-m-d H:i:s') ?? '', + 'duration' => (int) $adv->getDuration(), + 'done_advance' => (bool) $adv->getDoneAdvance(), + 'attendance_id' => $attendanceId, + 'room_id' => (int) ($adv->getRoom()?->getId() ?? 0), + ]; + + $this->findAndSetDocumentsInText((string) $advArr['content']); + $legacy->addThematicAdvance($advArr); + } + + /** @var CThematicPlan $pl */ + foreach ($row->getPlans() as $pl) { + $plArr = [ + 'id' => (int) $pl->getIid(), + 'thematic_id' => $iid, + 'title' => (string) $pl->getTitle(), + 'description' => (string) ($pl->getDescription() ?? ''), + 'description_type' => (int) $pl->getDescriptionType(), + ]; + $this->findAndSetDocumentsInText((string) $plArr['description']); + $legacy->addThematicPlan($plArr); } + + $legacyCourse->add_resource($legacy); } } /** - * Build tool intro. + * Export Wiki pages (content + metadata; collect docs in content). * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_tool_intro( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - $table = Database::get_course_table(TABLE_TOOL_INTRO); - - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); + private function build_wiki( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } - $courseId = (int) $courseId; + $repo = Container::getWikiRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); - $sql = "SELECT * FROM $table - WHERE c_id = $courseId $sessionCondition"; + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); + } - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $tool_intro = new ToolIntro($obj->id, $obj->intro_text); - $this->course->add_resource($tool_intro); + /** @var CWiki[] $pages */ + $pages = $qb->getQuery()->getResult(); + + foreach ($pages as $page) { + $iid = (int) $page->getIid(); + $pageId = (int) ($page->getPageId() ?? $iid); + $reflink = (string) $page->getReflink(); + $title = (string) $page->getTitle(); + $content = (string) $page->getContent(); + $userId = (int) $page->getUserId(); + $groupId = (int) ($page->getGroupId() ?? 0); + $progress = (string) ($page->getProgress() ?? ''); + $version = (int) ($page->getVersion() ?? 1); + $dtime = $page->getDtime()?->format('Y-m-d H:i:s') ?? ''; + + $this->findAndSetDocumentsInText($content); + + $legacy = new Wiki( + $iid, + $pageId, + $reflink, + $title, + $content, + $userId, + $groupId, + $dtime, + $progress, + $version + ); + + $this->course->add_resource($legacy); } } /** - * Build a link category. - * - * @param int $category Internal link ID + * Export Glossary terms (collect docs in descriptions). * - * @return int + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_link_category($category) - { - if (empty($category) || empty($category['category_title'])) { - return 0; + private function build_glossary( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; } - $linkCategory = new LinkCategory( - $category['id'], - $category['category_title'], - $category['description'], - $category['display_order'] - ); - $this->course->add_resource($linkCategory); + $repo = Container::getGlossaryRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); + + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); + } + + /** @var CGlossary[] $terms */ + $terms = $qb->getQuery()->getResult(); + + foreach ($terms as $term) { + $iid = (int) $term->getIid(); + $title = (string) $term->getTitle(); + $desc = (string) ($term->getDescription() ?? ''); + + $this->findAndSetDocumentsInText($desc); + + $legacy = new Glossary( + $iid, + $title, + $desc, + 0 + ); - return $category['id']; + $this->course->add_resource($legacy); + } } /** - * Build the Quizzes. + * Export Course descriptions (collect docs in HTML). * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs - * @param bool $buildOrphanQuestions + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_quizzes( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [], - $buildOrphanQuestions = true - ) { - $table_qui = Database::get_course_table(TABLE_QUIZ_TEST); - $table_rel = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION); - $table_doc = Database::get_course_table(TABLE_DOCUMENT); - - $courseId = (int) $courseId; - $idCondition = ''; - if (!empty($idList)) { - $idList = array_map('intval', $idList); - $idCondition = ' iid IN ("'.implode('","', $idList).'") AND '; + private function build_course_descriptions( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; } - if (!empty($courseId) && !empty($session_id)) { - $session_id = (int) $session_id; - if ($withBaseContent) { - $sessionCondition = api_get_session_condition( - $session_id, - true, - true - ); - } else { - $sessionCondition = api_get_session_condition( - $session_id, - true - ); - } + $repo = Container::getCourseDescriptionRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); - // Select only quizzes with active = 0 or 1 (not -1 which is for deleted quizzes) - $sql = "SELECT * FROM $table_qui - WHERE - c_id = $courseId AND - $idCondition - active >=0 - $sessionCondition "; - } else { - // Select only quizzes with active = 0 or 1 (not -1 which is for deleted quizzes) - $sql = "SELECT * FROM $table_qui - WHERE - c_id = $courseId AND - $idCondition - active >=0 AND - (session_id = 0 OR session_id IS NULL)"; - } - - $sql .= ' ORDER BY title'; - $db_result = Database::query($sql); - $questionList = []; - while ($obj = Database::fetch_object($db_result)) { - if (strlen($obj->sound) > 0) { - $sql = "SELECT id FROM $table_doc - WHERE c_id = $courseId AND path = '/audio/".$obj->sound."'"; - $res = Database::query($sql); - $doc = Database::fetch_object($res); - $obj->sound = $doc->id; - } - $this->findAndSetDocumentsInText($obj->description); - - $quiz = new Quiz($obj); - $sql = 'SELECT * FROM '.$table_rel.' - WHERE c_id = '.$courseId.' AND quiz_id = '.$obj->id; - $db_result2 = Database::query($sql); - while ($obj2 = Database::fetch_object($db_result2)) { - $quiz->add_question($obj2->question_id, $obj2->question_order); - $questionList[] = $obj2->question_id; - } - $this->course->add_resource($quiz); + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); } - if (!empty($courseId)) { - $this->build_quiz_questions($courseId, $questionList, $buildOrphanQuestions); - } else { - $this->build_quiz_questions(0, $questionList, $buildOrphanQuestions); + /** @var CCourseDescription[] $rows */ + $rows = $qb->getQuery()->getResult(); + + foreach ($rows as $row) { + $iid = (int) $row->getIid(); + $title = (string) ($row->getTitle() ?? ''); + $html = (string) ($row->getContent() ?? ''); + $type = (int) $row->getDescriptionType(); + + $this->findAndSetDocumentsInText($html); + + $export = new CourseDescription( + $iid, + $title, + $html, + $type + ); + + $this->course->add_resource($export); } } /** - * Build the Quiz-Questions. + * Export Calendar events (first attachment as legacy, all as assets). * - * @param int $courseId Internal course ID - * @param array $questionList - * @param bool $buildOrphanQuestions + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_quiz_questions($courseId = 0, $questionList = [], $buildOrphanQuestions = true) - { - $table_qui = Database::get_course_table(TABLE_QUIZ_TEST); - $table_rel = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION); - $table_que = Database::get_course_table(TABLE_QUIZ_QUESTION); - $table_ans = Database::get_course_table(TABLE_QUIZ_ANSWER); - $courseId = (int) $courseId; - $questionListToString = implode("','", $questionList); - - // Building normal tests (many queries) - $sql = "SELECT * FROM $table_que - WHERE c_id = $courseId AND id IN ('$questionListToString')"; - $result = Database::query($sql); - - while ($obj = Database::fetch_object($result)) { - // find the question category - // @todo : need to be adapted for multi category questions in 1.10 - $question_category_id = TestCategory::getCategoryForQuestion( - $obj->id, - $courseId - ); + private function build_events( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } - $this->findAndSetDocumentsInText($obj->description); - - // build the backup resource question object - $question = new QuizQuestion( - $obj->id, - $obj->question, - $obj->description, - $obj->ponderation, - $obj->type, - $obj->position, - $obj->picture, - $obj->level, - $obj->extra, - $question_category_id - ); - $question->addPicture($this); - - $sql = 'SELECT * FROM '.$table_ans.' - WHERE c_id = '.$courseId.' AND question_id = '.$obj->id; - $db_result2 = Database::query($sql); - while ($obj2 = Database::fetch_object($db_result2)) { - $question->add_answer( - $obj2->id, - $obj2->answer, - $obj2->correct, - $obj2->comment, - $obj2->ponderation, - $obj2->position, - $obj2->hotspot_coordinates, - $obj2->hotspot_type - ); + $eventRepo = Container::getCalendarEventRepository(); + $qb = $eventRepo->getResourcesByCourse($courseEntity, $sessionEntity); - $this->findAndSetDocumentsInText($obj2->answer); - $this->findAndSetDocumentsInText($obj2->comment); - - if (MULTIPLE_ANSWER_TRUE_FALSE == $obj->type) { - $table_options = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION); - $sql = 'SELECT * FROM '.$table_options.' - WHERE c_id = '.$courseId.' AND question_id = '.$obj->id; - $db_result3 = Database::query($sql); - while ($obj3 = Database::fetch_object($db_result3)) { - $question_option = new QuizQuestionOption($obj3); - $question->add_option($question_option); - } - } - } - $this->course->add_resource($question); - } - - if ($buildOrphanQuestions) { - // Building a fictional test for collecting orphan questions. - // When a course is emptied this option should be activated (true). - //$build_orphan_questions = !empty($_POST['recycle_option']); - - // 1st union gets the orphan questions from deleted exercises - // 2nd union gets the orphan questions from question that were deleted in a exercise. - $sql = " ( - SELECT question_id, q.* FROM $table_que q - INNER JOIN $table_rel r - ON (q.c_id = r.c_id AND q.id = r.question_id) - INNER JOIN $table_qui ex - ON (ex.id = r.quiz_id AND ex.c_id = r.c_id) - WHERE ex.c_id = $courseId AND ex.active = '-1' - ) - UNION - ( - SELECT question_id, q.* FROM $table_que q - left OUTER JOIN $table_rel r - ON (q.c_id = r.c_id AND q.id = r.question_id) - WHERE q.c_id = $courseId AND r.question_id is null - ) - UNION - ( - SELECT question_id, q.* FROM $table_que q - INNER JOIN $table_rel r - ON (q.c_id = r.c_id AND q.id = r.question_id) - WHERE r.c_id = $courseId AND (r.quiz_id = '-1' OR r.quiz_id = '0') - ) - "; - - $result = Database::query($sql); - if (Database::num_rows($result) > 0) { - $orphanQuestionIds = []; - while ($obj = Database::fetch_object($result)) { - // Orphan questions - if (!empty($obj->question_id)) { - $obj->id = $obj->question_id; - } + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); + } - // Avoid adding the same question twice - if (!isset($this->course->resources[$obj->id])) { - // find the question category - // @todo : need to be adapted for multi category questions in 1.10 - $question_category_id = TestCategory::getCategoryForQuestion($obj->id, $courseId); - $question = new QuizQuestion( - $obj->id, - $obj->question, - $obj->description, - $obj->ponderation, - $obj->type, - $obj->position, - $obj->picture, - $obj->level, - $obj->extra, - $question_category_id - ); - $question->addPicture($this); - $sql = "SELECT * FROM $table_ans - WHERE c_id = $courseId AND question_id = ".$obj->id; - $db_result2 = Database::query($sql); - if (Database::num_rows($db_result2)) { - while ($obj2 = Database::fetch_object($db_result2)) { - $question->add_answer( - $obj2->id, - $obj2->answer, - $obj2->correct, - $obj2->comment, - $obj2->ponderation, - $obj2->position, - $obj2->hotspot_coordinates, - $obj2->hotspot_type - ); + /** @var CCalendarEvent[] $events */ + $events = $qb->getQuery()->getResult(); + + /** @var KernelInterface $kernel */ + $kernel = Container::$container->get('kernel'); + $projectDir = rtrim($kernel->getProjectDir(), '/'); + $resourceBase = $projectDir . '/var/upload/resource'; + + /** @var ResourceNodeRepository $rnRepo */ + $rnRepo = Container::$container->get(ResourceNodeRepository::class); + + foreach ($events as $ev) { + $iid = (int) $ev->getIid(); + $title = (string) $ev->getTitle(); + $content = (string) ($ev->getContent() ?? ''); + $startDate = $ev->getStartDate()?->format('Y-m-d H:i:s') ?? ''; + $endDate = $ev->getEndDate()?->format('Y-m-d H:i:s') ?? ''; + $allDay = (int) $ev->isAllDay(); + + $firstPath = $firstName = $firstComment = ''; + $firstSize = 0; + + /** @var CCalendarEventAttachment $att */ + foreach ($ev->getAttachments() as $att) { + $node = $att->getResourceNode(); + $abs = null; + $size = 0; + $relForZip = null; + + if ($node) { + $file = $node->getFirstResourceFile(); + if ($file) { + $storedRel = (string) $rnRepo->getFilename($file); + if ($storedRel !== '') { + $candidate = $resourceBase . $storedRel; + if (is_readable($candidate)) { + $abs = $candidate; + $size = (int) $file->getSize(); + if ($size <= 0 && is_file($candidate)) { + $st = @stat($candidate); + $size = $st ? (int) $st['size'] : 0; + } + $base = basename($storedRel) ?: (string) $att->getIid(); + $relForZip = 'upload/calendar/' . $base; } - $orphanQuestionIds[] = $obj->id; } - $this->course->add_resource($question); } } - } - } - $obj = [ - 'id' => -1, - 'title' => get_lang('Orphan questions'), - 'type' => 2, - ]; - $newQuiz = new Quiz((object) $obj); - if (!empty($orphanQuestionIds)) { - foreach ($orphanQuestionIds as $index => $orphanId) { - $order = $index + 1; - $newQuiz->add_question($orphanId, $order); + if ($abs && $relForZip) { + $this->tryAddAsset($relForZip, $abs, $size); + } else { + error_log('COURSE_BUILD: event attachment file not found (event_iid=' + . $iid . '; att_iid=' . (int) $att->getIid() . ')'); + } + + if ($firstName === '' && $relForZip) { + $firstPath = substr($relForZip, strlen('upload/calendar/')); + $firstName = (string) $att->getFilename(); + $firstComment = (string) ($att->getComment() ?? ''); + $firstSize = (int) $size; + } } + + $export = new CalendarEvent( + $iid, + $title, + $content, + $startDate, + $endDate, + $firstPath, + $firstName, + $firstSize, + $firstComment, + $allDay + ); + + $this->course->add_resource($export); } - $this->course->add_resource($newQuiz); } /** - * @deprecated - * Build the orphan questions + * Export Announcements (first attachment legacy, all as assets). + * + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_quiz_orphan_questions() - { - $table_qui = Database::get_course_table(TABLE_QUIZ_TEST); - $table_rel = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION); - $table_que = Database::get_course_table(TABLE_QUIZ_QUESTION); - $table_ans = Database::get_course_table(TABLE_QUIZ_ANSWER); - - $courseId = api_get_course_int_id(); - - $sql = 'SELECT * - FROM '.$table_que.' as questions - LEFT JOIN '.$table_rel.' as quizz_questions - ON questions.id=quizz_questions.question_id - LEFT JOIN '.$table_qui.' as exercises - ON quizz_questions.quiz_id = exercises.id - WHERE - questions.c_id = quizz_questions.c_id AND - questions.c_id = exercises.c_id AND - exercises.c_id = '.$courseId.' AND - (quizz_questions.quiz_id IS NULL OR - exercises.active = -1)'; - - $db_result = Database::query($sql); - if (Database::num_rows($db_result) > 0) { - // This is the fictional test for collecting orphan questions. - $orphan_questions = new Quiz( - -1, - get_lang('Orphan questions'), - '', - 0, - 0, - 1, - '', - 0 - ); + private function build_announcements( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity) { + return; + } - $this->course->add_resource($orphan_questions); - while ($obj = Database::fetch_object($db_result)) { - $question = new QuizQuestion( - $obj->id, - $obj->question, - $obj->description, - $obj->ponderation, - $obj->type, - $obj->position, - $obj->picture, - $obj->level, - $obj->extra - ); - $question->addPicture($this); - - $sql = 'SELECT * FROM '.$table_ans.' WHERE question_id = '.$obj->id; - $db_result2 = Database::query($sql); - while ($obj2 = Database::fetch_object($db_result2)) { - $question->add_answer( - $obj2->id, - $obj2->answer, - $obj2->correct, - $obj2->comment, - $obj2->ponderation, - $obj2->position, - $obj2->hotspot_coordinates, - $obj2->hotspot_type - ); + $annRepo = Container::getAnnouncementRepository(); + $qb = $annRepo->getResourcesByCourse($courseEntity, $sessionEntity); + + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); + } + + /** @var CAnnouncement[] $anns */ + $anns = $qb->getQuery()->getResult(); + + /** @var KernelInterface $kernel */ + $kernel = Container::$container->get('kernel'); + $projectDir = rtrim($kernel->getProjectDir(), '/'); + $resourceBase = $projectDir . '/var/upload/resource'; + + /** @var ResourceNodeRepository $rnRepo */ + $rnRepo = Container::$container->get(ResourceNodeRepository::class); + + foreach ($anns as $a) { + $iid = (int) $a->getIid(); + $title = (string) $a->getTitle(); + $html = (string) ($a->getContent() ?? ''); + $date = $a->getEndDate()?->format('Y-m-d H:i:s') ?? ''; + $email = (bool) $a->getEmailSent(); + + $firstPath = $firstName = $firstComment = ''; + $firstSize = 0; + + $attachmentsArr = []; + + /** @var CAnnouncementAttachment $att */ + foreach ($a->getAttachments() as $att) { + $relPath = ltrim((string) $att->getPath(), '/'); + $assetRel = 'upload/announcements/' . $relPath; + + $abs = null; + $node = $att->getResourceNode(); + if ($node) { + $file = $node->getFirstResourceFile(); + if ($file) { + $storedRel = (string) $rnRepo->getFilename($file); + if ($storedRel !== '') { + $candidate = $resourceBase . $storedRel; + if (is_readable($candidate)) { + $abs = $candidate; + } + } + } + } + + if ($abs) { + $this->tryAddAsset($assetRel, $abs, (int) $att->getSize()); + } else { + error_log('COURSE_BUILD: announcement attachment not found (iid=' . (int) $att->getIid() . ')'); + } + + $attachmentsArr[] = [ + 'path' => $relPath, + 'filename' => (string) $att->getFilename(), + 'size' => (int) $att->getSize(), + 'comment' => (string) ($att->getComment() ?? ''), + 'asset_relpath' => $assetRel, + ]; + + if ($firstName === '') { + $firstPath = $relPath; + $firstName = (string) $att->getFilename(); + $firstSize = (int) $att->getSize(); + $firstComment = (string) ($att->getComment() ?? ''); } - $this->course->add_resource($question); } + + $payload = [ + 'title' => $title, + 'content' => $html, + 'date' => $date, + 'display_order' => 0, + 'email_sent' => $email ? 1 : 0, + 'attachment_path' => $firstPath, + 'attachment_filename' => $firstName, + 'attachment_size' => $firstSize, + 'attachment_comment' => $firstComment, + 'attachments' => $attachmentsArr, + ]; + + $legacyCourse->resources[RESOURCE_ANNOUNCEMENT][$iid] = + $this->mkLegacyItem(RESOURCE_ANNOUNCEMENT, $iid, $payload, ['attachments']); } } /** - * Build the test category. + * Register an asset to be packed into the export ZIP. * - * @param int $sessionId Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs + * @param string $relPath Relative path inside the ZIP + * @param string $absPath Absolute filesystem path + * @param int $size + * @return void + */ + private function addAsset(string $relPath, string $absPath, int $size = 0): void + { + if (!isset($this->course->resources['asset']) || !is_array($this->course->resources['asset'])) { + $this->course->resources['asset'] = []; + } + $this->course->resources['asset'][$relPath] = [ + 'abs' => $absPath, + 'size' => $size, + ]; + } + + /** + * Try to add an asset only if file exists. * - * @todo add course session + * @param string $relPath + * @param string $absPath + * @param int $size + * @return void */ - public function build_test_category( - $sessionId = 0, - $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - // get all test category in course - $category = new TestCategory(); - $categories = $category->getCategories(); - foreach ($categories as $category) { - $this->findAndSetDocumentsInText($category->getDescription()); - /** @var TestCategory $category */ - $courseCopyTestCategory = new CourseCopyTestCategory( - $category->id, - $category->name, - $category->description - ); - $this->course->add_resource($courseCopyTestCategory); + private function tryAddAsset(string $relPath, string $absPath, int $size = 0): void + { + if (is_file($absPath) && is_readable($absPath)) { + $this->addAsset($relPath, $absPath, $size); + } else { + error_log('COURSE_BUILD: asset missing: ' . $absPath); } } /** - * Build the Surveys. + * Export Surveys; returns needed Question IDs for follow-up export. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $surveyIds + * @return array */ - public function build_surveys( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $table_survey = Database::get_course_table(TABLE_SURVEY); - $table_question = Database::get_course_table(TABLE_SURVEY_QUESTION); - - $courseId = (int) $courseId; - - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); + private function build_surveys( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $surveyIds + ): array { + if (!$courseEntity) { + return []; + } - $sql = 'SELECT * FROM '.$table_survey.' - WHERE c_id = '.$courseId.' '.$sessionCondition; - if ($id_list) { - $sql .= ' AND iid IN ('.implode(', ', $id_list).')'; - } - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $survey = new Survey( - $obj->survey_id, - $obj->code, - $obj->title, - $obj->subtitle, - $obj->author, - $obj->lang, - $obj->avail_from, - $obj->avail_till, - $obj->is_shared, - $obj->template, - $obj->intro, - $obj->surveythanks, - $obj->creation_date, - $obj->invited, - $obj->answered, - $obj->invite_mail, - $obj->reminder_mail, - $obj->one_question_per_page, - $obj->shuffle - ); - $sql = 'SELECT * FROM '.$table_question.' - WHERE c_id = '.$courseId.' AND survey_id = '.$obj->survey_id; - $db_result2 = Database::query($sql); - while ($obj2 = Database::fetch_object($db_result2)) { - $survey->add_question($obj2->question_id); + $qb = $this->em->createQueryBuilder() + ->select('s') + ->from(CSurvey::class, 's') + ->innerJoin('s.resourceNode', 'rn') + ->leftJoin('rn.resourceLinks', 'links') + ->andWhere('links.course = :course')->setParameter('course', $courseEntity) + ->andWhere($sessionEntity + ? '(links.session IS NULL OR links.session = :session)' + : 'links.session IS NULL' + ) + ->andWhere('links.deletedAt IS NULL') + ->andWhere('links.endVisibilityAt IS NULL'); + + if ($sessionEntity) { + $qb->setParameter('session', $sessionEntity); + } + if (!empty($surveyIds)) { + $qb->andWhere('s.iid IN (:ids)')->setParameter('ids', array_map('intval', $surveyIds)); + } + + /** @var CSurvey[] $surveys */ + $surveys = $qb->getQuery()->getResult(); + + $neededQuestionIds = []; + + foreach ($surveys as $s) { + $iid = (int) $s->getIid(); + $qIds = []; + + foreach ($s->getQuestions() as $q) { + /** @var CSurveyQuestion $q */ + $qid = (int) $q->getIid(); + $qIds[] = $qid; + $neededQuestionIds[$qid] = true; } - $this->course->add_resource($survey); + + $payload = [ + 'code' => (string) ($s->getCode() ?? ''), + 'title' => (string) $s->getTitle(), + 'subtitle' => (string) ($s->getSubtitle() ?? ''), + 'author' => '', + 'lang' => (string) ($s->getLang() ?? ''), + 'avail_from' => $s->getAvailFrom()?->format('Y-m-d H:i:s'), + 'avail_till' => $s->getAvailTill()?->format('Y-m-d H:i:s'), + 'is_shared' => (string) ($s->getIsShared() ?? '0'), + 'template' => (string) ($s->getTemplate() ?? 'template'), + 'intro' => (string) ($s->getIntro() ?? ''), + 'surveythanks' => (string) ($s->getSurveythanks() ?? ''), + 'creation_date' => $s->getCreationDate()?->format('Y-m-d H:i:s') ?: date('Y-m-d H:i:s'), + 'invited' => (int) $s->getInvited(), + 'answered' => (int) $s->getAnswered(), + 'invite_mail' => (string) $s->getInviteMail(), + 'reminder_mail' => (string) $s->getReminderMail(), + 'mail_subject' => (string) $s->getMailSubject(), + 'anonymous' => (string) $s->getAnonymous(), + 'shuffle' => (bool) $s->getShuffle(), + 'one_question_per_page' => (bool) $s->getOneQuestionPerPage(), + 'visible_results' => $s->getVisibleResults(), + 'display_question_number' => (bool) $s->isDisplayQuestionNumber(), + 'survey_type' => (int) $s->getSurveyType(), + 'show_form_profile' => (int) $s->getShowFormProfile(), + 'form_fields' => (string) $s->getFormFields(), + 'duration' => $s->getDuration(), + 'question_ids' => $qIds, + 'survey_id' => $iid, + ]; + + $legacyCourse->resources[RESOURCE_SURVEY][$iid] = + $this->mkLegacyItem(RESOURCE_SURVEY, $iid, $payload); + + error_log('COURSE_BUILD: SURVEY iid=' . $iid . ' qids=[' . implode(',', $qIds) . ']'); } - $this->build_survey_questions($courseId); + + return array_keys($neededQuestionIds); } /** - * Build the Survey Questions. + * Export Survey Questions (answers promoted at top level). * - * @param int $courseId Internal course ID + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $questionIds + * @return void */ - public function build_survey_questions($courseId) - { - $table_que = Database::get_course_table(TABLE_SURVEY_QUESTION); - $table_opt = Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION); + private function build_survey_questions( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $questionIds + ): void { + if (!$courseEntity) { + return; + } - $courseId = (int) $courseId; - $idList = isset($this->specific_id_list['surveys']) ? $this->specific_id_list['surveys'] : []; + $qb = $this->em->createQueryBuilder() + ->select('q', 's') + ->from(CSurveyQuestion::class, 'q') + ->innerJoin('q.survey', 's') + ->innerJoin('s.resourceNode', 'rn') + ->leftJoin('rn.resourceLinks', 'links') + ->andWhere('links.course = :course')->setParameter('course', $courseEntity) + ->andWhere($sessionEntity + ? '(links.session IS NULL OR links.session = :session)' + : 'links.session IS NULL' + ) + ->andWhere('links.deletedAt IS NULL') + ->andWhere('links.endVisibilityAt IS NULL') + ->orderBy('s.iid', 'ASC') + ->addOrderBy('q.sort', 'ASC'); + + if ($sessionEntity) { + $qb->setParameter('session', $sessionEntity); + } + if (!empty($questionIds)) { + $qb->andWhere('q.iid IN (:ids)')->setParameter('ids', array_map('intval', $questionIds)); + } - $sql = 'SELECT * FROM '.$table_que.' WHERE c_id = '.$courseId.' '; + /** @var CSurveyQuestion[] $questions */ + $questions = $qb->getQuery()->getResult(); - if (!empty($idList)) { - $sql .= ' AND survey_id IN ('.implode(', ', $idList).')'; - } + $exported = 0; - $db_result = Database::query($sql); - $is_required = 0; - while ($obj = Database::fetch_object($db_result)) { - if (isset($obj->is_required)) { - $is_required = $obj->is_required; - } - $question = new SurveyQuestion( - $obj->question_id, - $obj->survey_id, - $obj->survey_question, - $obj->survey_question_comment, - $obj->type, - $obj->display, - $obj->sort, - $obj->shared_question_id, - $obj->max_value, - $is_required - ); - $sql = 'SELECT * FROM '.$table_opt.' - WHERE c_id = '.$courseId.' AND question_id = '.$obj->question_id; - $db_result2 = Database::query($sql); - while ($obj2 = Database::fetch_object($db_result2)) { - $question->add_answer($obj2->option_text, $obj2->sort); + foreach ($questions as $q) { + $qid = (int) $q->getIid(); + $sid = (int) $q->getSurvey()->getIid(); + + $answers = []; + foreach ($q->getOptions() as $opt) { + /** @var CSurveyQuestionOption $opt */ + $answers[] = [ + 'option_text' => (string) $opt->getOptionText(), + 'sort' => (int) $opt->getSort(), + 'value' => (int) $opt->getValue(), + ]; } - $this->course->add_resource($question); + + $payload = [ + 'survey_id' => $sid, + 'survey_question' => (string) $q->getSurveyQuestion(), + 'survey_question_comment' => (string) ($q->getSurveyQuestionComment() ?? ''), + 'type' => (string) $q->getType(), + 'display' => (string) $q->getDisplay(), + 'sort' => (int) $q->getSort(), + 'shared_question_id' => $q->getSharedQuestionId(), + 'max_value' => $q->getMaxValue(), + 'is_required' => (bool) $q->isMandatory(), + 'answers' => $answers, + ]; + + $legacyCourse->resources[RESOURCE_SURVEYQUESTION][$qid] = + $this->mkLegacyItem(RESOURCE_SURVEYQUESTION, $qid, $payload, ['answers']); + + $exported++; + error_log('COURSE_BUILD: SURVEY_Q qid=' . $qid . ' survey=' . $sid . ' answers=' . count($answers)); } + + error_log('COURSE_BUILD: survey questions exported=' . $exported); } /** - * Build the announcements. + * Export Quizzes and return required Question IDs. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $quizIds + * @return array */ - public function build_announcements( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $table = Database::get_course_table(TABLE_ANNOUNCEMENT); - - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); + private function build_quizzes( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $quizIds + ): array { + if (!$courseEntity) { + return []; + } - $courseId = (int) $courseId; + $qb = $this->em->createQueryBuilder() + ->select('q') + ->from(CQuiz::class, 'q') + ->innerJoin('q.resourceNode', 'rn') + ->leftJoin('rn.resourceLinks', 'links') + ->andWhere('links.course = :course')->setParameter('course', $courseEntity) + ->andWhere($sessionEntity + ? '(links.session IS NULL OR links.session = :session)' + : 'links.session IS NULL' + ) + ->andWhere('links.deletedAt IS NULL') + ->andWhere('links.endVisibilityAt IS NULL'); + + if ($sessionEntity) { + $qb->setParameter('session', $sessionEntity); + } + if (!empty($quizIds)) { + $qb->andWhere('q.iid IN (:ids)')->setParameter('ids', array_map('intval', $quizIds)); + } - $sql = 'SELECT * FROM '.$table.' - WHERE c_id = '.$courseId.' '.$sessionCondition; - $db_result = Database::query($sql); - $table_attachment = Database::get_course_table( - TABLE_ANNOUNCEMENT_ATTACHMENT - ); - while ($obj = Database::fetch_object($db_result)) { - if (empty($obj->id)) { - continue; - } - $sql = 'SELECT path, comment, filename, size - FROM '.$table_attachment.' - WHERE c_id = '.$courseId.' AND announcement_id = '.$obj->id.''; - $result = Database::query($sql); - $attachment_obj = Database::fetch_object($result); - $att_path = $att_filename = $att_size = $atth_comment = ''; - - if (!empty($attachment_obj)) { - $att_path = $attachment_obj->path; - $att_filename = $attachment_obj->filename; - $att_size = $attachment_obj->size; - $atth_comment = $attachment_obj->comment; + /** @var CQuiz[] $quizzes */ + $quizzes = $qb->getQuery()->getResult(); + $neededQuestionIds = []; + + foreach ($quizzes as $quiz) { + $iid = (int) $quiz->getIid(); + + $payload = [ + 'title' => (string) $quiz->getTitle(), + 'description' => (string) ($quiz->getDescription() ?? ''), + 'type' => (int) $quiz->getType(), + 'random' => (int) $quiz->getRandom(), + 'random_answers' => (bool) $quiz->getRandomAnswers(), + 'results_disabled' => (int) $quiz->getResultsDisabled(), + 'max_attempt' => (int) $quiz->getMaxAttempt(), + 'feedback_type' => (int) $quiz->getFeedbackType(), + 'expired_time' => (int) $quiz->getExpiredTime(), + 'review_answers' => (int) $quiz->getReviewAnswers(), + 'random_by_category' => (int) $quiz->getRandomByCategory(), + 'text_when_finished' => (string) ($quiz->getTextWhenFinished() ?? ''), + 'text_when_finished_failure' => (string) ($quiz->getTextWhenFinishedFailure() ?? ''), + 'display_category_name' => (int) $quiz->getDisplayCategoryName(), + 'save_correct_answers' => (int) ($quiz->getSaveCorrectAnswers() ?? 0), + 'propagate_neg' => (int) $quiz->getPropagateNeg(), + 'hide_question_title' => (bool) $quiz->isHideQuestionTitle(), + 'hide_question_number' => (int) $quiz->getHideQuestionNumber(), + 'question_selection_type' => (int) ($quiz->getQuestionSelectionType() ?? 0), + 'access_condition' => (string) ($quiz->getAccessCondition() ?? ''), + 'pass_percentage' => $quiz->getPassPercentage(), + 'start_time' => $quiz->getStartTime()?->format('Y-m-d H:i:s'), + 'end_time' => $quiz->getEndTime()?->format('Y-m-d H:i:s'), + 'question_ids' => [], + 'question_orders' => [], + ]; + + $rels = $this->em->createQueryBuilder() + ->select('rel', 'qq') + ->from(CQuizRelQuestion::class, 'rel') + ->innerJoin('rel.question', 'qq') + ->andWhere('rel.quiz = :quiz') + ->setParameter('quiz', $quiz) + ->orderBy('rel.questionOrder', 'ASC') + ->getQuery()->getResult(); + + foreach ($rels as $rel) { + $qid = (int) $rel->getQuestion()->getIid(); + $payload['question_ids'][] = $qid; + $payload['question_orders'][] = (int) $rel->getQuestionOrder(); + $neededQuestionIds[$qid] = true; } - $announcement = new Announcement( - $obj->id, - $obj->title, - $obj->content, - $obj->end_date, - $obj->display_order, - $obj->email_sent, - $att_path, - $att_filename, - $att_size, - $atth_comment - ); - $this->course->add_resource($announcement); + $legacyCourse->resources[RESOURCE_QUIZ][$iid] = + $this->mkLegacyItem( + RESOURCE_QUIZ, + $iid, + $payload, + ['question_ids', 'question_orders'] + ); } + + error_log( + 'COURSE_BUILD: build_quizzes done; total=' . count($quizzes) + ); + + return array_keys($neededQuestionIds); } /** - * Build the events. + * Safe count helper for mixed values. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs + * @param mixed $v + * @return int */ - public function build_events( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $table = Database::get_course_table(TABLE_AGENDA); - - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); + private function safeCount(mixed $v): int + { + return (is_array($v) || $v instanceof \Countable) ? \count($v) : 0; + } - $courseId = (int) $courseId; + /** + * Export Quiz Questions (answers and options promoted). + * + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $questionIds + * @return void + */ + private function build_quiz_questions( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $questionIds + ): void { + if (!$courseEntity) { + return; + } - $sql = 'SELECT * FROM '.$table.' - WHERE c_id = '.$courseId.' '.$sessionCondition; - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $table_attachment = Database::get_course_table( - TABLE_AGENDA_ATTACHMENT - ); - $sql = 'SELECT path, comment, filename, size - FROM '.$table_attachment.' - WHERE c_id = '.$courseId.' AND agenda_id = '.$obj->id.''; - $result = Database::query($sql); - - $attachment_obj = Database::fetch_object($result); - $att_path = $att_filename = $att_size = $atth_comment = ''; - if (!empty($attachment_obj)) { - $att_path = $attachment_obj->path; - $att_filename = $attachment_obj->filename; - $att_size = $attachment_obj->size; - $atth_comment = $attachment_obj->comment; - } - $event = new CalendarEvent( - $obj->id, - $obj->title, - $obj->content, - $obj->start_date, - $obj->end_date, - $att_path, - $att_filename, - $att_size, - $atth_comment, - $obj->all_day - ); - $this->course->add_resource($event); + error_log('COURSE_BUILD: build_quiz_questions start ids=' . json_encode(array_values($questionIds))); + error_log('COURSE_BUILD: build_quiz_questions exported=' . $this->safeCount($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? 0)); + + $qb = $this->em->createQueryBuilder() + ->select('qq') + ->from(CQuizQuestion::class, 'qq') + ->innerJoin('qq.resourceNode', 'qrn') + ->leftJoin('qrn.resourceLinks', 'qlinks') + ->andWhere('qlinks.course = :course')->setParameter('course', $courseEntity) + ->andWhere($sessionEntity + ? '(qlinks.session IS NULL OR qlinks.session = :session)' + : 'qlinks.session IS NULL' + ) + ->andWhere('qlinks.deletedAt IS NULL') + ->andWhere('qlinks.endVisibilityAt IS NULL'); + + if ($sessionEntity) { + $qb->setParameter('session', $sessionEntity); + } + if (!empty($questionIds)) { + $qb->andWhere('qq.iid IN (:ids)')->setParameter('ids', array_map('intval', $questionIds)); } + + /** @var CQuizQuestion[] $questions */ + $questions = $qb->getQuery()->getResult(); + + error_log('COURSE_BUILD: build_quiz_questions start ids=' . json_encode(array_values($questionIds))); + error_log('COURSE_BUILD: build_quiz_questions exported=' . $this->safeCount($legacyCourse->resources[RESOURCE_QUIZQUESTION] ?? 0)); + + $this->exportQuestionsWithAnswers($legacyCourse, $questions); } /** - * Build the course-descriptions. + * Internal exporter for quiz questions + answers (+ options for MATF type). * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param array $questions + * @return void */ - public function build_course_descriptions( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $table = Database::get_course_table(TABLE_COURSE_DESCRIPTION); - $courseId = (int) $courseId; - - if (!empty($session_id) && !empty($courseId)) { - $session_id = (int) $session_id; - if ($withBaseContent) { - $sessionCondition = api_get_session_condition( - $session_id, - true, - true - ); - } else { - $sessionCondition = api_get_session_condition( - $session_id, - true - ); + private function exportQuestionsWithAnswers(object $legacyCourse, array $questions): void + { + foreach ($questions as $q) { + $qid = (int) $q->getIid(); + + $payload = [ + 'question' => (string) $q->getQuestion(), + 'description' => (string) ($q->getDescription() ?? ''), + 'ponderation' => (float) $q->getPonderation(), + 'position' => (int) $q->getPosition(), + 'type' => (int) $q->getType(), + 'quiz_type' => (int) $q->getType(), + 'picture' => (string) ($q->getPicture() ?? ''), + 'level' => (int) $q->getLevel(), + 'extra' => (string) ($q->getExtra() ?? ''), + 'feedback' => (string) ($q->getFeedback() ?? ''), + 'question_code' => (string) ($q->getQuestionCode() ?? ''), + 'mandatory' => (int) $q->getMandatory(), + 'duration' => $q->getDuration(), + 'parent_media_id'=> $q->getParentMediaId(), + 'answers' => [], + ]; + + $ans = $this->em->createQueryBuilder() + ->select('a') + ->from(CQuizAnswer::class, 'a') + ->andWhere('a.question = :q')->setParameter('q', $q) + ->orderBy('a.position', 'ASC') + ->getQuery()->getResult(); + + foreach ($ans as $a) { + $payload['answers'][] = [ + 'id' => (int) $a->getIid(), + 'answer' => (string) $a->getAnswer(), + 'comment' => (string) ($a->getComment() ?? ''), + 'ponderation' => (float) $a->getPonderation(), + 'position' => (int) $a->getPosition(), + 'hotspot_coordinates' => $a->getHotspotCoordinates(), + 'hotspot_type' => $a->getHotspotType(), + 'correct' => $a->getCorrect(), + ]; } - $sql = 'SELECT * FROM '.$table.' - WHERE c_id = '.$courseId.' '.$sessionCondition; - } else { - $table = Database::get_course_table(TABLE_COURSE_DESCRIPTION); - $sql = 'SELECT * FROM '.$table.' - WHERE c_id = '.$courseId.' AND session_id = 0'; - } - - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $cd = new CourseDescription( - $obj->id, - $obj->title, - $obj->content, - $obj->description_type + + if (defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === (int) $q->getType()) { + $opts = $this->em->createQueryBuilder() + ->select('o') + ->from(CQuizQuestionOption::class, 'o') + ->andWhere('o.question = :q')->setParameter('q', $q) + ->orderBy('o.position', 'ASC') + ->getQuery()->getResult(); + + $payload['question_options'] = array_map(static fn($o) => [ + 'id' => (int) $o->getIid(), + 'name' => (string) $o->getTitle(), + 'position' => (int) $o->getPosition(), + ], $opts); + } + + $legacyCourse->resources[RESOURCE_QUIZQUESTION][$qid] = + $this->mkLegacyItem(RESOURCE_QUIZQUESTION, $qid, $payload, ['answers', 'question_options']); + + error_log('COURSE_BUILD: QQ qid=' . $qid . + ' quiz_type=' . ($legacyCourse->resources[RESOURCE_QUIZQUESTION][$qid]->quiz_type ?? 'missing') . + ' answers=' . count($legacyCourse->resources[RESOURCE_QUIZQUESTION][$qid]->answers ?? []) ); - $this->course->add_resource($cd); } } /** - * @param int $session_id - * @param int $courseId - * @param bool $withBaseContent - * @param array $idList + * Export Link category as legacy item. + * + * @param CLinkCategory $category + * @return void */ - public function build_learnpath_category($session_id = 0, $courseId = 0, $withBaseContent = false, $idList = []) + private function build_link_category(CLinkCategory $category): void { - $categories = learnpath::getCategories($courseId); - - /** @var CLpCategory $item */ - foreach ($categories as $item) { - $categoryId = $item->getId(); - if (!empty($idList)) { - if (!in_array($categoryId, $idList)) { - continue; - } - } - $category = new LearnPathCategory($categoryId, $item); - $this->course->add_resource($category); + $id = (int) $category->getIid(); + if ($id <= 0) { + return; } + + $payload = [ + 'title' => (string) $category->getTitle(), + 'description' => (string) ($category->getDescription() ?? ''), + 'category_title'=> (string) $category->getTitle(), + ]; + + $this->course->resources[RESOURCE_LINKCATEGORY][$id] = + $this->mkLegacyItem(RESOURCE_LINKCATEGORY, $id, $payload); } /** - * Build the learnpaths. + * Export Links (and their categories once). * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs - * @param bool $addScormFolder + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_learnpaths( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [], - $addScormFolder = true - ) { - $table_main = Database::get_course_table(TABLE_LP_MAIN); - $table_item = Database::get_course_table(TABLE_LP_ITEM); - $table_tool = Database::get_course_table(TABLE_TOOL_LIST); - - $courseId = (int) $courseId; - - if (!empty($session_id) && !empty($courseId)) { - $session_id = (int) $session_id; - if ($withBaseContent) { - $sessionCondition = api_get_session_condition( - $session_id, - true, - true - ); - } else { - $sessionCondition = api_get_session_condition( - $session_id, - true - ); - } - $sql = 'SELECT * FROM '.$table_main.' - WHERE c_id = '.$courseId.' '.$sessionCondition; - } else { - $sql = 'SELECT * FROM '.$table_main.' - WHERE c_id = '.$courseId.' AND (session_id = 0 OR session_id IS NULL)'; - } - - if (!empty($id_list)) { - $id_list = array_map('intval', $id_list); - $sql .= ' AND id IN ('.implode(', ', $id_list).') '; - } - - $result = Database::query($sql); - if ($result) { - while ($obj = Database::fetch_object($result)) { - $items = []; - $sql = "SELECT * FROM $table_item - WHERE c_id = '$courseId' AND lp_id = ".$obj->id; - $resultItem = Database::query($sql); - while ($obj_item = Database::fetch_object($resultItem)) { - $item['id'] = $obj_item->iid; - $item['item_type'] = $obj_item->item_type; - $item['ref'] = $obj_item->ref; - $item['title'] = $obj_item->title; - $item['description'] = $obj_item->description; - $item['path'] = $obj_item->path; - $item['min_score'] = $obj_item->min_score; - $item['max_score'] = $obj_item->max_score; - $item['mastery_score'] = $obj_item->mastery_score; - $item['parent_item_id'] = $obj_item->parent_item_id; - $item['previous_item_id'] = $obj_item->previous_item_id; - $item['next_item_id'] = $obj_item->next_item_id; - $item['display_order'] = $obj_item->display_order; - $item['prerequisite'] = $obj_item->prerequisite; - $item['parameters'] = $obj_item->parameters; - $item['launch_data'] = $obj_item->launch_data; - $item['audio'] = $obj_item->audio; - $items[] = $item; - } + private function build_links( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } - $sql = "SELECT id FROM $table_tool - WHERE - c_id = $courseId AND - (link LIKE '%lp_controller.php%lp_id=".$obj->id."%' AND image='scormbuilder.gif') AND - visibility = '1' "; - $db_tool = Database::query($sql); - $visibility = '0'; - if (Database::num_rows($db_tool)) { - $visibility = '1'; - } + $linkRepo = Container::getLinkRepository(); + $catRepo = Container::getLinkCategoryRepository(); - $lp = new CourseCopyLearnpath( - $obj->id, - $obj->lp_type, - $obj->title, - $obj->path, - $obj->ref, - $obj->description, - $obj->content_local, - $obj->default_encoding, - $obj->default_view_mod, - $obj->prevent_reinit, - $obj->force_commit, - $obj->content_maker, - $obj->display_order, - $obj->js_lib, - $obj->content_license, - $obj->debug, - $visibility, - $obj->author, - //$obj->preview_image, - $obj->use_max_score, - $obj->autolaunch, - $obj->created_on, - $obj->modified_on, - $obj->published_on, - $obj->expired_on, - $obj->session_id, - $obj->category_id, - $items - ); + $qb = $linkRepo->getResourcesByCourse($courseEntity, $sessionEntity); - $this->course->add_resource($lp); - - /*if (!empty($obj->preview_image)) { - // Add LP teacher image - $asset = new Asset( - $obj->preview_image, - '/upload/learning_path/images/'.$obj->preview_image, - '/upload/learning_path/images/'.$obj->preview_image - ); - $this->course->add_resource($asset); - }*/ - } + if (!empty($ids)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $ids)))); } - // Save scorm directory (previously build_scorm_documents()) - if ($addScormFolder) { - $i = 1; - if ($dir = @opendir($this->course->backup_path.'/scorm')) { - while ($file = readdir($dir)) { - if (is_dir($this->course->backup_path.'/scorm/'.$file) && - !in_array($file, ['.', '..']) - ) { - $doc = new ScormDocument($i++, '/'.$file, $file); - $this->course->add_resource($doc); - } - } - closedir($dir); + /** @var CLink[] $links */ + $links = $qb->getQuery()->getResult(); + + $exportedCats = []; + + foreach ($links as $link) { + $iid = (int) $link->getIid(); + $title = (string) $link->getTitle(); + $url = (string) $link->getUrl(); + $desc = (string) ($link->getDescription() ?? ''); + $tgt = (string) ($link->getTarget() ?? ''); + + $cat = $link->getCategory(); + $catId = (int) ($cat?->getIid() ?? 0); + + if ($catId > 0 && !isset($exportedCats[$catId])) { + $this->build_link_category($cat); + $exportedCats[$catId] = true; } + + $payload = [ + 'title' => $title !== '' ? $title : $url, + 'url' => $url, + 'description' => $desc, + 'target' => $tgt, + 'category_id' => $catId, + 'on_homepage' => false, + ]; + + $legacyCourse->resources[RESOURCE_LINK][$iid] = + $this->mkLegacyItem(RESOURCE_LINK, $iid, $payload); } } /** - * Build the glossaries. + * Format DateTime as string "Y-m-d H:i:s". * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs + * @param \DateTimeInterface|null $dt + * @return string */ - public function build_glossary( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $table_glossary = Database::get_course_table(TABLE_GLOSSARY); - - $courseId = (int) $courseId; - - if (!empty($session_id) && !empty($courseId)) { - $session_id = (int) $session_id; - if ($withBaseContent) { - $sessionCondition = api_get_session_condition( - $session_id, - true, - true - ); - } else { - $sessionCondition = api_get_session_condition( - $session_id, - true - ); - } + private function fmtDate(?\DateTimeInterface $dt): string + { + return $dt ? $dt->format('Y-m-d H:i:s') : ''; + } - //@todo check this queries are the same ... - if (!empty($this->course->type) && 'partial' == $this->course->type) { - $sql = 'SELECT * FROM '.$table_glossary.' g - WHERE g.c_id = '.$courseId.' '.$sessionCondition; - } else { - $sql = 'SELECT * FROM '.$table_glossary.' g - WHERE g.c_id = '.$courseId.' '.$sessionCondition; - } - } else { - $table_glossary = Database::get_course_table(TABLE_GLOSSARY); - //@todo check this queries are the same ... ayayay - if (!empty($this->course->type) && 'partial' == $this->course->type) { - $sql = 'SELECT * FROM '.$table_glossary.' g - WHERE g.c_id = '.$courseId.' AND (session_id = 0 OR session_id IS NULL)'; - } else { - $sql = 'SELECT * FROM '.$table_glossary.' g - WHERE g.c_id = '.$courseId.' AND (session_id = 0 OR session_id IS NULL)'; + /** + * Create a legacy item object, promoting selected array keys to top-level. + * + * @param string $type + * @param int $sourceId + * @param array|object $obj + * @param array $arrayKeysToPromote + * @return \stdClass + */ + private function mkLegacyItem(string $type, int $sourceId, array|object $obj, array $arrayKeysToPromote = []): \stdClass + { + $o = new \stdClass(); + $o->type = $type; + $o->source_id = $sourceId; + $o->destination_id = -1; + $o->has_obj = true; + $o->obj = (object) $obj; + + foreach ((array) $obj as $k => $v) { + if (is_scalar($v) || $v === null) { + if (!property_exists($o, $k)) { + $o->$k = $v; + } } } - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $doc = new Glossary( - $obj->glossary_id, - $obj->name, - $obj->description, - $obj->display_order - ); - $this->course->add_resource($doc); + foreach ($arrayKeysToPromote as $k) { + if (isset($obj[$k]) && is_array($obj[$k])) { + $o->$k = $obj[$k]; + } } - } - /* - * Build session course by jhon - */ - public function build_session_course() - { - $tbl_session = Database::get_main_table(TABLE_MAIN_SESSION); - $tbl_session_course = Database::get_main_table(TABLE_MAIN_SESSION_COURSE); - $list_course = CourseManager::get_course_list(); - $list = []; - foreach ($list_course as $_course) { - $this->course = new Course(); - $this->course->code = $_course['code']; - $this->course->type = 'partial'; - $this->course->path = api_get_path(SYS_COURSE_PATH).$_course['directory'].'/'; - $this->course->backup_path = api_get_path(SYS_COURSE_PATH).$_course['directory']; - $this->course->encoding = api_get_system_encoding(); //current platform encoding - $courseId = $_course['real_id']; - $sql = "SELECT s.id, name, c_id - FROM $tbl_session_course sc - INNER JOIN $tbl_session s - ON sc.session_id = s.id - WHERE sc.c_id = '$courseId' "; - $query_session = Database::query($sql); - while ($rows_session = Database::fetch_assoc($query_session)) { - $session = new CourseSession( - $rows_session['id'], - $rows_session['name'] - ); - $this->course->add_resource($session); + // Ensure "name" for restorer display + if (!isset($o->name) || $o->name === '' || $o->name === null) { + if (isset($obj['name']) && $obj['name'] !== '') { + $o->name = (string) $obj['name']; + } elseif (isset($obj['title']) && $obj['title'] !== '') { + $o->name = (string) $obj['title']; + } else { + $o->name = $type . ' ' . $sourceId; } - $list[] = $this->course; } - return $list; + return $o; } /** - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs + * Build an id filter closure. + * + * @param array $idsFilter + * @return \Closure(int):bool */ - public function build_wiki( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $tbl_wiki = Database::get_course_table(TABLE_WIKI); - $courseId = (int) $courseId; - - if (!empty($session_id) && !empty($courseId)) { - $session_id = (int) $session_id; - if ($withBaseContent) { - $sessionCondition = api_get_session_condition( - $session_id, - true, - true - ); - } else { - $sessionCondition = api_get_session_condition( - $session_id, - true - ); - } - $sql = 'SELECT * FROM '.$tbl_wiki.' - WHERE c_id = '.$courseId.' '.$sessionCondition; - } else { - $tbl_wiki = Database::get_course_table(TABLE_WIKI); - $sql = 'SELECT * FROM '.$tbl_wiki.' - WHERE c_id = '.$courseId.' AND (session_id = 0 OR session_id IS NULL)'; - } - $db_result = Database::query($sql); - while ($obj = Database::fetch_object($db_result)) { - $wiki = new Wiki( - $obj->id, - $obj->page_id, - $obj->reflink, - $obj->title, - $obj->content, - $obj->user_id, - $obj->group_id, - $obj->dtime, - $obj->progress, - $obj->version - ); - $this->course->add_resource($wiki); + private function makeIdFilter(array $idsFilter): \Closure + { + if (empty($idsFilter)) { + return static fn(int $id): bool => true; } + $set = array_fill_keys(array_map('intval', $idsFilter), true); + return static fn(int $id): bool => isset($set[$id]); } /** - * Build the Surveys. + * Export Tool intro (tool -> intro text) for visible tools. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs + * @param object $legacyCourse + * @param CourseEntity|null $courseEntity + * @param SessionEntity|null $sessionEntity + * @return void */ - public function build_thematic( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $table_thematic = Database::get_course_table(TABLE_THEMATIC); - $table_thematic_advance = Database::get_course_table(TABLE_THEMATIC_ADVANCE); - $table_thematic_plan = Database::get_course_table(TABLE_THEMATIC_PLAN); - $courseId = (int) $courseId; - - $courseInfo = api_get_course_info_by_id($courseId); - $session_id = (int) $session_id; - if ($withBaseContent) { - $sessionCondition = api_get_session_condition( - $session_id, - true, - true - ); + private function build_tool_intro( + object $legacyCourse, + ?CourseEntity $courseEntity, + ?SessionEntity $sessionEntity + ): void { + if (!$courseEntity instanceof CourseEntity) { + return; + } + + $repo = $this->em->getRepository(CToolIntro::class); + + $qb = $repo->createQueryBuilder('ti') + ->innerJoin('ti.courseTool', 'ct') + ->andWhere('ct.course = :course') + ->setParameter('course', $courseEntity); + + if ($sessionEntity) { + $qb->andWhere('ct.session = :session')->setParameter('session', $sessionEntity); } else { - $sessionCondition = api_get_session_condition($session_id, true); + $qb->andWhere('ct.session IS NULL'); } - $sql = "SELECT * FROM $table_thematic - WHERE c_id = $courseId $sessionCondition "; - $db_result = Database::query($sql); - while ($row = Database::fetch_assoc($db_result)) { - $thematic = new Thematic($row); - $sql = 'SELECT * FROM '.$table_thematic_advance.' - WHERE c_id = '.$courseId.' AND thematic_id = '.$row['id']; + $qb->andWhere('ct.visibility = :vis')->setParameter('vis', true); - $result = Database::query($sql); - while ($sub_row = Database::fetch_assoc($result)) { - $thematic->addThematicAdvance($sub_row); + /** @var CToolIntro[] $intros */ + $intros = $qb->getQuery()->getResult(); + + foreach ($intros as $intro) { + $ctool = $intro->getCourseTool(); // CTool + $titleKey = (string) $ctool->getTitle(); // e.g. 'documents', 'forum' + if ($titleKey === '') { + continue; } - $items = api_get_item_property_by_tool( - 'thematic_plan', - $courseInfo['code'], - $session_id - ); + $payload = [ + 'id' => $titleKey, + 'intro_text' => (string) $intro->getIntroText(), + ]; - $thematic_plan_id_list = []; - if (!empty($items)) { - foreach ($items as $item) { - $thematic_plan_id_list[] = $item['ref']; - } + // Use 0 as source_id (unused by restore) + $legacyCourse->resources[RESOURCE_TOOL_INTRO][$titleKey] = + $this->mkLegacyItem(RESOURCE_TOOL_INTRO, 0, $payload); + } + } + + /** + * Export Forum categories. + * + * @param object $legacyCourse + * @param CourseEntity $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void + */ + private function build_forum_category( + object $legacyCourse, + CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + $repo = Container::getForumCategoryRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); + $categories = $qb->getQuery()->getResult(); + + $keep = $this->makeIdFilter($ids); + + foreach ($categories as $cat) { + /** @var CForumCategory $cat */ + $id = (int) $cat->getIid(); + if (!$keep($id)) { + continue; } - if (count($thematic_plan_id_list) > 0) { - $sql = "SELECT tp.* - FROM $table_thematic_plan tp - INNER JOIN $table_thematic t ON (t.id=tp.thematic_id) - WHERE - t.c_id = $courseId AND - tp.c_id = $courseId AND - thematic_id = {$row['id']} AND - tp.id IN (".implode(', ', $thematic_plan_id_list).') '; - - $result = Database::query($sql); - while ($sub_row = Database::fetch_assoc($result)) { - $thematic->addThematicPlan($sub_row); - } + + $payload = [ + 'title' => (string) $cat->getTitle(), + 'description' => (string) ($cat->getCatComment() ?? ''), + 'cat_title' => (string) $cat->getTitle(), + 'cat_comment' => (string) ($cat->getCatComment() ?? ''), + ]; + + $legacyCourse->resources[RESOURCE_FORUMCATEGORY][$id] = + $this->mkLegacyItem(RESOURCE_FORUMCATEGORY, $id, $payload); + } + } + + /** + * Export Forums. + * + * @param object $legacyCourse + * @param CourseEntity $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void + */ + private function build_forums( + object $legacyCourse, + CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + $repo = Container::getForumRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); + $forums = $qb->getQuery()->getResult(); + + $keep = $this->makeIdFilter($ids); + + foreach ($forums as $f) { + /** @var CForum $f */ + $id = (int) $f->getIid(); + if (!$keep($id)) { + continue; } - $this->course->add_resource($thematic); + + $payload = [ + 'title' => (string) $f->getTitle(), + 'description' => (string) ($f->getForumComment() ?? ''), + 'forum_title' => (string) $f->getTitle(), + 'forum_comment' => (string) ($f->getForumComment() ?? ''), + 'forum_category' => (int) ($f->getForumCategory()?->getIid() ?? 0), + 'allow_anonymous' => (int) ($f->getAllowAnonymous() ?? 0), + 'allow_edit' => (int) ($f->getAllowEdit() ?? 0), + 'approval_direct_post' => (string) ($f->getApprovalDirectPost() ?? '0'), + 'allow_attachments' => (int) ($f->getAllowAttachments() ?? 1), + 'allow_new_threads' => (int) ($f->getAllowNewThreads() ?? 1), + 'default_view' => (string) ($f->getDefaultView() ?? 'flat'), + 'forum_of_group' => (string) ($f->getForumOfGroup() ?? '0'), + 'forum_group_public_private' => (string) ($f->getForumGroupPublicPrivate() ?? 'public'), + 'moderated' => (int) ($f->isModerated() ? 1 : 0), + 'start_time' => $this->fmtDate($f->getStartTime()), + 'end_time' => $this->fmtDate($f->getEndTime()), + ]; + + $legacyCourse->resources[RESOURCE_FORUM][$id] = + $this->mkLegacyItem(RESOURCE_FORUM, $id, $payload); } } /** - * Build the attendances. + * Export Forum threads. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $id_list If you want to restrict the structure to only the given IDs - * @throws \Exception - * @throws Exception + * @param object $legacyCourse + * @param CourseEntity $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_attendance( - $session_id = 0, - $courseId = 0, - $withBaseContent = false, - $id_list = [] - ) { - $table_attendance = Database::get_course_table(TABLE_ATTENDANCE); - $table_attendance_calendar = Database::get_course_table(TABLE_ATTENDANCE_CALENDAR); - $sessionCondition = api_get_session_condition($session_id, true, $withBaseContent); - $courseId = (int) $courseId; - - $sql = 'SELECT * FROM '.$table_attendance.' - WHERE c_id = '.$courseId.' '.$sessionCondition; - $db_result = Database::query($sql); - while ($row = Database::fetch_assoc($db_result)) { - $obj = new Attendance($row); - $sql = 'SELECT * FROM '.$table_attendance_calendar.' - WHERE c_id = '.$courseId.' AND attendance_id = '.$row['id']; - - $result = Database::query($sql); - while ($sub_row = Database::fetch_assoc($result)) { - $obj->add_attendance_calendar($sub_row); + private function build_forum_topics( + object $legacyCourse, + CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + $repo = Container::getForumThreadRepository(); + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity); + $threads = $qb->getQuery()->getResult(); + + $keep = $this->makeIdFilter($ids); + + foreach ($threads as $t) { + /** @var CForumThread $t */ + $id = (int) $t->getIid(); + if (!$keep($id)) { + continue; } - $this->course->add_resource($obj); + + $payload = [ + 'title' => (string) $t->getTitle(), + 'thread_title' => (string) $t->getTitle(), + 'title_qualify' => (string) ($t->getThreadTitleQualify() ?? ''), + 'topic_poster_name' => (string) ($t->getUser()?->getUsername() ?? ''), + 'forum_id' => (int) ($t->getForum()?->getIid() ?? 0), + 'thread_date' => $this->fmtDate($t->getThreadDate()), + 'thread_sticky' => (int) ($t->getThreadSticky() ? 1 : 0), + 'thread_title_qualify' => (string) ($t->getThreadTitleQualify() ?? ''), + 'thread_qualify_max' => (float) $t->getThreadQualifyMax(), + 'thread_weight' => (float) $t->getThreadWeight(), + 'thread_peer_qualify' => (int) ($t->isThreadPeerQualify() ? 1 : 0), + ]; + + $legacyCourse->resources[RESOURCE_FORUMTOPIC][$id] = + $this->mkLegacyItem(RESOURCE_FORUMTOPIC, $id, $payload); } } /** - * Build the works (or "student publications", or "assignments"). + * Export first post for each thread as topic root post. * - * @param int $session_id Internal session ID - * @param int $courseId Internal course ID - * @param bool $withBaseContent Whether to include content from the course without session or not - * @param array $idList If you want to restrict the structure to only the given IDs - * @throws Exception + * @param object $legacyCourse + * @param CourseEntity $courseEntity + * @param SessionEntity|null $sessionEntity + * @param array $ids + * @return void */ - public function build_works( - int $session_id = 0, - int $courseId = 0, - $withBaseContent = false, - $idList = [] - ) { - $table_work = Database::get_course_table(TABLE_STUDENT_PUBLICATION); - $sessionCondition = api_get_session_condition( - $session_id, - true, - $withBaseContent - ); - $courseId = (int) $courseId; + private function build_forum_posts( + object $legacyCourse, + CourseEntity $courseEntity, + ?SessionEntity $sessionEntity, + array $ids + ): void { + $repoThread = Container::getForumThreadRepository(); + $repoPost = Container::getForumPostRepository(); + + $qb = $repoThread->getResourcesByCourse($courseEntity, $sessionEntity); + $threads = $qb->getQuery()->getResult(); + + $keep = $this->makeIdFilter($ids); + + foreach ($threads as $t) { + /** @var CForumThread $t */ + $threadId = (int) $t->getIid(); + if (!$keep($threadId)) { + continue; + } - $idCondition = ''; - if (!empty($idList)) { - $idList = array_map('intval', $idList); - $idCondition = ' AND iid IN ("'.implode('","', $idList).'") '; - } - - $sql = "SELECT * FROM $table_work - WHERE - c_id = $courseId - $sessionCondition AND - filetype = 'folder' AND - parent_id = 0 AND - active = 1 - $idCondition - "; - $result = Database::query($sql); - while ($row = Database::fetch_assoc($result)) { - $obj = new Work($row); - $this->course->add_resource($obj); + $first = $repoPost->findOneBy(['thread' => $t], ['postDate' => 'ASC', 'iid' => 'ASC']); + if (!$first) { + continue; + } + + $postId = (int) $first->getIid(); + $titleFromPost = trim((string) $first->getTitle()); + if ($titleFromPost === '') { + $plain = trim(strip_tags((string) ($first->getPostText() ?? ''))); + $titleFromPost = mb_substr($plain !== '' ? $plain : 'Post', 0, 60); + } + + $payload = [ + 'title' => $titleFromPost, + 'post_title' => $titleFromPost, + 'post_text' => (string) ($first->getPostText() ?? ''), + 'thread_id' => $threadId, + 'forum_id' => (int) ($t->getForum()?->getIid() ?? 0), + 'post_notification' => (int) ($first->getPostNotification() ? 1 : 0), + 'visible' => (int) ($first->getVisible() ? 1 : 0), + 'status' => (int) ($first->getStatus() ?? CForumPost::STATUS_VALIDATED), + 'post_parent_id' => (int) ($first->getPostParent()?->getIid() ?? 0), + 'poster_id' => (int) ($first->getUser()?->getId() ?? 0), + 'text' => (string) ($first->getPostText() ?? ''), + 'poster_name' => (string) ($first->getUser()?->getUsername() ?? ''), + 'post_date' => $this->fmtDate($first->getPostDate()), + ]; + + $legacyCourse->resources[RESOURCE_FORUMPOST][$postId] = + $this->mkLegacyItem(RESOURCE_FORUMPOST, $postId, $payload); } } + /* ----------------------------------------------------------------- + * Documents (Chamilo 2 style) + * ----------------------------------------------------------------- */ + /** - * @param int $session_id - * @param int $courseId - * @param bool $withBaseContent + * New Chamilo 2 build: CDocumentRepository-based (instead of legacy tables). + * + * @param CourseEntity|null $course + * @param SessionEntity|null $session + * @param bool $withBaseContent + * @param array $idList + * @return void */ - public function build_gradebook( - $session_id = 0, - $courseId = 0, - $withBaseContent = false - ) { - $courseInfo = api_get_course_info_by_id($courseId); - $courseCode = $courseInfo['code']; - $cats = Category::load( - null, - null, - $courseCode, + private function build_documents_with_repo( + ?CourseEntity $course, + ?SessionEntity $session, + bool $withBaseContent, + array $idList = [] + ): void { + if (!$course instanceof CourseEntity) { + return; + } + + $qb = $this->docRepo->getResourcesByCourse( + $course, + $session, null, null, - $session_id + true, + false ); - if (!empty($cats)) { - /** @var Category $cat */ - foreach ($cats as $cat) { - $cat->evaluations = $cat->get_evaluations(null, false); - $cat->links = $cat->get_links(null, false); - $cat->subCategories = $cat->get_subcategories( - null, - $courseCode, - $session_id - ); + if (!empty($idList)) { + $qb->andWhere('resource.iid IN (:ids)') + ->setParameter('ids', array_values(array_unique(array_map('intval', $idList)))); + } + + /** @var CDocument[] $docs */ + $docs = $qb->getQuery()->getResult(); + + foreach ($docs as $doc) { + $node = $doc->getResourceNode(); + $filetype = $doc->getFiletype(); // 'file'|'folder'|... + $title = $doc->getTitle(); + $comment = $doc->getComment() ?? ''; + $iid = (int) $doc->getIid(); + $fullPath = $doc->getFullPath(); + + // Determine size + $size = 0; + if ($filetype === 'folder') { + $size = $this->docRepo->getFolderSize($node, $course, $session); + } else { + /** @var Collection|null $files */ + $files = $node?->getResourceFiles(); + if ($files && $files->count() > 0) { + /** @var ResourceFile $first */ + $first = $files->first(); + $size = (int) $first->getSize(); + } } - $obj = new GradeBookBackup($cats); - $this->course->add_resource($obj); + + $exportDoc = new Document( + $iid, + '/' . $fullPath, + $comment, + $title, + $filetype, + (string) $size + ); + + $this->course->add_resource($exportDoc); } } + + /** + * Backward-compatible wrapper for build_documents_with_repo(). + * + * @param int $session_id + * @param int $courseId + * @param bool $withBaseContent + * @param array $idList + * @return void + */ + public function build_documents( + int $session_id = 0, + int $courseId = 0, + bool $withBaseContent = false, + array $idList = [] + ): void { + /** @var CourseEntity|null $course */ + $course = $this->em->getRepository(CourseEntity::class)->find($courseId); + /** @var SessionEntity|null $session */ + $session = $session_id ? $this->em->getRepository(SessionEntity::class)->find($session_id) : null; + + $this->build_documents_with_repo($course, $session, $withBaseContent, $idList); + } } diff --git a/src/CourseBundle/Component/CourseCopy/CourseRecycler.php b/src/CourseBundle/Component/CourseCopy/CourseRecycler.php index c543657d592..2dfffe83dbb 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRecycler.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRecycler.php @@ -2,779 +2,312 @@ /* For licensing terms, see /license.txt */ -namespace Chamilo\CourseBundle\Component\CourseCopy; +declare(strict_types=1); -use CourseManager; -use Database; -use TestCategory; +namespace Chamilo\CourseBundle\Component\CourseCopy; -/** - * Class to delete items from a Chamilo-course. - * - * @author Bart Mollet - */ -class CourseRecycler +use Chamilo\CoreBundle\Entity\AbstractResource; +use Chamilo\CourseBundle\Entity\CAnnouncement; +use Chamilo\CourseBundle\Entity\CAttendance; +use Chamilo\CourseBundle\Entity\CCalendarEvent; +use Chamilo\CourseBundle\Entity\CCourseDescription; +use Chamilo\CourseBundle\Entity\CDocument; +use Chamilo\CourseBundle\Entity\CForum; +use Chamilo\CourseBundle\Entity\CForumCategory; +use Chamilo\CourseBundle\Entity\CGlossary; +use Chamilo\CourseBundle\Entity\CLink; +use Chamilo\CourseBundle\Entity\CLinkCategory; +use Chamilo\CourseBundle\Entity\CLp; +use Chamilo\CourseBundle\Entity\CLpCategory; +use Chamilo\CourseBundle\Entity\CQuiz; +use Chamilo\CourseBundle\Entity\CQuizCategory; +use Chamilo\CourseBundle\Entity\CStudentPublication; +use Chamilo\CourseBundle\Entity\CSurvey; +use Chamilo\CourseBundle\Entity\CThematic; +use Chamilo\CourseBundle\Entity\CWiki; +use Chamilo\CoreBundle\Entity\Course as CoreCourse; +use Doctrine\ORM\EntityManagerInterface; + +final class CourseRecycler { - /** - * A course-object with the items to delete. - */ - public $course; - public $type; - - /** - * Create a new CourseRecycler. - * - * @param course $course The course-object which contains the items to - * delete - */ - public function __construct($course) + public function __construct( + private readonly EntityManagerInterface $em, + private readonly string $courseCode, + private readonly int $courseId + ) {} + + /** $type: 'full_backup' | 'select_items' ; $selected: [type => [id => true]] */ + public function recycle(string $type, array $selected): void { - $this->course = $course; - $this->course_info = api_get_course_info($this->course->code); - $this->course_id = $this->course_info['real_id']; + $isFull = ($type === 'full_backup'); + + // If your EM doesn't have wrapInTransaction(), replace by $this->em->transactional(fn() => { ... }) + $this->em->wrapInTransaction(function () use ($isFull, $selected) { + // Links & categories + $this->recycleGeneric($isFull, CLink::class, $selected['link'] ?? []); + $this->recycleGeneric($isFull, CLinkCategory::class, $selected['link_category'] ?? [], autoClean: true); + + // Calendar & announcements + $this->recycleGeneric($isFull, CCalendarEvent::class, $selected['event'] ?? []); + $this->recycleGeneric($isFull, CAnnouncement::class, $selected['announcement'] ?? []); + + // Documents + $this->recycleGeneric($isFull, CDocument::class, $selected['document'] ?? [], deleteFiles: true); + + // Forums & forum categories + $this->recycleGeneric($isFull, CForum::class, $selected['forum'] ?? [], cascadeHeavy: true); + $this->recycleGeneric($isFull, CForumCategory::class, $selected['forum_category'] ?? [], autoClean: true); + + // Quizzes & categories + $this->recycleGeneric($isFull, CQuiz::class, $selected['quiz'] ?? [], cascadeHeavy: true); + $this->recycleGeneric($isFull, CQuizCategory::class, $selected['test_category'] ?? []); + + // Surveys + $this->recycleGeneric($isFull, CSurvey::class, $selected['survey'] ?? [], cascadeHeavy: true); + + // Learning paths & categories + $this->recycleGeneric($isFull, CLp::class, $selected['learnpath'] ?? [], cascadeHeavy: true, scormCleanup: true); + $this->recycleLpCategories($isFull, $selected['learnpath_category'] ?? []); + + // Other resources + $this->recycleGeneric($isFull, CCourseDescription::class, $selected['course_description'] ?? []); + $this->recycleGeneric($isFull, CWiki::class, $selected['wiki'] ?? [], cascadeHeavy: true); + $this->recycleGeneric($isFull, CGlossary::class, $selected['glossary'] ?? []); + $this->recycleGeneric($isFull, CThematic::class, $selected['thematic'] ?? [], cascadeHeavy: true); + $this->recycleGeneric($isFull, CAttendance::class, $selected['attendance'] ?? [], cascadeHeavy: true); + $this->recycleGeneric($isFull, CStudentPublication::class, $selected['work'] ?? [], cascadeHeavy: true); + + if ($isFull) { + // If you keep cleaning course picture: + // CourseManager::deleteCoursePicture($this->courseCode); + } + }); } /** - * Delete all items from the course. - * This deletes all items in the course-object from the current Chamilo- - * course. - * - * @param string $backupType 'full_backup' or 'select_items' - * - * @return bool - * - * @assert (null) === false + * Generic recycler for any AbstractResource-based entity. + * - If $isFull => deletes *all resources of that type* for the course. + * - If partial => deletes only the provided $ids. + * Options: + * - deleteFiles: physical files are already handled by hardDelete (if repo supports it). + * - cascadeHeavy: for heavy-relations types (forums, LPs). hardDelete should traverse. + * - autoClean: e.g. remove empty categories after deleting links/forums. + * - scormCleanup: if LP SCORM → hook storage service if needed. */ - public function recycle($backupType) - { - if (empty($backupType)) { - return false; + private function recycleGeneric( + bool $isFull, + string $entityClass, + array $idsMap, + bool $deleteFiles = false, + bool $cascadeHeavy = false, + bool $autoClean = false, + bool $scormCleanup = false + ): void { + if ($isFull) { + $this->deleteAllOfTypeForCourse($entityClass); + if ($autoClean) { + $this->autoCleanIfSupported($entityClass); + } + if ($scormCleanup && $entityClass === CLp::class) { + $this->cleanupScormDirsForAllLp(); + } + return; } - $table_tool_intro = Database::get_course_table(TABLE_TOOL_INTRO); - $table_item_properties = Database::get_course_table(TABLE_ITEM_PROPERTY); - - $this->type = $backupType; - $this->recycle_links(); - $this->recycle_link_categories(); - $this->recycle_events(); - $this->recycle_announcements(); - $this->recycle_documents(); - $this->recycle_forums(); - $this->recycle_forum_categories(); - $this->recycle_quizzes(); - $this->recycle_test_category(); - $this->recycle_surveys(); - $this->recycle_learnpaths(); - $this->recycle_learnpath_categories(); - $this->recycle_cours_description(); - $this->recycle_wiki(); - $this->recycle_glossary(); - $this->recycle_thematic(); - $this->recycle_attendance(); - $this->recycle_work(); - - foreach ($this->course->resources as $type => $resources) { - foreach ($resources as $id => $resource) { - if (is_numeric($id)) { - $sql = "DELETE FROM $table_item_properties - WHERE c_id = ".$this->course_id." AND tool ='".$resource->get_tool()."' AND ref=".$id; - Database::query($sql); - } elseif (RESOURCE_TOOL_INTRO == $type) { - $sql = "DELETE FROM $table_tool_intro - WHERE c_id = ".$this->course_id." AND id='$id'"; - Database::query($sql); - } + $ids = $this->ids($idsMap); + if (!$ids) { + if ($autoClean) { + $this->autoCleanIfSupported($entityClass); } + return; } - if ('full_backup' === $backupType) { - CourseManager::deleteCoursePicture($this->course_info['code']); - } - } + $this->deleteSelectedOfTypeForCourse($entityClass, $ids); - /** - * Delete documents. - */ - public function recycle_documents() - { - $table = Database::get_course_table(TABLE_DOCUMENT); - $tableItemProperty = Database::get_course_table(TABLE_ITEM_PROPERTY); - - if ('full_backup' === $this->type) { - $sql = "DELETE FROM $tableItemProperty - WHERE - c_id = ".$this->course_id." AND - tool = '".TOOL_DOCUMENT."'"; - Database::query($sql); - - $sql = "DELETE FROM $table WHERE c_id = ".$this->course_id; - Database::query($sql); - - // Delete all content in the documents. - rmdirr($this->course->backup_path.'/document', true); - } else { - if ($this->course->has_resources(RESOURCE_DOCUMENT)) { - foreach ($this->course->resources[RESOURCE_DOCUMENT] as $document) { - rmdirr($this->course->backup_path.'/'.$document->path); - } - - $ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_DOCUMENT]))); - if (!empty($ids)) { - $sql = "DELETE FROM $table - WHERE c_id = ".$this->course_id.' AND id IN('.$ids.')'; - Database::query($sql); - } - } + if ($autoClean) { + $this->autoCleanIfSupported($entityClass); + } + if ($scormCleanup && $entityClass === CLp::class) { + $this->cleanupScormDirsForLpIds($ids); } } - /** - * Delete wiki. - */ - public function recycle_wiki() + /** LP categories: detach LPs and then delete selected/all categories */ + private function recycleLpCategories(bool $isFull, array $idsMap): void { - if ($this->course->has_resources(RESOURCE_WIKI)) { - $table_wiki = Database::get_course_table(TABLE_WIKI); - $table_wiki_conf = Database::get_course_table(TABLE_WIKI_CONF); - $pages = []; - foreach ($this->course->resources[RESOURCE_WIKI] as $resource) { - $pages[] = $resource->page_id; - } - - $wiki_ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_WIKI]))); - if (!empty($wiki_ids)) { - $page_ids = implode(',', $pages); - - $sql = 'DELETE FROM '.$table_wiki.' - WHERE c_id = '.$this->course_id.' AND id IN('.$wiki_ids.')'; - Database::query($sql); + if ($isFull) { + // Detach all categories from LPs in course + $this->clearLpCategoriesForCourse(); + $this->deleteAllOfTypeForCourse(CLpCategory::class); + return; + } - $sql = 'DELETE FROM '.$table_wiki_conf.' - WHERE c_id = '.$this->course_id.' AND page_id IN('.$page_ids.')'; - Database::query($sql); - } + $ids = $this->ids($idsMap); + if (!$ids) { + return; } + + // Detach LPs from these categories + $this->clearLpCategoriesForIds($ids); + $this->deleteSelectedOfTypeForCourse(CLpCategory::class, $ids); } - /** - * Delete glossary. - */ - public function recycle_glossary() + /** Normalizes IDs from [id => true] maps into int/string scalars */ + private function ids(array $map): array { - if ($this->course->has_resources(RESOURCE_GLOSSARY)) { - $table = Database::get_course_table(TABLE_GLOSSARY); - $ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_GLOSSARY]))); - if (!empty($ids)) { - $sql = "DELETE FROM $table - WHERE c_id = ".$this->course_id.' AND glossary_id IN('.$ids.')'; - Database::query($sql); - } - } + return array_values(array_filter(array_map( + static fn($k) => is_numeric($k) ? (int) $k : (string) $k, + array_keys($map) + ), static fn($v) => $v !== '' && $v !== null)); } - /** - * Delete links. - */ - public function recycle_links() + /** Lightweight Course reference for query builders */ + private function courseRef(): CoreCourse { - if ($this->course->has_resources(RESOURCE_LINK)) { - $table = Database::get_course_table(TABLE_LINK); - $ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_LINK]))); - if (!empty($ids)) { - $sql = "DELETE FROM $table - WHERE c_id = ".$this->course_id.' AND id IN('.$ids.')'; - Database::query($sql); - } - } + /** @var CoreCourse $ref */ + $ref = $this->em->getReference(CoreCourse::class, $this->courseId); + return $ref; } /** - * Delete forums. + * Fetches resources by entity class within course, optionally filtering by resource iid. + * If the repository doesn't extend ResourceRepository, falls back to a generic QB. + * + * @return array */ - public function recycle_forums() + private function fetchResourcesForCourse(string $entityClass, ?array $ids = null): array { - $table_category = Database::get_course_table(TABLE_FORUM_CATEGORY); - $table_forum = Database::get_course_table(TABLE_FORUM); - $table_thread = Database::get_course_table(TABLE_FORUM_THREAD); - $table_post = Database::get_course_table(TABLE_FORUM_POST); - $table_attachment = Database::get_course_table(TABLE_FORUM_ATTACHMENT); - $table_notification = Database::get_course_table(TABLE_FORUM_NOTIFICATION); - $table_mail_queue = Database::get_course_table(TABLE_FORUM_MAIL_QUEUE); - $table_thread_qualify = Database::get_course_table(TABLE_FORUM_THREAD_QUALIFY); - $table_thread_qualify_log = Database::get_course_table(TABLE_FORUM_THREAD_QUALIFY_LOG); - - if ('full_backup' === $this->type) { - $sql = 'DELETE FROM '.$table_category.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_forum.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_thread.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_post.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_attachment.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_notification.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_mail_queue.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_thread_qualify.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_thread_qualify_log.' WHERE c_id = '.$this->course_id; - Database::query($sql); - $sql = 'DELETE FROM '.$table_thread_qualify_log.' WHERE c_id = '.$this->course_id; - Database::query($sql); - } + $repo = $this->em->getRepository($entityClass); - if ($this->course->has_resources(RESOURCE_FORUMCATEGORY)) { - $forum_ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_FORUMCATEGORY]))); - if (!empty($forum_ids)) { - $sql = 'DELETE FROM '.$table_category.' - WHERE c_id = '.$this->course_id.' AND cat_id IN('.$forum_ids.');'; - Database::query($sql); + // Path A: repository exposes ResourceRepository API + if (method_exists($repo, 'getResourcesByCourseIgnoreVisibility')) { + $qb = $repo->getResourcesByCourseIgnoreVisibility($this->courseRef()); + if ($ids && \count($ids) > 0) { + $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids); } + return $qb->getQuery()->getResult(); } - if ($this->course->has_resources(RESOURCE_FORUM)) { - $forum_ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_FORUM]))); - - if (empty($forum_ids)) { - return false; - } - - $sql = "DELETE FROM $table_attachment USING $table_attachment - INNER JOIN $table_post - WHERE ".$table_post.'.c_id = '.$this->course_id.' AND - '.$table_attachment.'.c_id = '.$this->course_id.' AND - '.$table_attachment.'.post_id = '.$table_post.'.post_id'. - ' AND '.$table_post.'.forum_id IN('.$forum_ids.');'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_mail_queue.' USING '.$table_mail_queue." INNER JOIN $table_post - WHERE - ".$table_post.'.c_id = '.$this->course_id.' AND - '.$table_mail_queue.'.c_id = '.$this->course_id.' AND - '.$table_mail_queue.'.post_id = '.$table_post.'.post_id AND - '.$table_post.'.forum_id IN('.$forum_ids.');'; - Database::query($sql); - - // Just in case, deleting in the same table using thread_id as record-linker. - $sql = "DELETE FROM $table_mail_queue - USING ".$table_mail_queue." INNER JOIN $table_thread - WHERE - $table_mail_queue.c_id = ".$this->course_id." AND - $table_thread.c_id = ".$this->course_id." AND - $table_mail_queue.thread_id = ".$table_thread.".thread_id AND - $table_thread.forum_id IN(".$forum_ids.');'; - Database::query($sql); - - $sql = "DELETE FROM $table_thread_qualify - USING $table_thread_qualify INNER JOIN $table_thread - WHERE - $table_thread_qualify.c_id = ".$this->course_id." AND - $table_thread.c_id = ".$this->course_id." AND - $table_thread_qualify.thread_id = $table_thread.thread_id AND - $table_thread.forum_id IN(".$forum_ids.');'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_thread_qualify_log. - ' USING '.$table_thread_qualify_log.' INNER JOIN '.$table_thread. - " WHERE - $table_thread_qualify_log.c_id = ".$this->course_id." AND - $table_thread.c_id = ".$this->course_id.' AND - '.$table_thread_qualify_log.'.thread_id = '.$table_thread.'.thread_id AND - '.$table_thread.'.forum_id IN('.$forum_ids.');'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_notification.' - WHERE c_id = '.$this->course_id.' AND forum_id IN('.$forum_ids.')'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_post.' - WHERE c_id = '.$this->course_id.' AND forum_id IN('.$forum_ids.')'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_thread.' - WHERE c_id = '.$this->course_id.' AND forum_id IN('.$forum_ids.')'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_forum.' - WHERE c_id = '.$this->course_id.' AND forum_id IN('.$forum_ids.')'; - Database::query($sql); + // Path B: generic fallback (join to ResourceNode/ResourceLinks and filter by course) + $qb = $this->em->createQueryBuilder() + ->select('resource') + ->from($entityClass, 'resource') + ->innerJoin('resource.resourceNode', 'node') + ->innerJoin('node.resourceLinks', 'links') + ->andWhere('links.course = :course') + ->setParameter('course', $this->courseRef()); + + if ($ids && \count($ids) > 0) { + $qb->andWhere('resource.iid IN (:ids)')->setParameter('ids', $ids); } - } - - /** - * Deletes all forum-categories without forum from the current course. - * Categories with forums in it are dealt with by recycle_forums() - * This requires a check on the status of the forum item in c_item_property. - */ - public function recycle_forum_categories() - { - $forumTable = Database::get_course_table(TABLE_FORUM); - $forumCategoryTable = Database::get_course_table(TABLE_FORUM_CATEGORY); - $itemPropertyTable = Database::get_course_table(TABLE_ITEM_PROPERTY); - $courseId = $this->course_id; - // c_forum_forum.forum_category points to c_forum_category.cat_id and - // has to be queried *with* the c_id to ensure a match - $subQuery = "SELECT distinct(f.forum_category) as categoryId - FROM $forumTable f, $itemPropertyTable i - WHERE - f.c_id = $courseId AND - i.c_id = f.c_id AND - i.tool = 'forum' AND - f.iid = i.ref AND - i.visibility = 1"; - $sql = "DELETE FROM $forumCategoryTable - WHERE c_id = $courseId AND cat_id NOT IN ($subQuery)"; - Database::query($sql); - } - /** - * Deletes all empty link-categories (=without links) from current course. - * Links are already dealt with by recycle_links() but if recycle is called - * on categories and not on link, then non-empty categories will survive - * the recycling. - */ - public function recycle_link_categories() - { - $linkCategoryTable = Database::get_course_table(TABLE_LINK_CATEGORY); - $linkTable = Database::get_course_table(TABLE_LINK); - $itemPropertyTable = Database::get_course_table(TABLE_ITEM_PROPERTY); - $courseId = $this->course_id; - // c_link.category_id points to c_link_category.id and - // has to be queried *with* the c_id to ensure a match - $subQuery = "SELECT distinct(l.category_id) as categoryId - FROM $linkTable l, $itemPropertyTable i - WHERE - l.c_id = $courseId AND - i.c_id = l.c_id AND - i.tool = 'link' AND - l.iid = i.ref AND - i.visibility = 1"; - $sql = "DELETE FROM $linkCategoryTable - WHERE c_id = $courseId AND id NOT IN ($subQuery)"; - Database::query($sql); + return $qb->getQuery()->getResult(); } /** - * Delete events. + * Hard-deletes a list of resources. If repository doesn't provide hardDelete(), + * falls back to EM->remove() and a final flush (expect proper cascade mappings). */ - public function recycle_events() + private function hardDeleteMany(string $entityClass, array $resources): void { - if ($this->course->has_resources(RESOURCE_EVENT)) { - $table = Database::get_course_table(TABLE_AGENDA); - $table_attachment = Database::get_course_table(TABLE_AGENDA_ATTACHMENT); - - $ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_EVENT]))); - if (!empty($ids)) { - $sql = 'DELETE FROM '.$table.' - WHERE c_id = '.$this->course_id.' AND id IN('.$ids.')'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_attachment.' - WHERE c_id = '.$this->course_id.' AND agenda_id IN('.$ids.')'; - Database::query($sql); + $repo = $this->em->getRepository($entityClass); + + $usedFallback = false; + foreach ($resources as $res) { + if (method_exists($repo, 'hardDelete')) { + // hardDelete takes care of Resource, ResourceNode, Links and Files (Flysystem) + $repo->hardDelete($res); + } else { + // Fallback: standard remove. Ensure your mappings cascade what you need. + $this->em->remove($res); + $usedFallback = true; } } - } - /** - * Delete announcements. - */ - public function recycle_announcements() - { - if ($this->course->has_resources(RESOURCE_ANNOUNCEMENT)) { - $table = Database::get_course_table(TABLE_ANNOUNCEMENT); - $table_attachment = Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT); - - $ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_ANNOUNCEMENT]))); - if (!empty($ids)) { - $sql = 'DELETE FROM '.$table.' - WHERE c_id = '.$this->course_id.' AND id IN('.$ids.')'; - Database::query($sql); - - $sql = 'DELETE FROM '.$table_attachment.' - WHERE c_id = '.$this->course_id.' AND announcement_id IN('.$ids.')'; - Database::query($sql); - } + // One flush at the end. If hardDelete() already flushed internally, this is harmless. + if ($usedFallback) { + $this->em->flush(); } } - /** - * Recycle quizzes - doesn't remove the questions and their answers, - * as they might still be used later. - */ - public function recycle_quizzes() + /** Deletes all resources of a type in the course */ + private function deleteAllOfTypeForCourse(string $entityClass): void { - if ($this->course->has_resources(RESOURCE_QUIZ)) { - $table_qui_que = Database::get_course_table(TABLE_QUIZ_QUESTION); - $table_qui_ans = Database::get_course_table(TABLE_QUIZ_ANSWER); - $table_qui = Database::get_course_table(TABLE_QUIZ_TEST); - $table_rel = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION); - $table_qui_que_opt = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION); - $table_qui_que_cat = Database::get_course_table(TABLE_QUIZ_QUESTION_CATEGORY); - $table_qui_que_rel_cat = Database::get_course_table(TABLE_QUIZ_QUESTION_REL_CATEGORY); - - $ids = array_keys($this->course->resources[RESOURCE_QUIZ]); - // If the value "-1" is in the ids of elements (questions) to - // be deleted, then consider all orphan questions should be deleted - // This value is set in CourseBuilder::quiz_build_questions() - $delete_orphan_questions = in_array(-1, $ids); - $ids = implode(',', $ids); - - if (!empty($ids)) { - // Deletion of the tests first. Questions in these tests are - // not deleted and become orphan at this point - $sql = 'DELETE FROM '.$table_qui.' - WHERE c_id = '.$this->course_id.' AND id IN('.$ids.')'; - Database::query($sql); - $sql = 'DELETE FROM '.$table_rel.' - WHERE c_id = '.$this->course_id.' AND quiz_id IN('.$ids.')'; - Database::query($sql); - } - - // Identifying again and deletion of the orphan questions, if it was desired. - if ($delete_orphan_questions) { - // If this query was ever too slow, there is an alternative here: - // https://github.com/beeznest/chamilo-lms-icpna/commit/a38eab725402188dffff50245ee068d79bcef779 - $sql = " ( - SELECT q.id, ex.c_id FROM $table_qui_que q - INNER JOIN $table_rel r - ON (q.c_id = r.c_id AND q.id = r.question_id) - - INNER JOIN $table_qui ex - ON (ex.id = r.quiz_id AND ex.c_id = r.c_id) - WHERE ex.c_id = ".$this->course_id." AND (ex.active = '-1' OR ex.id = '-1') - ) - UNION - ( - SELECT q.id, r.c_id FROM $table_qui_que q - LEFT OUTER JOIN $table_rel r - ON (q.c_id = r.c_id AND q.id = r.question_id) - WHERE q.c_id = ".$this->course_id." AND r.question_id is null - ) - UNION - ( - SELECT q.id, r.c_id FROM $table_qui_que q - INNER JOIN $table_rel r - ON (q.c_id = r.c_id AND q.id = r.question_id) - WHERE r.c_id = ".$this->course_id." AND (r.quiz_id = '-1' OR r.quiz_id = '0') - )"; - $db_result = Database::query($sql); - if (Database::num_rows($db_result) > 0) { - $orphan_ids = []; - while ($obj = Database::fetch_object($db_result)) { - $orphan_ids[] = $obj->id; - } - $orphan_ids = implode(',', $orphan_ids); - $sql = 'DELETE FROM '.$table_rel.' - WHERE c_id = '.$this->course_id.' AND question_id IN('.$orphan_ids.')'; - Database::query($sql); - $sql = 'DELETE FROM '.$table_qui_ans.' - WHERE c_id = '.$this->course_id.' AND question_id IN('.$orphan_ids.')'; - Database::query($sql); - $sql = 'DELETE FROM '.$table_qui_que.' - WHERE c_id = '.$this->course_id.' AND id IN('.$orphan_ids.')'; - Database::query($sql); - } - // Also delete questions categories and options - $sql = "DELETE FROM $table_qui_que_rel_cat WHERE c_id = ".$this->course_id; - Database::query($sql); - $sql = "DELETE FROM $table_qui_que_cat WHERE c_id = ".$this->course_id; - Database::query($sql); - $sql = "DELETE FROM $table_qui_que_opt WHERE c_id = ".$this->course_id; - Database::query($sql); - } - - // Quizzes previously deleted are, in fact, kept with a status - // (active field) of "-1". Delete those, now. - $sql = 'DELETE FROM '.$table_qui.' WHERE c_id = '.$this->course_id.' AND active = -1'; - Database::query($sql); + $resources = $this->fetchResourcesForCourse($entityClass, null); + if ($resources) { + $this->hardDeleteMany($entityClass, $resources); } } - /** - * Recycle tests categories. - */ - public function recycle_test_category() + /** Deletes selected resources (by iid) of a type in the course */ + private function deleteSelectedOfTypeForCourse(string $entityClass, array $ids): void { - if (isset($this->course->resources[RESOURCE_TEST_CATEGORY])) { - foreach ($this->course->resources[RESOURCE_TEST_CATEGORY] as $tab_test_cat) { - $obj_cat = new TestCategory(); - $obj_cat->removeCategory($tab_test_cat->source_id); - } + if (!$ids) { + return; + } + $resources = $this->fetchResourcesForCourse($entityClass, $ids); + if ($resources) { + $this->hardDeleteMany($entityClass, $resources); } } - /** - * Recycle surveys - removes everything. - */ - public function recycle_surveys() + /** Optional post-clean for empty categories if repository supports it */ + private function autoCleanIfSupported(string $entityClass): void { - if ($this->course->has_resources(RESOURCE_SURVEY)) { - $table_survey = Database::get_course_table(TABLE_SURVEY); - $table_survey_q = Database::get_course_table(TABLE_SURVEY_QUESTION); - $table_survey_q_o = Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION); - $table_survey_a = Database::get_course_Table(TABLE_SURVEY_ANSWER); - $table_survey_i = Database::get_course_table(TABLE_SURVEY_INVITATION); - $sql = "DELETE FROM $table_survey_i - WHERE c_id = ".$this->course_id; - Database::query($sql); - - $ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_SURVEY]))); - if (!empty($ids)) { - $sql = "DELETE FROM $table_survey_a - WHERE c_id = ".$this->course_id.' AND survey_id IN('.$ids.')'; - Database::query($sql); - - $sql = "DELETE FROM $table_survey_q_o - WHERE c_id = ".$this->course_id.' AND survey_id IN('.$ids.')'; - Database::query($sql); - - $sql = "DELETE FROM $table_survey_q - WHERE c_id = ".$this->course_id.' AND survey_id IN('.$ids.')'; - Database::query($sql); - - $sql = "DELETE FROM $table_survey - WHERE c_id = ".$this->course_id.' AND survey_id IN('.$ids.')'; - Database::query($sql); - } + $repo = $this->em->getRepository($entityClass); + if (method_exists($repo, 'deleteEmptyByCourse')) { + $repo->deleteEmptyByCourse($this->courseId); } } - /** - * Recycle learning paths. - */ - public function recycle_learnpaths() + /** Detach categories from ALL LPs in course (repo-level bulk method preferred if available) */ + private function clearLpCategoriesForCourse(): void { - if ($this->course->has_resources(RESOURCE_LEARNPATH)) { - $table_main = Database::get_course_table(TABLE_LP_MAIN); - $table_item = Database::get_course_table(TABLE_LP_ITEM); - $table_view = Database::get_course_table(TABLE_LP_VIEW); - $table_iv = Database::get_course_table(TABLE_LP_ITEM_VIEW); - $table_iv_int = Database::get_course_table(TABLE_LP_IV_INTERACTION); - $table_tool = Database::get_course_table(TABLE_TOOL_LIST); - - foreach ($this->course->resources[RESOURCE_LEARNPATH] as $id => $learnpath) { - // See task #875. - if (2 == $learnpath->lp_type) { - // This is a learning path of SCORM type. - // A sanity check for avoiding removal of the parent folder scorm/ - if ('' != trim($learnpath->path)) { - // when $learnpath->path value is incorrect for some reason. - // The directory trat contains files of the SCORM package is to be deleted. - $scorm_package_dir = realpath($this->course->path.'scorm/'.$learnpath->path); - rmdirr($scorm_package_dir); - } - } - - //remove links from course homepage - $sql = "DELETE FROM $table_tool - WHERE - c_id = ".$this->course_id." AND - link LIKE '%lp_controller.php%lp_id=$id%' AND - image='scormbuilder.gif'"; - Database::query($sql); - //remove elements from lp_* tables (from bottom-up) - // by removing interactions, then item_view, then views and items, then paths - $sql_items = "SELECT id FROM $table_item - WHERE c_id = ".$this->course_id." AND lp_id=$id"; - $res_items = Database::query($sql_items); - while ($row_item = Database::fetch_array($res_items)) { - //get item views - $sql_iv = "SELECT id FROM $table_iv - WHERE c_id = ".$this->course_id.' AND lp_item_id='.$row_item['id']; - $res_iv = Database::query($sql_iv); - while ($row_iv = Database::fetch_array($res_iv)) { - //delete interactions - $sql_iv_int_del = "DELETE FROM $table_iv_int - WHERE c_id = ".$this->course_id.' AND lp_iv_id = '.$row_iv['id']; - Database::query($sql_iv_int_del); - } - //delete item views - $sql_iv_del = "DELETE FROM $table_iv - WHERE c_id = ".$this->course_id.' AND lp_item_id='.$row_item['id']; - Database::query($sql_iv_del); + $lps = $this->fetchResourcesForCourse(CLp::class, null); + $changed = false; + foreach ($lps as $lp) { + if (method_exists($lp, 'getCategory') && method_exists($lp, 'setCategory')) { + if ($lp->getCategory()) { + $lp->setCategory(null); + $this->em->persist($lp); + $changed = true; } - //delete items - $sql_items_del = "DELETE FROM $table_item WHERE c_id = ".$this->course_id." AND lp_id=$id"; - Database::query($sql_items_del); - //delete views - $sql_views_del = "DELETE FROM $table_view WHERE c_id = ".$this->course_id." AND lp_id=$id"; - Database::query($sql_views_del); - //delete lps - $sql_del = "DELETE FROM $table_main WHERE c_id = ".$this->course_id." AND id = $id"; - Database::query($sql_del); } } - } - - /** - * Recycle selected learning path categories and dissociate learning paths - * that are associated with it. - */ - public function recycle_learnpath_categories() - { - $learningPathTable = Database::get_course_table(TABLE_LP_MAIN); - $learningPathCategoryTable = Database::get_course_table(TABLE_LP_CATEGORY); - $tblCTool = Database::get_course_table(TABLE_TOOL_LIST); - if (isset($this->course->resources[RESOURCE_LEARNPATH_CATEGORY])) { - foreach ($this->course->resources[RESOURCE_LEARNPATH_CATEGORY] as $id => $learnpathCategory) { - $categoryId = $learnpathCategory->object->getId(); - $sql = "DELETE FROM $tblCTool WHERE c_id = {$this->course_id} - AND link LIKE '%lp_controller.php%action=view_category&id=$categoryId%'"; - Database::query($sql); - // Dissociate learning paths from categories that will be deleted - $sql = "UPDATE $learningPathTable SET category_id = 0 WHERE category_id = ".$categoryId; - Database::query($sql); - $sql = "DELETE FROM $learningPathCategoryTable WHERE iid = ".$categoryId; - Database::query($sql); - } + if ($changed) { + $this->em->flush(); } } - /** - * Delete course description. - */ - public function recycle_cours_description() + /** Detach categories only for LPs that are linked to given category ids */ + private function clearLpCategoriesForIds(array $catIds): void { - if ($this->course->has_resources(RESOURCE_COURSEDESCRIPTION)) { - $table = Database::get_course_table(TABLE_COURSE_DESCRIPTION); - $ids = implode(',', array_filter(array_keys($this->course->resources[RESOURCE_COURSEDESCRIPTION]))); - if (!empty($ids)) { - $sql = "DELETE FROM $table - WHERE c_id = ".$this->course_id.' AND id IN('.$ids.')'; - Database::query($sql); + $lps = $this->fetchResourcesForCourse(CLp::class, null); + $changed = false; + foreach ($lps as $lp) { + $cat = method_exists($lp, 'getCategory') ? $lp->getCategory() : null; + $catId = $cat?->getId(); + if ($catId !== null && \in_array($catId, $catIds, true) && method_exists($lp, 'setCategory')) { + $lp->setCategory(null); + $this->em->persist($lp); + $changed = true; } } - } - - /** - * Recycle Thematics. - */ - public function recycle_thematic($session_id = 0) - { - if ($this->course->has_resources(RESOURCE_THEMATIC)) { - $table_thematic = Database::get_course_table(TABLE_THEMATIC); - $table_thematic_advance = Database::get_course_table(TABLE_THEMATIC_ADVANCE); - $table_thematic_plan = Database::get_course_table(TABLE_THEMATIC_PLAN); - - $resources = $this->course->resources; - foreach ($resources[RESOURCE_THEMATIC] as $last_id => $thematic) { - if (is_numeric($last_id)) { - foreach ($thematic->thematic_advance_list as $thematic_advance) { - $cond = [ - 'id = ? AND c_id = ?' => [ - $thematic_advance['id'], - $this->course_id, - ], - ]; - api_item_property_update( - $this->course_info, - 'thematic_advance', - $thematic_advance['id'], - 'ThematicAdvanceDeleted', - api_get_user_id() - ); - Database::delete($table_thematic_advance, $cond); - } - - foreach ($thematic->thematic_plan_list as $thematic_plan) { - $cond = [ - 'id = ? AND c_id = ?' => [ - $thematic_plan['id'], - $this->course_id, - ], - ]; - api_item_property_update( - $this->course_info, - 'thematic_plan', - $thematic_advance['id'], - 'ThematicPlanDeleted', - api_get_user_id() - ); - Database::delete($table_thematic_plan, $cond); - } - $cond = [ - 'id = ? AND c_id = ?' => [ - $last_id, - $this->course_id, - ], - ]; - api_item_property_update( - $this->course_info, - 'thematic', - $last_id, - 'ThematicDeleted', - api_get_user_id() - ); - Database::delete($table_thematic, $cond); - } - } + if ($changed) { + $this->em->flush(); } } - /** - * Recycle Attendances. - */ - public function recycle_attendance($session_id = 0) + /** SCORM directory cleanup for ALL LPs (hook your storage service here if needed) */ + private function cleanupScormDirsForAllLp(): void { - if ($this->course->has_resources(RESOURCE_ATTENDANCE)) { - $table_attendance = Database::get_course_table(TABLE_ATTENDANCE); - $table_attendance_calendar = Database::get_course_table(TABLE_ATTENDANCE_CALENDAR); - - $resources = $this->course->resources; - foreach ($resources[RESOURCE_ATTENDANCE] as $last_id => $obj) { - if (is_numeric($last_id)) { - foreach ($obj->attendance_calendar as $attendance_calendar) { - $cond = ['id = ? AND c_id = ? ' => [$attendance_calendar['id'], $this->course_id]]; - Database::delete($table_attendance_calendar, $cond); - } - $cond = ['id = ? AND c_id = ?' => [$last_id, $this->course_id]]; - Database::delete($table_attendance, $cond); - api_item_property_update( - $this->course_info, - TOOL_ATTENDANCE, - $last_id, - 'AttendanceDeleted', - api_get_user_id() - ); - } - } - } + // If you have a storage/scorm service, invoke it here. + // By default, nothing: hardDelete already deletes files linked to ResourceNode. } - /** - * Recycle Works. - */ - public function recycle_work($session_id = 0) + /** SCORM directory cleanup for selected LPs */ + private function cleanupScormDirsForLpIds(array $lpIds): void { - if ($this->course->has_resources(RESOURCE_WORK)) { - $table_work = Database::get_course_table(TABLE_STUDENT_PUBLICATION); - $table_work_assignment = Database::get_course_table(TABLE_STUDENT_PUBLICATION_ASSIGNMENT); - - $resources = $this->course->resources; - foreach ($resources[RESOURCE_WORK] as $last_id => $obj) { - if (is_numeric($last_id)) { - $cond = ['publication_id = ? AND c_id = ? ' => [$last_id, $this->course_id]]; - Database::delete($table_work_assignment, $cond); - // The following also deletes student tasks - $cond = ['parent_id = ? AND c_id = ?' => [$last_id, $this->course_id]]; - Database::delete($table_work, $cond); - // Finally, delete the main task registry - $cond = ['id = ? AND c_id = ?' => [$last_id, $this->course_id]]; - Database::delete($table_work, $cond); - api_item_property_update( - $this->course_info, - TOOL_STUDENTPUBLICATION, - $last_id, - 'StudentPublicationDeleted', - api_get_user_id() - ); - } - } - } + // Same as above, but limited to provided LP ids. } } diff --git a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php index 675d0bbd509..6e8a71338f2 100644 --- a/src/CourseBundle/Component/CourseCopy/CourseRestorer.php +++ b/src/CourseBundle/Component/CourseCopy/CourseRestorer.php @@ -4,25 +4,72 @@ namespace Chamilo\CourseBundle\Component\CourseCopy; -use AbstractLink; -use Category; -use Chamilo\CourseBundle\Component\CourseCopy\Resources\GradeBookBackup; +use AllowDynamicProperties; +use Chamilo\CoreBundle\Entity\GradebookCategory; +use Chamilo\CoreBundle\Entity\GradebookEvaluation; +use Chamilo\CoreBundle\Entity\GradebookLink; +use Chamilo\CoreBundle\Entity\GradeModel; +use Chamilo\CoreBundle\Entity\ResourceLink; +use Chamilo\CoreBundle\Entity\Room; +use Chamilo\CoreBundle\Entity\Session as SessionEntity; +use Chamilo\CoreBundle\Entity\Course as CourseEntity; +use Chamilo\CoreBundle\Entity\Tool; +use Chamilo\CoreBundle\Framework\Container; +use Chamilo\CoreBundle\Helpers\ChamiloHelper; +use Chamilo\CoreBundle\Repository\ResourceNodeRepository; +use Chamilo\CoreBundle\Tool\User; use Chamilo\CourseBundle\Component\CourseCopy\Resources\LearnPathCategory; -use Chamilo\CourseBundle\Component\CourseCopy\Resources\QuizQuestion; +use Chamilo\CourseBundle\Entity\CAnnouncement; +use Chamilo\CourseBundle\Entity\CAnnouncementAttachment; +use Chamilo\CourseBundle\Entity\CAttendance; +use Chamilo\CourseBundle\Entity\CAttendanceCalendar; +use Chamilo\CourseBundle\Entity\CAttendanceCalendarRelGroup; +use Chamilo\CourseBundle\Entity\CCalendarEvent; +use Chamilo\CourseBundle\Entity\CCalendarEventAttachment; +use Chamilo\CourseBundle\Entity\CCourseDescription; +use Chamilo\CourseBundle\Entity\CDocument; +use Chamilo\CourseBundle\Entity\CForum; +use Chamilo\CourseBundle\Entity\CForumCategory; +use Chamilo\CourseBundle\Entity\CForumPost; +use Chamilo\CourseBundle\Entity\CForumThread; +use Chamilo\CourseBundle\Entity\CGlossary; +use Chamilo\CourseBundle\Entity\CLink; +use Chamilo\CourseBundle\Entity\CLinkCategory; +use Chamilo\CourseBundle\Entity\CLp; use Chamilo\CourseBundle\Entity\CLpCategory; +use Chamilo\CourseBundle\Entity\CLpItem; +use Chamilo\CourseBundle\Entity\CQuiz; use Chamilo\CourseBundle\Entity\CQuizAnswer; +use Chamilo\CourseBundle\Entity\CQuizQuestion; +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; +use Chamilo\CourseBundle\Entity\CThematic; +use Chamilo\CourseBundle\Entity\CThematicAdvance; +use Chamilo\CourseBundle\Entity\CThematicPlan; +use Chamilo\CourseBundle\Entity\CTool; +use Chamilo\CourseBundle\Entity\CToolIntro; +use Chamilo\CourseBundle\Entity\CWiki; +use Chamilo\CourseBundle\Entity\CWikiConf; +use Chamilo\CourseBundle\Repository\CGlossaryRepository; +use Chamilo\CourseBundle\Repository\CStudentPublicationRepository; +use Chamilo\CourseBundle\Repository\CWikiRepository; use CourseManager; use Database; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\EntityManagerInterface; use DocumentManager; -use Evaluation; -use ExtraFieldValue; use GroupManager; -use Image; use learnpath; -use Question; -use stdClass; -use SurveyManager; -use TestCategory; +use PhpZip\ZipFile; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RouterInterface; /** * Class CourseRestorer. @@ -32,30 +79,33 @@ * @author Bart Mollet * @author Julio Montoya Several fixes/improvements */ +#[AllowDynamicProperties] class CourseRestorer { + /** Debug flag (default: true). Toggle with setDebug(). */ + private bool $debug = true; + /** * The course-object. */ public $course; public $destination_course_info; - /** - * What to do with files with same name (FILE_SKIP, FILE_RENAME or - * FILE_OVERWRITE). - */ + /** What to do with files with same name (FILE_SKIP, FILE_RENAME, FILE_OVERWRITE). */ public $file_option; public $set_tools_invisible_by_default; public $skip_content; + + /** Restore order (keep existing order; docs first). */ public $tools_to_restore = [ - 'documents', // first restore documents + 'documents', 'announcements', 'attendance', 'course_descriptions', 'events', 'forum_category', 'forums', - // 'forum_topics', + // 'forum_topics', 'glossary', 'quizzes', 'test_category', @@ -64,7 +114,7 @@ class CourseRestorer 'surveys', 'learnpath_category', 'learnpaths', - //'scorm_documents', ?? + 'scorm_documents', 'tool_intro', 'thematic', 'wiki', @@ -75,13 +125,52 @@ class CourseRestorer /** Setting per tool */ public $tool_copy_settings = []; - /** - * If true adds the text "copy" in the title of an item (only for LPs right now). - */ + /** If true adds the text "copy" in the title of an item (only for LPs right now). */ public $add_text_in_items = false; + public $destination_course_id; public bool $copySessionContent = false; + /** Optional course origin id (legacy). */ + private $course_origin_id = null; + + /** First teacher (owner) used for forums/posts. */ + private $first_teacher_id = 0; + + /** Destination course entity cache. */ + private $destination_course_entity; + + /** + * Centralized logger controlled by $this->debug. + */ + private function dlog(string $message, array $context = []): void + { + if (!$this->debug) { + return; + } + $ctx = ''; + if (!empty($context)) { + try { + $ctx = ' ' . json_encode( + $context, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR + ); + } catch (\Throwable $e) { + $ctx = ' [context_json_failed: '.$e->getMessage().']'; + } + } + error_log('COURSE_DEBUG: '.$message.$ctx); + } + + /** + * Public setter for the debug flag. + */ + public function setDebug(?bool $on = true): void + { + $this->debug = (bool) $on; + $this->dlog('Debug flag changed', ['debug' => $this->debug]); + } + /** * CourseRestorer constructor. * @@ -89,31 +178,36 @@ class CourseRestorer */ public function __construct($course) { + // Read env constant/course hint if present + if (defined('COURSE_RESTORER_DEBUG')) { + $this->debug = (bool) constant('COURSE_RESTORER_DEBUG'); + } + $this->course = $course; $courseInfo = api_get_course_info($this->course->code); - $this->course_origin_id = null; - if (!empty($courseInfo)) { - $this->course_origin_id = $courseInfo['real_id']; - } + $this->course_origin_id = !empty($courseInfo) ? $courseInfo['real_id'] : null; + $this->file_option = FILE_RENAME; $this->set_tools_invisible_by_default = false; $this->skip_content = []; - $forceImport = ('true' === api_get_setting('lp.allow_import_scorm_package_in_course_builder')); - if ($forceImport) { - $this->tools_to_restore[] = 'scorm_documents'; - } + $this->dlog('Ctor: initial course info', [ + 'course_code' => $this->course->code ?? null, + 'origin_id' => $this->course_origin_id, + 'has_resources' => is_array($this->course->resources ?? null), + 'resource_keys' => array_keys((array) ($this->course->resources ?? [])), + ]); } /** * Set the file-option. * - * @param int $option (optional) What to do with files with same name - * FILE_SKIP, FILE_RENAME or FILE_OVERWRITE + * @param int $option FILE_SKIP, FILE_RENAME or FILE_OVERWRITE */ public function set_file_option($option = FILE_OVERWRITE) { $this->file_option = $option; + $this->dlog('File option set', ['file_option' => $this->file_option]); } /** @@ -132,15 +226,65 @@ public function set_tool_copy_settings($array) $this->tool_copy_settings = $array; } + /** Normalize forum keys so internal bags are always available. */ + private function normalizeForumKeys(): void + { + if (!is_array($this->course->resources ?? null)) { + $this->course->resources = []; + return; + } + $r = $this->course->resources; + + // Categories + if (!isset($r['Forum_Category']) && isset($r['forum_category'])) { + $r['Forum_Category'] = $r['forum_category']; + } + + // Forums + if (!isset($r['forum']) && isset($r['Forum'])) { + $r['forum'] = $r['Forum']; + } + + // Topics + if (!isset($r['thread']) && isset($r['forum_topic'])) { + $r['thread'] = $r['forum_topic']; + } elseif (!isset($r['thread']) && isset($r['Forum_Thread'])) { + $r['thread'] = $r['Forum_Thread']; + } + + // Posts + if (!isset($r['post']) && isset($r['forum_post'])) { + $r['post'] = $r['forum_post']; + } elseif (!isset($r['post']) && isset($r['Forum_Post'])) { + $r['post'] = $r['Forum_Post']; + } + + $this->course->resources = $r; + $this->dlog('Forum keys normalized', [ + 'has_Forum_Category' => isset($r['Forum_Category']), + 'forum_count' => isset($r['forum']) && is_array($r['forum']) ? count($r['forum']) : 0, + 'thread_count' => isset($r['thread']) && is_array($r['thread']) ? count($r['thread']) : 0, + 'post_count' => isset($r['post']) && is_array($r['post']) ? count($r['post']) : 0, + ]); + } + + private function resetDoctrineIfClosed(): void + { + try { + $em = \Database::getManager(); + if (!$em->isOpen()) { + $registry = Container::$container->get('doctrine'); + $registry->resetManager(); + } else { + $em->clear(); + } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: resetDoctrineIfClosed failed: '.$e->getMessage()); + } + } + /** - * Restore a course. - * - * @param string $destination_course_code code of the Chamilo-course in - * @param int $session_id - * @param bool $update_course_settings Course settings are going to be restore? - * @param bool $respect_base_content - * - * @return false|null + * Entry point. */ public function restore( $destination_course_code = '', @@ -148,2994 +292,3343 @@ public function restore( $update_course_settings = false, $respect_base_content = false ) { - if ('' == $destination_course_code) { - $course_info = api_get_course_info(); - $this->destination_course_info = $course_info; - $this->course->destination_path = $course_info['path']; - } else { - $course_info = api_get_course_info($destination_course_code); - $this->destination_course_info = $course_info; - $this->course->destination_path = $course_info['path']; + $this->dlog('Restore() called', [ + 'destination_code' => $destination_course_code, + 'session_id' => (int) $session_id, + 'update_course_settings' => (bool) $update_course_settings, + 'respect_base_content' => (bool) $respect_base_content, + ]); + + // Resolve destination course + $course_info = $destination_course_code === '' + ? api_get_course_info() + : api_get_course_info($destination_course_code); + + if (empty($course_info) || empty($course_info['real_id'])) { + $this->dlog('Destination course not resolved or missing real_id', ['course_info' => $course_info]); + return false; } - $this->destination_course_id = $course_info['real_id']; - // Getting first teacher (for the forums) - $teacher_list = CourseManager::get_teacher_list_from_course_code($course_info['code']); - $this->first_teacher_id = api_get_user_id(); + $this->destination_course_info = $course_info; + $this->destination_course_id = (int) $course_info['real_id']; + $this->destination_course_entity = api_get_course_entity($this->destination_course_id); + // Resolve teacher for forum/thread/post ownership + $this->first_teacher_id = api_get_user_id(); + $teacher_list = CourseManager::get_teacher_list_from_course_code($course_info['code']); if (!empty($teacher_list)) { - foreach ($teacher_list as $teacher) { - $this->first_teacher_id = $teacher['user_id']; - - break; - } + foreach ($teacher_list as $t) { $this->first_teacher_id = (int) $t['user_id']; break; } } if (empty($this->course)) { + $this->dlog('No source course found'); return false; } - // Source platform encoding - reading/detection - // The correspondent data field has been added as of version 1.8.6.1 + // Encoding detection/normalization if (empty($this->course->encoding)) { - // The archive has been created by a system which is prior to 1.8.6.1 version. - // In this case we have to detect the encoding. $sample_text = $this->course->get_sample_text()."\n"; - // Let us exclude ASCII lines, probably they are English texts. - $sample_text = explode("\n", $sample_text); - foreach ($sample_text as $key => &$line) { - if (api_is_valid_ascii($line)) { - unset($sample_text[$key]); - } + $lines = explode("\n", $sample_text); + foreach ($lines as $k => $line) { + if (api_is_valid_ascii($line)) { unset($lines[$k]); } } - $sample_text = implode("\n", $sample_text); - $this->course->encoding = api_detect_encoding( - $sample_text, - $course_info['language'] - ); + $sample_text = implode("\n", $lines); + $this->course->encoding = api_detect_encoding($sample_text, $course_info['language']); } - - // Encoding conversion of the course, if it is needed. $this->course->to_system_encoding(); + $this->dlog('Encoding resolved', ['encoding' => $this->course->encoding ?? '']); + + // Normalize forum bags + $this->normalizeForumKeys(); + // Dump a compact view of the resource bags before restoring + $this->debug_course_resources_simple(null); + + // Restore tools foreach ($this->tools_to_restore as $tool) { - $function_build = 'restore_'.$tool; - $this->$function_build( - $session_id, - $respect_base_content, - $destination_course_code - ); + $fn = 'restore_'.$tool; + if (method_exists($this, $fn)) { + $this->dlog('Starting tool restore', ['tool' => $tool]); + try { + $this->{$fn}($session_id, $respect_base_content, $destination_course_code); + } catch (\Throwable $e) { + $this->dlog('Tool restore failed with exception', [ + 'tool' => $tool, + 'error' => $e->getMessage(), + ]); + $this->resetDoctrineIfClosed(); + } + $this->dlog('Finished tool restore', ['tool' => $tool]); + } else { + $this->dlog('Restore method not found for tool (skipping)', ['tool' => $tool]); + } } + // Optionally restore safe course settings if ($update_course_settings) { + $this->dlog('Restoring course settings'); $this->restore_course_settings($destination_course_code); } - // Restore the item properties - $table = Database::get_course_table(TABLE_ITEM_PROPERTY); - foreach ($this->course->resources as $type => $resources) { - if (is_array($resources)) { - foreach ($resources as $id => $resource) { - if (isset($resource->item_properties)) { - foreach ($resource->item_properties as $property) { - // First check if there isn't already a record for this resource - $sql = "SELECT * FROM $table - WHERE - c_id = ".$this->destination_course_id." AND - tool = '".$property['tool']."' AND - ref = '".$resource->destination_id."'"; - - $params = []; - if (!empty($session_id)) { - $params['session_id'] = (int) $session_id; - } + $this->dlog('Restore() finished', [ + 'destination_course_id' => $this->destination_course_id, + ]); - $res = Database::query($sql); - if (0 == Database::num_rows($res)) { - /* The to_group_id and to_user_id are set to default - values as users/groups possibly not exist in - the target course*/ - - $params['c_id'] = $this->destination_course_id; - $params['tool'] = self::DBUTF8($property['tool']); - $params['insert_user_id'] = $this->checkUserId($property['insert_user_id']) ?: null; - $params['insert_date'] = self::DBUTF8($property['insert_date']); - $params['lastedit_date'] = self::DBUTF8($property['lastedit_date']); - $params['ref'] = $resource->destination_id; - $params['lastedit_type'] = self::DBUTF8($property['lastedit_type']); - $params['lastedit_user_id'] = $this->checkUserId($property['lastedit_user_id']); - $params['visibility'] = self::DBUTF8($property['visibility']); - $params['start_visible'] = self::DBUTF8($property['start_visible']); - $params['end_visible'] = self::DBUTF8($property['end_visible']); - $params['to_user_id'] = $this->checkUserId($property['to_user_id']) ?: null; - - $id = Database::insert($table, $params); - if ($id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $id"; - Database::query($sql); - } - } - } - } + return null; + } + + /** + * Restore only harmless course settings (Chamilo 2 entity-safe). + */ + public function restore_course_settings(string $destination_course_code = ''): void + { + $this->dlog('restore_course_settings() called'); + + $courseEntity = null; + + if ($destination_course_code !== '') { + $courseEntity = Container::getCourseRepository()->findOneByCode($destination_course_code); + } else { + if (!empty($this->destination_course_id)) { + $courseEntity = api_get_course_entity((int) $this->destination_course_id); + } else { + $info = api_get_course_info(); + if (!empty($info['real_id'])) { + $courseEntity = api_get_course_entity((int) $info['real_id']); } } } + + if (!$courseEntity) { + $this->dlog('No destination course entity found, skipping settings restore'); + return; + } + + $src = $this->course->info ?? []; + + if (!empty($src['language'])) { + $courseEntity->setCourseLanguage((string) $src['language']); + } + if (isset($src['visibility']) && $src['visibility'] !== '') { + $courseEntity->setVisibility((int) $src['visibility']); + } + if (array_key_exists('department_name', $src)) { + $courseEntity->setDepartmentName((string) $src['department_name']); + } + if (array_key_exists('department_url', $src)) { + $courseEntity->setDepartmentUrl((string) $src['department_url']); + } + if (!empty($src['category_id'])) { + $catRepo = Container::getCourseCategoryRepository(); + $cat = $catRepo?->find((int) $src['category_id']); + if ($cat) { + $courseEntity->setCategories(new ArrayCollection([$cat])); + } + } + if (array_key_exists('subscribe_allowed', $src)) { + $courseEntity->setSubscribe((bool) $src['subscribe_allowed']); + } + if (array_key_exists('unsubscribe', $src)) { + $courseEntity->setUnsubscribe((bool) $src['unsubscribe']); + } + + $em = Database::getManager(); + $em->persist($courseEntity); + $em->flush(); + + $this->dlog('Course settings restored'); } - /** - * Restore only harmless course settings: - * course_language, visibility, department_name,department_url, - * subscribe, unsubscribe, category_id. - * - * @param string $destination_course_code - */ - public function restore_course_settings($destination_course_code) + private function projectUploadBase(): string + { + /** @var KernelInterface $kernel */ + $kernel = Container::$container->get('kernel'); + return rtrim($kernel->getProjectDir(), '/').'/var/upload/resource'; + } + + private function resourceFileAbsPathFromDocument(CDocument $doc): ?string { - $origin_course_info = api_get_course_info($destination_course_code); - $course_info = $this->course->info; - $params['course_language'] = $course_info['language']; - $params['visibility'] = $course_info['visibility']; - $params['department_name'] = $course_info['department_name']; - $params['department_url'] = $course_info['department_url']; - $params['category_id'] = $course_info['category_id']; - $params['subscribe'] = $course_info['subscribe_allowed']; - $params['unsubscribe'] = $course_info['unsubscribe']; - CourseManager::update_attributes($origin_course_info['real_id'], $params); + $node = $doc->getResourceNode(); + if (!$node) return null; + + $file = $node->getFirstResourceFile(); + if (!$file) return null; + + /** @var ResourceNodeRepository $rnRepo */ + $rnRepo = Container::$container->get(ResourceNodeRepository::class); + $rel = $rnRepo->getFilename($file); + if (!$rel) return null; + + $abs = $this->projectUploadBase().$rel; + return is_readable($abs) ? $abs : null; } /** * Restore documents. - * - * @param int $session_id - * @param bool $respect_base_content - * @param string $destination_course_code */ - public function restore_documents( - $session_id = 0, - $respect_base_content = false, - $destination_course_code = '' - ) { - $course_info = api_get_course_info($destination_course_code); - + public function restore_documents($session_id = 0, $respect_base_content = false, $destination_course_code = '') + { if (!$this->course->has_resources(RESOURCE_DOCUMENT)) { + $this->dlog('restore_documents: no document resources'); return; } - $webEditorCss = api_get_path(WEB_CSS_PATH).'editor.css'; - $table = Database::get_course_table(TABLE_DOCUMENT); - $resources = $this->course->resources; - $path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/'; - $originalFolderNameList = []; - foreach ($resources[RESOURCE_DOCUMENT] as $id => $document) { - $my_session_id = empty($document->item_properties[0]['session_id']) ? 0 : $session_id; - //$path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/'; - if (false === $respect_base_content && $session_id) { - if (empty($my_session_id)) { - $my_session_id = $session_id; - } - } + $courseInfo = $this->destination_course_info; + $docRepo = Container::getDocumentRepository(); + $courseEntity = api_get_course_entity($courseInfo['real_id']); + $session = api_get_session_entity((int)$session_id); + $group = api_get_group_entity(0); - if (FOLDER == $document->file_type) { - $visibility = isset($document->item_properties[0]['visibility']) ? $document->item_properties[0]['visibility'] : ''; - $new = substr($document->path, 8); + $copyMode = empty($this->course->backup_path); + $srcRoot = $copyMode ? null : rtrim((string)$this->course->backup_path, '/').'/'; - $folderList = explode('/', $new); - $tempFolder = ''; + $this->dlog('restore_documents: begin', [ + 'files' => count($this->course->resources[RESOURCE_DOCUMENT] ?? []), + 'session' => (int) $session_id, + 'mode' => $copyMode ? 'copy' : 'import', + 'srcRoot' => $srcRoot, + ]); - // Check if the parent path exists. - foreach ($folderList as $folder) { - $folderToCreate = $tempFolder.$folder; - //$sysFolderPath = $path.'document'.$folderToCreate; - $sysFolderPath = null; - $tempFolder .= $folder.'/'; + // 1) folders + $folders = []; + foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) { + if ($item->file_type !== FOLDER) { continue; } - if (empty($folderToCreate)) { - continue; - } + $rel = '/'.ltrim(substr($item->path, 8), '/'); + if ($rel === '/') { continue; } - $title = $document->title; - $originalFolderNameList[basename($document->path)] = $document->title; - if (empty($title)) { - $title = basename($sysFolderPath); - } + $parts = array_values(array_filter(explode('/', $rel))); + $accum = ''; + $parentId = 0; - // File doesn't exist in file system. - if (!is_dir($sysFolderPath)) { - // Creating directory - create_unexisting_directory( - $course_info, - api_get_user_id(), - $my_session_id, - 0, - 0, - $path.'document', - $folderToCreate, - $title, - $visibility - ); + foreach ($parts as $i => $seg) { + $accum .= '/'.$seg; + if (isset($folders[$accum])) { $parentId = $folders[$accum]; continue; } - continue; - } + $parentResource = $parentId ? $docRepo->find($parentId) : $courseEntity; + $title = ($i === count($parts)-1) ? ($item->title ?: $seg) : $seg; + + $existing = $docRepo->findCourseResourceByTitle( + $title, $parentResource->getResourceNode(), $courseEntity, $session, $group + ); - // File exist in file system. - $documentData = DocumentManager::get_document_id( - $course_info, - $folderToCreate, - $session_id + if ($existing) { + $iid = method_exists($existing,'getIid') ? $existing->getIid() : 0; + $this->dlog('restore_documents: reuse folder', ['title' => $title, 'iid' => $iid]); + } else { + $entity = DocumentManager::addDocument( + ['real_id'=>$courseInfo['real_id'],'code'=>$courseInfo['code']], + $accum, 'folder', 0, $title, null, 0, null, 0, (int)$session_id, 0, false, '', $parentId, '' ); + $iid = method_exists($entity,'getIid') ? $entity->getIid() : 0; + $this->dlog('restore_documents: created folder', ['title' => $title, 'iid' => $iid]); + } - if (empty($documentData)) { - /* This means the folder exists in the - filesystem but not in the DB, trying to fix it */ - DocumentManager::addDocument( - $course_info, - $folderToCreate, - 'folder', - 0, - $title, - null, - null, - false, - null, - $session_id, - 0, - false - ); - } else { - $insertUserId = isset($document->item_properties[0]['insert_user_id']) ? $document->item_properties[0]['insert_user_id'] : api_get_user_id(); - $insertUserId = $this->checkUserId($insertUserId); - - // Check if user exists in platform - $toUserId = isset($document->item_properties[0]['to_user_id']) ? $document->item_properties[0]['to_user_id'] : null; - $toUserId = $this->checkUserId($toUserId, true); - - $groupId = isset($document->item_properties[0]['to_group_id']) ? $document->item_properties[0]['to_group_id'] : null; - $groupInfo = $this->checkGroupId($groupId); - - // if folder exists then just refresh it - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $documentData, - 'FolderUpdated', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ - } + $folders[$accum] = $iid; + if ($i === count($parts)-1) { + $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid; } - } elseif (DOCUMENT == $document->file_type) { - // Checking if folder exists in the database otherwise we created it - $dir_to_create = dirname($document->path); - $originalFolderNameList[basename($document->path)] = $document->title; - if (!empty($dir_to_create) && 'document' != $dir_to_create && '/' != $dir_to_create) { - if (is_dir($path.dirname($document->path))) { - $sql = "SELECT id FROM $table - WHERE - c_id = ".$this->destination_course_id." AND - path = '/".self::DBUTF8escapestring(substr(dirname($document->path), 9))."'"; - $res = Database::query($sql); - - if (0 == Database::num_rows($res)) { - //continue; - $visibility = $document->item_properties[0]['visibility']; - $new = '/'.substr(dirname($document->path), 9); - $title = $document->title; - if (empty($title)) { - $title = str_replace('/', '', $new); - } + $parentId = $iid; + } + } - // This code fixes the possibility for a file without a directory entry to be - $document_id = DocumentManager::addDocument( - $course_info, - $new, - 'folder', - 0, - $title, - null, - null, - false, - 0, - 0, - 0, - false - ); + // 2) files + foreach ($this->course->resources[RESOURCE_DOCUMENT] as $k => $item) { + if ($item->file_type !== DOCUMENT) { continue; } + + $srcPath = null; + $rawTitle = $item->title ?: basename((string)$item->path); + $ext = strtolower(pathinfo($rawTitle, PATHINFO_EXTENSION)); + $isHtml = in_array($ext, ['html','htm'], true); + + if ($copyMode) { + $srcDoc = null; + if (!empty($item->source_id)) { + $srcDoc = $docRepo->find((int)$item->source_id); + } + if (!$srcDoc) { + $this->dlog('restore_documents: source CDocument not found by source_id', ['source_id' => $item->source_id ?? null]); + continue; + } + $srcPath = $this->resourceFileAbsPathFromDocument($srcDoc); + if (!$srcPath) { + $this->dlog('restore_documents: source file not readable from ResourceFile', ['source_id' => (int)$item->source_id]); + continue; + } + } else { + $srcPath = $srcRoot.$item->path; + if (!is_file($srcPath) || !is_readable($srcPath)) { + $this->dlog('restore_documents: source file not found/readable', ['src' => $srcPath]); + continue; + } + } + + $rel = '/'.ltrim(substr($item->path, 8), '/'); + $parentRel = rtrim(dirname($rel), '/'); + $parentId = $folders[$parentRel] ?? 0; + $parentRes = $parentId ? $docRepo->find($parentId) : $courseEntity; + + $baseTitle = $rawTitle; + $finalTitle = $baseTitle; + + $findExisting = function($t) use ($docRepo,$parentRes,$courseEntity,$session,$group){ + $e = $docRepo->findCourseResourceByTitle($t, $parentRes->getResourceNode(), $courseEntity, $session, $group); + return $e && method_exists($e,'getIid') ? $e->getIid() : null; + }; + + $existsIid = $findExisting($finalTitle); + if ($existsIid) { + $this->dlog('restore_documents: collision', ['title' => $finalTitle, 'policy' => $this->file_option]); + if ($this->file_option === FILE_SKIP) { + $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $existsIid; + continue; + } + $pi = pathinfo($baseTitle); + $name = $pi['filename'] ?? $baseTitle; + $ext2 = isset($pi['extension']) && $pi['extension'] !== '' ? '.'.$pi['extension'] : ''; + $i=1; + while ($findExisting($finalTitle)) { $finalTitle = $name.'_'.$i.$ext2; $i++; } + } + + $content = ''; + $realPath = ''; + if ($isHtml) { + $raw = @file_get_contents($srcPath) ?: ''; + if (defined('UTF8_CONVERT') && UTF8_CONVERT) { $raw = utf8_encode($raw); } + $content = DocumentManager::replaceUrlWithNewCourseCode( + $raw, + $this->course->code, + $this->course->destination_path, + $this->course->backup_path, + $this->course->info['path'] + ); + } else { + $realPath = $srcPath; + } + + try { + $entity = DocumentManager::addDocument( + ['real_id'=>$courseInfo['real_id'],'code'=>$courseInfo['code']], + $rel, + 'file', + (int)($item->size ?? 0), + $finalTitle, + $item->comment ?? '', + 0, + null, + 0, + (int)$session_id, + 0, + false, + $content, + $parentId, + $realPath + ); + $iid = method_exists($entity,'getIid') ? $entity->getIid() : 0; + $this->course->resources[RESOURCE_DOCUMENT][$k]->destination_id = $iid; + $this->dlog('restore_documents: file created', [ + 'title' => $finalTitle, + 'iid' => $iid, + 'mode' => $copyMode ? 'copy' : 'import' + ]); + } catch (\Throwable $e) { + $this->dlog('restore_documents: file create failed', ['title' => $finalTitle, 'error' => $e->getMessage()]); + } + } - $itemProperty = isset($document->item_properties[0]) ? $document->item_properties[0] : ''; - $insertUserId = isset($itemProperty['insert_user_id']) ? $itemProperty['insert_user_id'] : api_get_user_id(); - $toGroupId = isset($itemProperty['to_group_id']) ? $itemProperty['to_group_id'] : 0; - $toUserId = isset($itemProperty['to_user_id']) ? $itemProperty['to_user_id'] : null; - $groupInfo = $this->checkGroupId($toGroupId); - $insertUserId = $this->checkUserId($insertUserId); - $toUserId = $this->checkUserId($toUserId, true); - - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $document_id, - 'FolderCreated', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ + $this->dlog('restore_documents: end'); + } + + /** + * Compact dump of resources: keys, per-bag counts and one sample (trimmed). + */ + private function debug_course_resources_simple(?string $focusBag = null, int $maxObjFields = 10): void + { + try { + $resources = is_array($this->course->resources ?? null) ? $this->course->resources : []; + + $safe = function ($data): string { + try { + return json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[json_encode_failed]'; + } catch (\Throwable $e) { + return '[json_exception: '.$e->getMessage().']'; + } + }; + $short = function ($v, int $max = 200) { + if (is_string($v)) { + $s = trim($v); + return mb_strlen($s) > $max ? (mb_substr($s, 0, $max).'…('.mb_strlen($s).' chars)') : $s; + } + if (is_numeric($v) || is_bool($v) || $v === null) return $v; + return '['.gettype($v).']'; + }; + $sample = function ($item) use ($short, $maxObjFields) { + $out = [ + 'source_id' => null, + 'destination_id' => null, + 'type' => null, + 'has_obj' => false, + 'obj_fields' => [], + 'has_item_props' => false, + 'extra' => [], + ]; + if (is_object($item) || is_array($item)) { + $arr = (array)$item; + $out['source_id'] = $arr['source_id'] ?? null; + $out['destination_id'] = $arr['destination_id'] ?? null; + $out['type'] = $arr['type'] ?? null; + $out['has_item_props'] = !empty($arr['item_properties']); + + $obj = $arr['obj'] ?? null; + if (is_object($obj) || is_array($obj)) { + $out['has_obj'] = true; + $objArr = (array)$obj; + $fields = []; + $i = 0; + foreach ($objArr as $k => $v) { + if ($i++ >= $maxObjFields) { $fields['__notice'] = 'truncated'; break; } + $fields[$k] = $short($v); } + $out['obj_fields'] = $fields; } + foreach (['path','title','comment'] as $k) { + if (isset($arr[$k])) $out['extra'][$k] = $short($arr[$k]); + } + } else { + $out['extra']['_type'] = gettype($item); } + return $out; + }; - if (file_exists($path.$document->path)) { - switch ($this->file_option) { - case FILE_OVERWRITE: - $origin_path = $this->course->backup_path.'/'.$document->path; - if (file_exists($origin_path)) { - copy($origin_path, $path.$document->path); - $this->fixEditorHtmlContent($path.$document->path, $webEditorCss); - $sql = "SELECT id FROM $table - WHERE - c_id = ".$this->destination_course_id." AND - path = '/".self::DBUTF8escapestring(substr($document->path, 9))."'"; - - $res = Database::query($sql); - $count = Database::num_rows($res); - - if (0 == $count) { - $params = [ - 'path' => '/'.self::DBUTF8(substr($document->path, 9)), - 'c_id' => $this->destination_course_id, - 'comment' => self::DBUTF8($document->comment), - 'title' => self::DBUTF8($document->title), - 'filetype' => self::DBUTF8($document->file_type), - 'size' => self::DBUTF8($document->size), - 'session_id' => $my_session_id, - 'readonly' => 0, - ]; - - $document_id = Database::insert($table, $params); - - if ($document_id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $document_id"; - Database::query($sql); - } - $this->course->resources[RESOURCE_DOCUMENT][$id]->destination_id = $document_id; - - $itemProperty = isset($document->item_properties[0]) ? $document->item_properties[0] : ''; - $insertUserId = isset($itemProperty['insert_user_id']) ? $itemProperty['insert_user_id'] : api_get_user_id(); - $toGroupId = isset($itemProperty['to_group_id']) ? $itemProperty['to_group_id'] : 0; - $toUserId = isset($itemProperty['to_user_id']) ? $itemProperty['to_user_id'] : null; - - $insertUserId = $this->checkUserId($insertUserId); - $toUserId = $this->checkUserId($toUserId, true); - $groupInfo = $this->checkGroupId($toGroupId); - - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $document_id, - 'DocumentAdded', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ - } else { - $obj = Database::fetch_object($res); - $document_id = $obj->id; - $params = [ - 'path' => '/'.self::DBUTF8(substr($document->path, 9)), - 'c_id' => $this->destination_course_id, - 'comment' => self::DBUTF8($document->comment), - 'title' => self::DBUTF8($document->title), - 'filetype' => self::DBUTF8($document->file_type), - 'size' => self::DBUTF8($document->size), - 'session_id' => $my_session_id, - ]; - - Database::update( - $table, - $params, - [ - 'c_id = ? AND path = ?' => [ - $this->destination_course_id, - '/'.self::DBUTF8escapestring(substr($document->path, 9)), - ], - ] - ); + $this->dlog('Resources overview', ['keys' => array_keys($resources)]); - $this->course->resources[RESOURCE_DOCUMENT][$id]->destination_id = $obj->id; - - $itemProperty = isset($document->item_properties[0]) ? $document->item_properties[0] : ''; - $insertUserId = isset($itemProperty['insert_user_id']) ? $itemProperty['insert_user_id'] : api_get_user_id(); - $toGroupId = isset($itemProperty['to_group_id']) ? $itemProperty['to_group_id'] : 0; - $toUserId = isset($itemProperty['to_user_id']) ? $itemProperty['to_user_id'] : null; - - $insertUserId = $this->checkUserId($insertUserId); - $toUserId = $this->checkUserId($toUserId, true); - $groupInfo = $this->checkGroupId($toGroupId); - - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $obj->id, - 'default', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ - } + foreach ($resources as $bagName => $bag) { + if (!is_array($bag)) { + $this->dlog("Bag not an array, skipping", ['bag' => $bagName, 'type' => gettype($bag)]); + continue; + } + $count = count($bag); + $this->dlog('Bag count', ['bag' => $bagName, 'count' => $count]); + + if ($count > 0) { + $firstKey = array_key_first($bag); + $firstVal = $bag[$firstKey]; + $s = $sample($firstVal); + $s['__first_key'] = $firstKey; + $s['__class'] = is_object($firstVal) ? get_class($firstVal) : gettype($firstVal); + $this->dlog('Bag sample', ['bag' => $bagName, 'sample' => $s]); + } - // Replace old course code with the new destination code - $file_info = pathinfo($path.$document->path); + if ($focusBag !== null && $focusBag === $bagName) { + $preview = []; + $i = 0; + foreach ($bag as $k => $v) { + if ($i++ >= 10) { $preview[] = ['__notice' => 'truncated-after-10-items']; break; } + $preview[] = ['key' => $k, 'sample' => $sample($v)]; + } + $this->dlog('Bag deep preview', ['bag' => $bagName, 'items' => $preview]); + } + } + } catch (\Throwable $e) { + $this->dlog('Failed to dump resources', ['error' => $e->getMessage()]); + } + } - if (isset($file_info['extension']) && in_array($file_info['extension'], ['html', 'htm'])) { - $content = file_get_contents($path.$document->path); - if (UTF8_CONVERT) { - $content = utf8_encode($content); - } - $content = DocumentManager::replaceUrlWithNewCourseCode( - $content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - file_put_contents($path.$document->path, $content); - } + public function restore_forum_category($session_id = 0, $respect_base_content = false, $destination_course_code = ''): void + { + $bag = $this->course->resources['Forum_Category'] + ?? $this->course->resources['forum_category'] + ?? []; - $params = [ - 'comment' => self::DBUTF8($document->comment), - 'title' => self::DBUTF8($document->title), - 'size' => self::DBUTF8($document->size), - ]; - Database::update( - $table, - $params, - [ - 'c_id = ? AND id = ?' => [ - $this->destination_course_id, - $document_id, - ], - ] - ); - } + if (empty($bag)) { + $this->dlog('restore_forum_category: empty bag'); + return; + } - break; - case FILE_SKIP: - $sql = "SELECT id FROM $table - WHERE - c_id = ".$this->destination_course_id." AND - path='/".self::DBUTF8escapestring(substr($document->path, 9))."'"; - $res = Database::query($sql); - $obj = Database::fetch_object($res); - $this->course->resources[RESOURCE_DOCUMENT][$id]->destination_id = $obj->id; + $em = Database::getManager(); + $catRepo = Container::getForumCategoryRepository(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int)$session_id); - break; - case FILE_RENAME: - $i = 1; - $ext = explode('.', basename($document->path)); - if (count($ext) > 1) { - $ext = array_pop($ext); - $file_name_no_ext = substr($document->path, 0, -(strlen($ext) + 1)); - $ext = '.'.$ext; - } else { - $ext = ''; - $file_name_no_ext = $document->path; - } - $new_file_name = $file_name_no_ext.'_'.$i.$ext; - $file_exists = file_exists($path.$new_file_name); - while ($file_exists) { - $i++; - $new_file_name = $file_name_no_ext.'_'.$i.$ext; - $file_exists = file_exists($path.$new_file_name); - } + foreach ($bag as $id => $res) { + if (!empty($res->destination_id)) { continue; } - if (!empty($session_id)) { - $originalPath = $document->path; - $document_path = explode('/', $document->path, 3); - $course_path = $path; - $orig_base_folder = $document_path[1]; - $orig_base_path = $course_path.$document_path[0].'/'.$document_path[1]; - - if (is_dir($orig_base_path)) { - $new_base_foldername = $orig_base_folder; - $new_base_path = $orig_base_path; - - if (isset($_SESSION['orig_base_foldername']) && - $_SESSION['orig_base_foldername'] != $new_base_foldername - ) { - unset($_SESSION['new_base_foldername']); - unset($_SESSION['orig_base_foldername']); - unset($_SESSION['new_base_path']); - } + $obj = is_object($res->obj ?? null) ? $res->obj : (object)[]; + $title = (string)($obj->cat_title ?? $obj->title ?? "Forum category #$id"); + $comment = (string)($obj->cat_comment ?? $obj->description ?? ''); - $folder_exists = file_exists($new_base_path); - if ($folder_exists) { - // e.g: carpeta1 in session - $_SESSION['orig_base_foldername'] = $new_base_foldername; - $x = 0; - while ($folder_exists) { - $x++; - $new_base_foldername = $document_path[1].'_'.$x; - $new_base_path = $orig_base_path.'_'.$x; - if (isset($_SESSION['new_base_foldername']) - && $_SESSION['new_base_foldername'] == $new_base_foldername - ) { - break; - } - $folder_exists = file_exists($new_base_path); - } - $_SESSION['new_base_foldername'] = $new_base_foldername; - $_SESSION['new_base_path'] = $new_base_path; - } + $existing = $catRepo->findOneBy(['title' => $title, 'resourceNode.parent' => $course->getResourceNode()]); + if ($existing) { + $destIid = (int)$existing->getIid(); + if (!isset($this->course->resources['Forum_Category'])) { + $this->course->resources['Forum_Category'] = []; + } + $this->course->resources['Forum_Category'][$id]->destination_id = $destIid; + $this->dlog('restore_forum_category: reuse existing', ['title' => $title, 'iid' => $destIid]); + continue; + } - if (isset($_SESSION['new_base_foldername']) && isset($_SESSION['new_base_path'])) { - $new_base_foldername = $_SESSION['new_base_foldername']; - $new_base_path = $_SESSION['new_base_path']; - } + $cat = (new CForumCategory()) + ->setTitle($title) + ->setCatComment($comment) + ->setParent($course) + ->addCourseLink($course, $session); - $dest_document_path = $new_base_path.'/'.$document_path[2]; // e.g: "/var/www/wiener/courses/CURSO4/document/carpeta1_1/subcarpeta1/collaborative.png" - $basedir_dest_path = dirname($dest_document_path); // e.g: "/var/www/wiener/courses/CURSO4/document/carpeta1_1/subcarpeta1" - $base_path_document = $course_path.$document_path[0]; // e.g: "/var/www/wiener/courses/CURSO4/document" - $path_title = '/'.$new_base_foldername.'/'.$document_path[2]; - - copy_folder_course_session( - $basedir_dest_path, - $base_path_document, - $session_id, - $course_info, - $document, - $this->course_origin_id, - $originalFolderNameList, - $originalPath - ); + $catRepo->create($cat); + $em->flush(); - if (file_exists($course_path.$document->path)) { - copy($course_path.$document->path, $dest_document_path); - } + $this->course->resources['Forum_Category'][$id]->destination_id = (int)$cat->getIid(); + $this->dlog('restore_forum_category: created', ['title' => $title, 'iid' => (int)$cat->getIid()]); + } - // Replace old course code with the new destination code see BT#1985 - if (file_exists($dest_document_path)) { - $file_info = pathinfo($dest_document_path); - if (in_array($file_info['extension'], ['html', 'htm'])) { - $content = file_get_contents($dest_document_path); - if (UTF8_CONVERT) { - $content = utf8_encode($content); - } - $content = DocumentManager::replaceUrlWithNewCourseCode( - $content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - file_put_contents($dest_document_path, $content); - $this->fixEditorHtmlContent($dest_document_path, $webEditorCss); - } - } + $this->dlog('restore_forum_category: done', ['count' => count($bag)]); + } - $title = basename($path_title); - if (isset($originalFolderNameList[basename($path_title)])) { - $title = $originalFolderNameList[basename($path_title)]; - } + public function restore_forums(int $sessionId = 0): void + { + $forumsBag = $this->course->resources['forum'] ?? []; + if (empty($forumsBag)) { + $this->dlog('restore_forums: empty forums bag'); + return; + } - $params = [ - 'path' => self::DBUTF8($path_title), - 'c_id' => $this->destination_course_id, - 'comment' => self::DBUTF8($document->comment), - 'title' => self::DBUTF8($title), - 'filetype' => self::DBUTF8($document->file_type), - 'size' => self::DBUTF8($document->size), - 'session_id' => $my_session_id, - ]; - - $document_id = Database::insert($table, $params); - - if ($document_id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $document_id"; - Database::query($sql); - - $this->course->resources[RESOURCE_DOCUMENT][$id]->destination_id = $document_id; - - $itemProperty = isset($document->item_properties[0]) ? $document->item_properties[0] : ''; - $insertUserId = isset($itemProperty['insert_user_id']) ? $itemProperty['insert_user_id'] : api_get_user_id(); - $toGroupId = isset($itemProperty['to_group_id']) ? $itemProperty['to_group_id'] : 0; - $toUserId = isset($itemProperty['to_user_id']) ? $itemProperty['to_user_id'] : null; - - $insertUserId = $this->checkUserId($insertUserId); - $toUserId = $this->checkUserId($toUserId, true); - $groupInfo = $this->checkGroupId($toGroupId); - - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $document_id, - 'DocumentAdded', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ - } - } else { - if (file_exists($path.$document->path)) { - copy($path.$document->path, $path.$new_file_name); - } - // Replace old course code with the new destination code see BT#1985 - if (file_exists($path.$new_file_name)) { - $file_info = pathinfo($path.$new_file_name); - if (in_array($file_info['extension'], ['html', 'htm'])) { - $content = file_get_contents($path.$new_file_name); - if (UTF8_CONVERT) { - $content = utf8_encode($content); - } - $content = DocumentManager::replaceUrlWithNewCourseCode( - $content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - file_put_contents($path.$new_file_name, $content); - $this->fixEditorHtmlContent($path.$new_file_name, $webEditorCss); - } - } + $em = Database::getManager(); + $catRepo = Container::getForumCategoryRepository(); + $forumRepo = Container::getForumRepository(); - $params = [ - 'path' => '/'.self::DBUTF8escapestring(substr($new_file_name, 9)), - 'c_id' => $this->destination_course_id, - 'comment' => self::DBUTF8($document->comment), - 'title' => self::DBUTF8($document->title), - 'filetype' => self::DBUTF8($document->file_type), - 'size' => self::DBUTF8($document->size), - 'session_id' => $my_session_id, - ]; - - $document_id = Database::insert($table, $params); - - if ($document_id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $document_id"; - Database::query($sql); - - $this->course->resources[RESOURCE_DOCUMENT][$id]->destination_id = $document_id; - - $itemProperty = isset($document->item_properties[0]) ? $document->item_properties[0] : ''; - $insertUserId = isset($itemProperty['insert_user_id']) ? $itemProperty['insert_user_id'] : api_get_user_id(); - $toGroupId = isset($itemProperty['to_group_id']) ? $itemProperty['to_group_id'] : 0; - $toUserId = isset($itemProperty['to_user_id']) ? $itemProperty['to_user_id'] : null; - - $insertUserId = $this->checkUserId($insertUserId); - $toUserId = $this->checkUserId($toUserId, true); - $groupInfo = $this->checkGroupId($toGroupId); - - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $document_id, - 'DocumentAdded', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ - } - } - } else { - copy( - $this->course->backup_path.'/'.$document->path, - $path.$new_file_name - ); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity($sessionId); - // Replace old course code with the new destination code see BT#1985 - if (file_exists($path.$new_file_name)) { - $file_info = pathinfo($path.$new_file_name); - if (in_array($file_info['extension'], ['html', 'htm'])) { - $content = file_get_contents($path.$new_file_name); - if (UTF8_CONVERT) { - $content = utf8_encode($content); - } - $content = DocumentManager::replaceUrlWithNewCourseCode( - $content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - file_put_contents($path.$new_file_name, $content); - $this->fixEditorHtmlContent($path.$new_file_name, $webEditorCss); - } - } + // Build/ensure categories + $catBag = $this->course->resources['Forum_Category'] ?? $this->course->resources['forum_category'] ?? []; + $catMap = []; - $params = [ - 'c_id' => $this->destination_course_id, - 'path' => '/'.self::DBUTF8escapestring(substr($new_file_name, 9)), - 'comment' => self::DBUTF8($document->comment), - 'title' => self::DBUTF8($document->title), - 'filetype' => self::DBUTF8($document->file_type), - 'size' => self::DBUTF8($document->size), - 'session_id' => $my_session_id, - ]; - - $document_id = Database::insert($table, $params); - - if ($document_id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $document_id"; - Database::query($sql); - $this->course->resources[RESOURCE_DOCUMENT][$id]->destination_id = $document_id; - - $itemProperty = isset($document->item_properties[0]) ? $document->item_properties[0] : ''; - $insertUserId = isset($itemProperty['insert_user_id']) ? $itemProperty['insert_user_id'] : api_get_user_id(); - $toGroupId = isset($itemProperty['to_group_id']) ? $itemProperty['to_group_id'] : 0; - $toUserId = isset($itemProperty['to_user_id']) ? $itemProperty['to_user_id'] : null; - - $insertUserId = $this->checkUserId($insertUserId); - $toUserId = $this->checkUserId($toUserId, true); - $groupInfo = $this->checkGroupId($toGroupId); - - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $document_id, - 'DocumentAdded', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ - } - } + if (!empty($catBag)) { + foreach ($catBag as $srcCatId => $res) { + if (!empty($res->destination_id)) { + $catMap[(int)$srcCatId] = (int)$res->destination_id; + continue; + } - break; - } // end switch - } else { - // end if file exists - //make sure the source file actually exists - if (is_file($this->course->backup_path.'/'.$document->path) && - is_readable($this->course->backup_path.'/'.$document->path) && - is_dir(dirname($path.$document->path)) && - is_writable(dirname($path.$document->path)) - ) { - copy( - $this->course->backup_path.'/'.$document->path, - $path.$document->path - ); + $obj = is_object($res->obj ?? null) ? $res->obj : (object)[]; + $title = (string)($obj->cat_title ?? $obj->title ?? "Forum category #$srcCatId"); + $comment = (string)($obj->cat_comment ?? $obj->description ?? ''); - // Replace old course code with the new destination code see BT#1985 - if (file_exists($path.$document->path)) { - $file_info = pathinfo($path.$document->path); - if (isset($file_info['extension']) && in_array($file_info['extension'], ['html', 'htm'])) { - $content = file_get_contents($path.$document->path); - if (UTF8_CONVERT) { - $content = utf8_encode($content); - } - $content = DocumentManager::replaceUrlWithNewCourseCode( - $content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - file_put_contents($path.$document->path, $content); - $this->fixEditorHtmlContent($path.$document->path, $webEditorCss); - } - } + $cat = (new CForumCategory()) + ->setTitle($title) + ->setCatComment($comment) + ->setParent($course) + ->addCourseLink($course, $session); - $params = [ - 'c_id' => $this->destination_course_id, - 'path' => '/'.self::DBUTF8(substr($document->path, 9)), - 'comment' => self::DBUTF8($document->comment), - 'title' => self::DBUTF8($document->title), - 'filetype' => self::DBUTF8($document->file_type), - 'size' => self::DBUTF8($document->size), - 'session_id' => $my_session_id, - 'readonly' => 0, - ]; + $catRepo->create($cat); + $em->flush(); - $document_id = Database::insert($table, $params); - - if ($document_id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $document_id"; - Database::query($sql); - - $this->course->resources[RESOURCE_DOCUMENT][$id]->destination_id = $document_id; - - $itemProperty = isset($document->item_properties[0]) ? $document->item_properties[0] : ''; - $insertUserId = isset($itemProperty['insert_user_id']) ? $itemProperty['insert_user_id'] : api_get_user_id(); - $toGroupId = isset($itemProperty['to_group_id']) ? $itemProperty['to_group_id'] : 0; - $toUserId = isset($itemProperty['to_user_id']) ? $itemProperty['to_user_id'] : null; - - $insertUserId = $this->checkUserId($insertUserId); - $toUserId = $this->checkUserId($toUserId, true); - $groupInfo = $this->checkGroupId($toGroupId); - - /*api_item_property_update( - $course_info, - TOOL_DOCUMENT, - $document_id, - 'DocumentAdded', - $insertUserId, - $groupInfo, - $toUserId, - null, - null, - $my_session_id - );*/ - } - } else { - // There was an error in checking existence and - // permissions for files to copy. Try to determine - // the exact issue - // Issue with origin document? - if (!is_file($this->course->backup_path.'/'.$document->path)) { - error_log( - 'Course copy generated an ignorable error while trying to copy '. - $this->course->backup_path.'/'.$document->path.': origin file not found' - ); - } elseif (!is_readable($this->course->backup_path.'/'.$document->path)) { - error_log( - 'Course copy generated an ignorable error while trying to copy '. - $this->course->backup_path.'/'.$document->path.': origin file not readable' - ); - } - // Issue with destination directories? - if (!is_dir(dirname($path.$document->path))) { - error_log( - 'Course copy generated an ignorable error while trying to copy '. - $this->course->backup_path.'/'.$document->path.' to '. - dirname($path.$document->path).': destination directory not found' - ); - } - if (!is_writable(dirname($path.$document->path))) { - error_log( - 'Course copy generated an ignorable error while trying to copy '. - $this->course->backup_path.'/'.$document->path.' to '. - dirname($path.$document->path).': destination directory not writable' - ); - } - } - } // end file doesn't exist - } - - // add image information for area questions - if (preg_match('/^quiz-.*$/', $document->title) && - preg_match('/^document\/images\/.*$/', $document->path) - ) { - $this->course->resources[RESOURCE_DOCUMENT]['image_quiz'][$document->title] = [ - 'path' => $document->path, - 'title' => $document->title, - 'source_id' => $document->source_id, - 'destination_id' => $document->destination_id, - ]; + $destIid = (int)$cat->getIid(); + $catMap[(int)$srcCatId] = $destIid; + + if (!isset($this->course->resources['Forum_Category'])) { + $this->course->resources['Forum_Category'] = []; + } + $this->course->resources['Forum_Category'][$srcCatId]->destination_id = $destIid; + + $this->dlog('restore_forums: created category', ['src_id' => (int)$srcCatId, 'iid' => $destIid, 'title' => $title]); } - } // end for each + } - // Delete sessions for the copy the new folder in session - unset($_SESSION['new_base_foldername']); - unset($_SESSION['orig_base_foldername']); - unset($_SESSION['new_base_path']); - } + // Default category "General" if needed + $defaultCategory = null; + $ensureDefault = function() use (&$defaultCategory, $course, $session, $catRepo, $em): CForumCategory { + if ($defaultCategory instanceof CForumCategory) { + return $defaultCategory; + } + $defaultCategory = (new CForumCategory()) + ->setTitle('General') + ->setCatComment('') + ->setParent($course) + ->addCourseLink($course, $session); + $catRepo->create($defaultCategory); + $em->flush(); + return $defaultCategory; + }; + + // Create forums and their topics + foreach ($forumsBag as $srcForumId => $forumRes) { + if (!is_object($forumRes) || !is_object($forumRes->obj)) { continue; } + $p = (array)$forumRes->obj; + + $dstCategory = null; + $srcCatId = (int)($p['forum_category'] ?? 0); + if ($srcCatId > 0 && isset($catMap[$srcCatId])) { + $dstCategory = $catRepo->find($catMap[$srcCatId]); + } + if (!$dstCategory && count($catMap) === 1) { + $onlyDestIid = (int)reset($catMap); + $dstCategory = $catRepo->find($onlyDestIid); + } + if (!$dstCategory) { + $dstCategory = $ensureDefault(); + } - /** - * Restore scorm documents - * TODO @TODO check that the restore function with renaming doesn't break the scorm structure! - * see #7029. - */ - public function restore_scorm_documents() + $forum = (new CForum()) + ->setTitle($p['forum_title'] ?? ('Forum #'.$srcForumId)) + ->setForumComment((string)($p['forum_comment'] ?? '')) + ->setForumCategory($dstCategory) + ->setAllowAnonymous((int)($p['allow_anonymous'] ?? 0)) + ->setAllowEdit((int)($p['allow_edit'] ?? 0)) + ->setApprovalDirectPost((string)($p['approval_direct_post'] ?? '0')) + ->setAllowAttachments((int)($p['allow_attachments'] ?? 1)) + ->setAllowNewThreads((int)($p['allow_new_threads'] ?? 1)) + ->setDefaultView($p['default_view'] ?? 'flat') + ->setForumOfGroup((string)($p['forum_of_group'] ?? 0)) + ->setForumGroupPublicPrivate($p['forum_group_public_private'] ?? 'public') + ->setModerated((bool)($p['moderated'] ?? false)) + ->setStartTime(!empty($p['start_time']) && $p['start_time'] !== '0000-00-00 00:00:00' + ? api_get_utc_datetime($p['start_time'], true, true) : null) + ->setEndTime(!empty($p['end_time']) && $p['end_time'] !== '0000-00-00 00:00:00' + ? api_get_utc_datetime($p['end_time'], true, true) : null) + ->setParent($dstCategory ?: $course) + ->addCourseLink($course, $session); + + $forumRepo->create($forum); + $em->flush(); + + $this->course->resources['forum'][$srcForumId]->destination_id = (int)$forum->getIid(); + $this->dlog('restore_forums: created forum', [ + 'src_forum_id' => (int)$srcForumId, + 'dst_forum_iid'=> (int)$forum->getIid(), + 'category_iid' => (int)$dstCategory->getIid(), + ]); + + // Topics of this forum + $topicsBag = $this->course->resources['thread'] ?? []; + foreach ($topicsBag as $srcThreadId => $topicRes) { + if (!is_object($topicRes) || !is_object($topicRes->obj)) { continue; } + if ((int)$topicRes->obj->forum_id === (int)$srcForumId) { + $tid = $this->restore_topic((int)$srcThreadId, (int)$forum->getIid(), $sessionId); + $this->dlog('restore_forums: topic restored', [ + 'src_thread_id' => (int)$srcThreadId, + 'dst_thread_iid'=> (int)($tid ?? 0), + 'dst_forum_iid' => (int)$forum->getIid(), + ]); + } + } + } + + $this->dlog('restore_forums: done', ['forums' => count($forumsBag)]); + } + + public function restore_topic(int $srcThreadId, int $dstForumId, int $sessionId = 0): ?int { - /*$perm = api_get_permissions_for_new_directories(); - if ($this->course->has_resources(RESOURCE_SCORM)) { - $resources = $this->course->resources; - foreach ($resources[RESOURCE_SCORM] as $document) { - $path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/'; - @mkdir(dirname($path.$document->path), $perm, true); - if (file_exists($path.$document->path)) { - switch ($this->file_option) { - case FILE_OVERWRITE: - rmdirr($path.$document->path); - copyDirTo( - $this->course->backup_path.'/'.$document->path, - $path.$document->path, - false - ); + $topicsBag = $this->course->resources['thread'] ?? []; + $topicRes = $topicsBag[$srcThreadId] ?? null; + if (!$topicRes || !is_object($topicRes->obj)) { + $this->dlog('restore_topic: missing topic object', ['src_thread_id' => $srcThreadId]); + return null; + } - break; - case FILE_SKIP: - break; - case FILE_RENAME: - $i = 1; - $ext = explode('.', basename($document->path)); - if (count($ext) > 1) { - $ext = array_pop($ext); - $file_name_no_ext = substr($document->path, 0, -(strlen($ext) + 1)); - $ext = '.'.$ext; - } else { - $ext = ''; - $file_name_no_ext = $document->path; - } + $em = Database::getManager(); + $forumRepo = Container::getForumRepository(); + $threadRepo = Container::getForumThreadRepository(); + $postRepo = Container::getForumPostRepository(); - $new_file_name = $file_name_no_ext.'_'.$i.$ext; - $file_exists = file_exists($path.$new_file_name); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int)$sessionId); + $user = api_get_user_entity($this->first_teacher_id); - while ($file_exists) { - $i++; - $new_file_name = $file_name_no_ext.'_'.$i.$ext; - $file_exists = file_exists($path.$new_file_name); - } + /** @var CForum|null $forum */ + $forum = $forumRepo->find($dstForumId); + if (!$forum) { + $this->dlog('restore_topic: destination forum not found', ['dst_forum_id' => $dstForumId]); + return null; + } - rename( - $this->course->backup_path.'/'.$document->path, - $this->course->backup_path.'/'.$new_file_name - ); - copyDirTo( - $this->course->backup_path.'/'.$new_file_name, - $path.dirname($new_file_name), - false - ); - rename( - $this->course->backup_path.'/'.$new_file_name, - $this->course->backup_path.'/'.$document->path - ); + $p = (array)$topicRes->obj; + + $thread = (new CForumThread()) + ->setTitle((string)($p['thread_title'] ?? "Thread #$srcThreadId")) + ->setForum($forum) + ->setUser($user) + ->setThreadDate(new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'))) + ->setThreadSticky((bool)($p['thread_sticky'] ?? false)) + ->setThreadTitleQualify((string)($p['thread_title_qualify'] ?? '')) + ->setThreadQualifyMax((float)($p['thread_qualify_max'] ?? 0)) + ->setThreadWeight((float)($p['thread_weight'] ?? 0)) + ->setThreadPeerQualify((bool)($p['thread_peer_qualify'] ?? false)) + ->setParent($forum) + ->addCourseLink($course, $session); + + $threadRepo->create($thread); + $em->flush(); + + $this->course->resources['thread'][$srcThreadId]->destination_id = (int)$thread->getIid(); + $this->dlog('restore_topic: created', [ + 'src_thread_id' => $srcThreadId, + 'dst_thread_iid'=> (int)$thread->getIid(), + 'dst_forum_iid' => (int)$forum->getIid(), + ]); + + // Posts + $postsBag = $this->course->resources[ 'post'] ?? []; + foreach ($postsBag as $srcPostId => $postRes) { + if (!is_object($postRes) || !is_object($postRes->obj)) { continue; } + if ((int)$postRes->obj->thread_id === (int)$srcThreadId) { + $pid = $this->restore_post((int)$srcPostId, (int)$thread->getIid(), (int)$forum->getIid(), $sessionId); + $this->dlog('restore_topic: post restored', ['src_post_id' => (int)$srcPostId, 'dst_post_iid' => (int)($pid ?? 0)]); + } + } - break; - } // end switch - } else { - // end if file exists - copyDirTo( - $this->course->backup_path.'/'.$document->path, - $path.$document->path, - false - ); - } - } // end for each - }*/ + $last = $postRepo->findOneBy(['thread' => $thread], ['postDate' => 'DESC']); + if ($last) { + $thread->setThreadLastPost($last); + $em->persist($thread); + $em->flush(); + } + + return (int)$thread->getIid(); } - /** - * Restore forums. - * - * @param int $sessionId - */ - public function restore_forums($sessionId = 0) + public function restore_post(int $srcPostId, int $dstThreadId, int $dstForumId, int $sessionId = 0): ?int { - if ($this->course->has_resources(RESOURCE_FORUM)) { - $sessionId = (int) $sessionId; - $table_forum = Database::get_course_table(TABLE_FORUM); - $resources = $this->course->resources; - foreach ($resources[RESOURCE_FORUM] as $id => $forum) { - $params = (array) $forum->obj; - $cat_id = ''; - if (isset($this->course->resources[RESOURCE_FORUMCATEGORY]) && - isset($this->course->resources[RESOURCE_FORUMCATEGORY][$params['forum_category']])) { - if (-1 == $this->course->resources[RESOURCE_FORUMCATEGORY][$params['forum_category']]->destination_id) { - $cat_id = $this->restore_forum_category($params['forum_category'], $sessionId); - } else { - $cat_id = $this->course->resources[RESOURCE_FORUMCATEGORY][$params['forum_category']]->destination_id; - } + $postsBag = $this->course->resources['post'] ?? []; + $postRes = $postsBag[$srcPostId] ?? null; + if (!$postRes || !is_object($postRes->obj)) { + $this->dlog('restore_post: missing post object', ['src_post_id' => $srcPostId]); + return null; + } + + $em = Database::getManager(); + $forumRepo = Container::getForumRepository(); + $threadRepo = Container::getForumThreadRepository(); + $postRepo = Container::getForumPostRepository(); + + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int)$sessionId); + $user = api_get_user_entity($this->first_teacher_id); + + $thread = $threadRepo->find($dstThreadId); + $forum = $forumRepo->find($dstForumId); + if (!$thread || !$forum) { + $this->dlog('restore_post: destination thread/forum not found', [ + 'dst_thread_id' => $dstThreadId, + 'dst_forum_id' => $dstForumId, + ]); + return null; + } + + $p = (array)$postRes->obj; + + $post = (new CForumPost()) + ->setTitle((string)($p['post_title'] ?? "Post #$srcPostId")) + ->setPostText((string)($p['post_text'] ?? '')) + ->setThread($thread) + ->setForum($forum) + ->setUser($user) + ->setPostDate(new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'))) + ->setPostNotification((bool)($p['post_notification'] ?? false)) + ->setVisible(true) + ->setStatus(CForumPost::STATUS_VALIDATED) + ->setParent($thread) + ->addCourseLink($course, $session); + + if (!empty($p['post_parent_id'])) { + $parentDestId = (int)($postsBag[$p['post_parent_id']]->destination_id ?? 0); + if ($parentDestId > 0) { + $parent = $postRepo->find($parentDestId); + if ($parent) { + $post->setPostParent($parent); } + } + } - $params = self::DBUTF8_array($params); - $params['c_id'] = $this->destination_course_id; - $params['forum_category'] = $cat_id; - $params['session_id'] = $sessionId; - $params['start_time'] = isset($params['start_time']) && '0000-00-00 00:00:00' === $params['start_time'] ? null : $params['start_time']; - $params['end_time'] = isset($params['end_time']) && '0000-00-00 00:00:00' === $params['end_time'] ? null : $params['end_time']; - $params['forum_id'] = 0; - unset($params['iid']); + $postRepo->create($post); + $em->flush(); - $params['forum_comment'] = DocumentManager::replaceUrlWithNewCourseCode( - $params['forum_comment'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + $this->course->resources['post'][$srcPostId]->destination_id = (int)$post->getIid(); + $this->dlog('restore_post: created', [ + 'src_post_id' => (int)$srcPostId, + 'dst_post_iid' => (int)$post->getIid(), + 'dst_thread_id' => (int)$thread->getIid(), + 'dst_forum_id' => (int)$forum->getIid(), + ]); - if (!empty($params['forum_image'])) { - $original_forum_image = $this->course->path.'upload/forum/images/'.$params['forum_image']; - if (file_exists($original_forum_image)) { - $new_forum_image = api_get_path(SYS_COURSE_PATH). - $this->destination_course_info['path'].'/upload/forum/images/'.$params['forum_image']; - @copy($original_forum_image, $new_forum_image); - } - } + return (int)$post->getIid(); + } - $new_id = Database::insert($table_forum, $params); + public function restore_link_category($id, $sessionId = 0) + { + $sessionId = (int) $sessionId; - if ($new_id) { - $sql = "UPDATE $table_forum SET forum_id = iid WHERE iid = $new_id"; - Database::query($sql); + // "No category" short-circuit (legacy used 0 as 'uncategorized'). + if (0 === (int) $id) { + $this->dlog('restore_link_category: source category is 0 (no category), returning 0'); - /*api_item_property_update( - $this->destination_course_info, - TOOL_FORUM, - $new_id, - 'ForumUpdated', - api_get_user_id() - );*/ + return 0; + } - $this->course->resources[RESOURCE_FORUM][$id]->destination_id = $new_id; + $resources = $this->course->resources ?? []; + $srcCat = $resources[RESOURCE_LINKCATEGORY][$id] ?? null; - $forum_topics = 0; - if (isset($this->course->resources[RESOURCE_FORUMTOPIC]) && - is_array($this->course->resources[RESOURCE_FORUMTOPIC]) - ) { - foreach ($this->course->resources[RESOURCE_FORUMTOPIC] as $topic_id => $topic) { - if ($topic->obj->forum_id == $id) { - $this->restore_topic($topic_id, $new_id, $sessionId); - $forum_topics++; - } - } + if (!is_object($srcCat)) { + error_log('COURSE_DEBUG: restore_link_category: source category object not found for id ' . $id); + + return 0; + } + + // Already restored? + if (!empty($srcCat->destination_id)) { + return (int) $srcCat->destination_id; + } + + $em = Database::getManager(); + $catRepo = Container::getLinkCategoryRepository(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity($sessionId); + + // Normalize incoming values + $title = (string) ($srcCat->title ?? $srcCat->category_title ?? 'Links'); + $description = (string) ($srcCat->description ?? ''); + + // Try to find existing category by *title* under this course (we'll filter by course parent in PHP) + $candidates = $catRepo->findBy(['title' => $title]); + + $existing = null; + if (!empty($candidates)) { + $courseNode = $course->getResourceNode(); + foreach ($candidates as $cand) { + $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null; + $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null; + if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) { + $existing = $cand; + break; + } + } + } + + // Collision handling + if ($existing) { + switch ($this->file_option) { + case FILE_SKIP: + $destIid = (int) $existing->getIid(); + $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid; + $this->dlog('restore_link_category: reuse (SKIP)', [ + 'src_cat_id' => (int) $id, + 'dst_cat_id' => $destIid, + 'title' => $title, + ]); + + return $destIid; + + case FILE_OVERWRITE: + // Update description (keep title) + $existing->setDescription($description); + // Ensure course/session link + if (method_exists($existing, 'setParent')) { + $existing->setParent($course); } - if ($forum_topics > 0) { - $sql = 'UPDATE '.$table_forum.' SET forum_threads = '.$forum_topics." - WHERE c_id = {$this->destination_course_id} AND forum_id = ".(int) $new_id; - Database::query($sql); + if (method_exists($existing, 'addCourseLink')) { + $existing->addCourseLink($course, $session); } - } + + $em->persist($existing); + $em->flush(); + + $destIid = (int) $existing->getIid(); + $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid; + $this->dlog('restore_link_category: overwrite', [ + 'src_cat_id' => (int) $id, + 'dst_cat_id' => $destIid, + 'title' => $title, + ]); + + return $destIid; + + case FILE_RENAME: + default: + // Create a new unique title inside the same course parent + $base = $title; + $i = 1; + do { + $title = $base . ' (' . $i . ')'; + $candidates = $catRepo->findBy(['title' => $title]); + $exists = false; + + if (!empty($candidates)) { + $courseNode = $course->getResourceNode(); + foreach ($candidates as $cand) { + $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null; + $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null; + if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) { + $exists = true; + break; + } + } + } + + $i++; + } while ($exists); + break; } } + + // Create new category + $cat = (new CLinkCategory()) + ->setTitle($title) + ->setDescription($description); + + if (method_exists($cat, 'setParent')) { + $cat->setParent($course); // parent ResourceNode: Course + } + if (method_exists($cat, 'addCourseLink')) { + $cat->addCourseLink($course, $session); // visibility link (course, session) + } + + $em->persist($cat); + $em->flush(); + + $destIid = (int) $cat->getIid(); + $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $destIid; + + $this->dlog('restore_link_category: created', [ + 'src_cat_id' => (int) $id, + 'dst_cat_id' => $destIid, + 'title' => (string) $title, + ]); + + return $destIid; } - /** - * Restore forum-categories. - */ - public function restore_forum_category($my_id = null, $sessionId = 0) + public function restore_links($session_id = 0) { - $forum_cat_table = Database::get_course_table(TABLE_FORUM_CATEGORY); + if (!$this->course->has_resources(RESOURCE_LINK)) { + return; + } + $resources = $this->course->resources; - $sessionId = (int) $sessionId; - if (!empty($resources[RESOURCE_FORUMCATEGORY])) { - foreach ($resources[RESOURCE_FORUMCATEGORY] as $id => $forum_cat) { - if (!empty($my_id)) { - if ($my_id != $id) { - continue; - } + $count = is_array($resources[RESOURCE_LINK] ?? null) ? count($resources[RESOURCE_LINK]) : 0; + + $this->dlog('restore_links: begin', ['count' => $count]); + + $em = Database::getManager(); + $linkRepo = Container::getLinkRepository(); + $catRepo = Container::getLinkCategoryRepository(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int) $session_id); + + // Safe duplicate finder (no dot-path in criteria; filter parent in PHP) + $findDuplicate = function (string $t, string $u, ?CLinkCategory $cat) use ($linkRepo, $course) { + $criteria = ['title' => $t, 'url' => $u]; + $criteria['category'] = $cat instanceof CLinkCategory ? $cat : null; + + $candidates = $linkRepo->findBy($criteria); + if (empty($candidates)) { + return null; + } + + $courseNode = $course->getResourceNode(); + foreach ($candidates as $cand) { + $node = method_exists($cand, 'getResourceNode') ? $cand->getResourceNode() : null; + $parent = $node && method_exists($node, 'getParent') ? $node->getParent() : null; + if ($parent && $courseNode && $parent->getId() === $courseNode->getId()) { + return $cand; } - if ($forum_cat && !$forum_cat->is_restored()) { - $params = (array) $forum_cat->obj; - $params['c_id'] = $this->destination_course_id; - $params['cat_comment'] = DocumentManager::replaceUrlWithNewCourseCode( - $params['cat_comment'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - $params['session_id'] = $sessionId; - $params['cat_id'] = 0; - unset($params['iid']); - - $params = self::DBUTF8_array($params); - $new_id = Database::insert($forum_cat_table, $params); - - if ($new_id) { - $sql = "UPDATE $forum_cat_table SET cat_id = iid WHERE iid = $new_id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - TOOL_FORUM_CATEGORY, - $new_id, - 'ForumCategoryUpdated', - api_get_user_id() - );*/ - $this->course->resources[RESOURCE_FORUMCATEGORY][$id]->destination_id = $new_id; + } + + return null; + }; + + foreach ($resources[RESOURCE_LINK] as $oldLinkId => $link) { + // Normalize (accept values from object or "extra") + $rawUrl = (string) ($link->url ?? ($link->extra['url'] ?? '')); + $rawTitle = (string) ($link->title ?? ($link->extra['title'] ?? '')); + $rawDesc = (string) ($link->description ?? ($link->extra['description'] ?? '')); + $target = isset($link->target) ? (string) $link->target : null; + $catSrcId = (int) ($link->category_id ?? 0); + $onHome = (bool) ($link->on_homepage ?? false); + + $url = trim($rawUrl); + $title = trim($rawTitle) !== '' ? trim($rawTitle) : $url; + + if ($url === '') { + $this->dlog('restore_links: skipped (empty URL)', [ + 'src_link_id' => (int) $oldLinkId, + 'has_obj' => !empty($link->has_obj), + 'extra_keys' => isset($link->extra) ? implode(',', array_keys((array) $link->extra)) : '', + ]); + continue; + } + + // Resolve / create destination category if source had one; otherwise null + $category = null; + if ($catSrcId > 0) { + $dstCatIid = (int) $this->restore_link_category($catSrcId, (int) $session_id); + if ($dstCatIid > 0) { + $category = $catRepo->find($dstCatIid); + } else { + $this->dlog('restore_links: category not available, using null', [ + 'src_link_id' => (int) $oldLinkId, + 'src_cat_id' => (int) $catSrcId, + ]); + } + } + + // Duplicate handling (title + url + category in same course) + $existing = $findDuplicate($title, $url, $category); + + if ($existing) { + if ($this->file_option === FILE_SKIP) { + $destIid = (int) $existing->getIid(); + $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new \stdClass(); + $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid; + + $this->dlog('restore_links: reuse (SKIP)', [ + 'src_link_id' => (int) $oldLinkId, + 'dst_link_id' => $destIid, + 'title' => $title, + 'url' => $url, + ]); + + continue; + } + + if ($this->file_option === FILE_OVERWRITE) { + // Update main fields (keep position/shortcut logic outside) + $existing + ->setUrl($url) + ->setTitle($title) + ->setDescription($rawDesc) // rewrite to assets after flush + ->setTarget((string) ($target ?? '')); + + if (method_exists($existing, 'setParent')) { + $existing->setParent($course); } + if (method_exists($existing, 'addCourseLink')) { + $existing->addCourseLink($course, $session); + } + $existing->setCategory($category); // can be null + + $em->persist($existing); + $em->flush(); - if (!empty($my_id)) { - return $new_id; + // Now rewrite legacy "document/..." URLs inside description to Assets + try { + $backupRoot = $this->course->backup_path ?? ''; + $extraRoots = array_filter([ + $this->course->destination_path ?? '', + $this->course->origin_path ?? '', + ]); + $rewritten = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $rawDesc, + $existing, + $backupRoot, + $extraRoots + ); + + if ($rewritten !== $rawDesc) { + $existing->setDescription($rewritten); + $em->persist($existing); + $em->flush(); + } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_links: asset rewrite failed (overwrite): ' . $e->getMessage()); } + + $destIid = (int) $existing->getIid(); + $this->course->resources[RESOURCE_LINK][$oldLinkId] ??= new \stdClass(); + $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid; + + $this->dlog('restore_links: overwrite', [ + 'src_link_id' => (int) $oldLinkId, + 'dst_link_id' => $destIid, + 'title' => $title, + 'url' => $url, + ]); + + continue; } + + // FILE_RENAME (default): make title unique among same course/category + $base = $title; + $i = 1; + do { + $title = $base . ' (' . $i . ')'; + $i++; + } while ($findDuplicate($title, $url, $category)); } - } - } - /** - * Restore a forum-topic. - * - * @param false|string $forum_id - * - * @return int - */ - public function restore_topic($thread_id, $forum_id, $sessionId = 0) - { - $table = Database::get_course_table(TABLE_FORUM_THREAD); - $topic = $this->course->resources[RESOURCE_FORUMTOPIC][$thread_id]; + // Create new link entity + $entity = (new CLink()) + ->setUrl($url) + ->setTitle($title) + ->setDescription($rawDesc) // rewrite to assets after first flush + ->setTarget((string) ($target ?? '')); - $sessionId = (int) $sessionId; - $params = (array) $topic->obj; - $params = self::DBUTF8_array($params); - $params['c_id'] = $this->destination_course_id; - $params['forum_id'] = $forum_id; - $params['thread_poster_id'] = $this->first_teacher_id; - $params['thread_date'] = api_get_utc_datetime(); - $params['thread_close_date'] = null; - $params['thread_last_post'] = 0; - $params['thread_replies'] = 0; - $params['thread_views'] = 0; - $params['session_id'] = $sessionId; - $params['thread_id'] = 0; - - unset($params['iid']); - - $new_id = Database::insert($table, $params); - - if ($new_id) { - $sql = "UPDATE $table SET thread_id = iid WHERE iid = $new_id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - TOOL_FORUM_THREAD, - $new_id, - 'ThreadAdded', - api_get_user_id(), - 0, - 0, - null, - null, - $sessionId - );*/ - - $this->course->resources[RESOURCE_FORUMTOPIC][$thread_id]->destination_id = $new_id; - foreach ($this->course->resources[RESOURCE_FORUMPOST] as $post_id => $post) { - if ($post->obj->thread_id == $thread_id) { - $this->restore_post($post_id, $new_id, $forum_id, $sessionId); + if (method_exists($entity, 'setParent')) { + $entity->setParent($course); // parent ResourceNode: Course + } + if (method_exists($entity, 'addCourseLink')) { + $entity->addCourseLink($course, $session); // visibility (course, session) + } + + if ($category instanceof CLinkCategory) { + $entity->setCategory($category); + } + + // Persist to create the ResourceNode; we need it for Asset attachment + $em->persist($entity); + $em->flush(); + + // Rewrite legacy "document/..." URLs inside description to Assets, then save if changed + try { + $backupRoot = $this->course->backup_path ?? ''; + $extraRoots = array_filter([ + $this->course->destination_path ?? '', + $this->course->origin_path ?? '', + ]); + $rewritten = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $rawDesc, + $entity, + (string) $backupRoot, + $extraRoots + ); + + if ($rewritten !== (string) $rawDesc) { + $entity->setDescription($rewritten); + $em->persist($entity); + $em->flush(); + } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_links: asset rewrite failed (create): ' . $e->getMessage()); + } + + // Map destination id back into resources + $destIid = (int) $entity->getIid(); + + if (!isset($this->course->resources[RESOURCE_LINK][$oldLinkId])) { + $this->course->resources[RESOURCE_LINK][$oldLinkId] = new \stdClass(); + } + $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $destIid; + + $this->dlog('restore_links: created', [ + 'src_link_id' => (int) $oldLinkId, + 'dst_link_id' => $destIid, + 'title' => $title, + 'url' => $url, + 'category' => $category ? $category->getTitle() : null, + ]); + + // Optional: emulate "show on homepage" by ensuring ResourceLink exists (UI/Controller handles real shortcut) + if (!empty($onHome)) { + try { + // Ensure resource link is persisted (it already is via addCourseLink) + // Any actual shortcut creation should be delegated to the appropriate service/controller. + $em->persist($entity); + $em->flush(); + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_links: homepage flag handling failed: ' . $e->getMessage()); } } } - return $new_id; + $this->dlog('restore_links: end'); } - /** - * Restore a forum-post. - * - * @TODO Restore tree-structure of posts. For example: attachments to posts. - * - * @param false|string $topic_id - * - * @return int - */ - public function restore_post($id, $topic_id, $forum_id, $sessionId = 0) + public function restore_tool_intro($sessionId = 0) { - $table_post = Database::get_course_table(TABLE_FORUM_POST); - $post = $this->course->resources[RESOURCE_FORUMPOST][$id]; - $params = (array) $post->obj; - $params['c_id'] = $this->destination_course_id; - $params['forum_id'] = $forum_id; - $params['thread_id'] = $topic_id; - $params['poster_id'] = $this->first_teacher_id; - $params['post_date'] = api_get_utc_datetime(); - $params['post_id'] = 0; - unset($params['iid']); - - $params['post_text'] = DocumentManager::replaceUrlWithNewCourseCode( - $params['post_text'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - $new_id = Database::insert($table_post, $params); - - if ($new_id) { - $sql = "UPDATE $table_post SET post_id = iid WHERE iid = $new_id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - TOOL_FORUM_POST, - $new_id, - 'PostAdded', - api_get_user_id(), - 0, - 0, - null, - null, - $sessionId - );*/ - $this->course->resources[RESOURCE_FORUMPOST][$id]->destination_id = $new_id; + $resources = $this->course->resources ?? []; + $bagKey = null; + if ($this->course->has_resources(RESOURCE_TOOL_INTRO)) { + $bagKey = RESOURCE_TOOL_INTRO; + } elseif (!empty($resources['Tool introduction'])) { + $bagKey = 'Tool introduction'; + } + if ($bagKey === null || empty($resources[$bagKey]) || !is_array($resources[$bagKey])) { + return; } - return $new_id; - } + $sessionId = (int) $sessionId; + $this->dlog('restore_tool_intro: begin', ['count' => count($resources[$bagKey])]); - /** - * Restore links. - */ - public function restore_links($session_id = 0) - { - if ($this->course->has_resources(RESOURCE_LINK)) { - $link_table = Database::get_course_table(TABLE_LINK); - $resources = $this->course->resources; + $em = \Database::getManager(); + $course = api_get_course_entity($this->destination_course_id); + $session = $sessionId ? api_get_session_entity($sessionId) : null; - foreach ($resources[RESOURCE_LINK] as $oldLinkId => $link) { - $cat_id = (int) $this->restore_link_category($link->category_id, $session_id); - $sql = "SELECT MAX(display_order) - FROM $link_table - WHERE - c_id = ".$this->destination_course_id." AND - category_id='".$cat_id."'"; - $result = Database::query($sql); - list($max_order) = Database::fetch_array($result); - - $params = []; - if (!empty($session_id)) { - $params['session_id'] = $session_id; - } - - $params['c_id'] = $this->destination_course_id; - $params['url'] = self::DBUTF8($link->url); - $params['title'] = self::DBUTF8($link->title); - $params['description'] = self::DBUTF8($link->description); - $params['category_id'] = $cat_id; - $params['on_homepage'] = $link->on_homepage; - $params['display_order'] = $max_order + 1; - $params['target'] = $link->target; - - $id = Database::insert($link_table, $params); - - if ($id) { - $sql = "UPDATE $link_table SET id = iid WHERE iid = $id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - TOOL_LINK, - $id, - 'LinkAdded', - api_get_user_id() - );*/ - - if (!isset($this->course->resources[RESOURCE_LINK][$oldLinkId])) { - $this->course->resources[RESOURCE_LINK][$oldLinkId] = new stdClass(); - } - $this->course->resources[RESOURCE_LINK][$oldLinkId]->destination_id = $id; + $toolRepo = $em->getRepository(Tool::class); + $cToolRepo = $em->getRepository(CTool::class); + $introRepo = $em->getRepository(CToolIntro::class); + + $rewriteContent = function (string $html) { + if ($html === '') return ''; + try { + if (class_exists(ChamiloHelper::class) + && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets') + ) { + return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $html, + api_get_course_entity($this->destination_course_id), + (string)($this->course->backup_path ?? ''), + array_filter([ + (string)($this->course->destination_path ?? ''), + (string)($this->course->info['path'] ?? ''), + ]) + ); + } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed (tool_intro): '.$e->getMessage()); + } + + $out = \DocumentManager::replaceUrlWithNewCourseCode( + $html, + $this->course->code, + $this->course->destination_path, + $this->course->backup_path, + $this->course->info['path'] + ); + return $out === false ? '' : $out; + }; + + foreach ($resources[$bagKey] as $rawId => $tIntro) { + // prefer source->id only if non-empty AND not "0"; otherwise use the bag key ($rawId) + $toolKey = trim((string)($tIntro->id ?? '')); + if ($toolKey === '' || $toolKey === '0') { + $toolKey = (string)$rawId; + } + + // normalize a couple of common aliases defensively + $alias = strtolower($toolKey); + if ($alias === 'homepage' || $alias === 'course_home') { + $toolKey = 'course_homepage'; + } + + // log exactly what we got to avoid future confusion + $this->dlog('restore_tool_intro: resolving tool key', [ + 'raw_id' => (string)$rawId, + 'obj_id' => isset($tIntro->id) ? (string)$tIntro->id : null, + 'toolKey' => $toolKey, + ]); + + $mapped = $tIntro->destination_id ?? 0; + if ($mapped > 0) { + $this->dlog('restore_tool_intro: already mapped, skipping', ['src_id' => $toolKey, 'dst_id' => $mapped]); + continue; + } + + $introHtml = $rewriteContent($tIntro->intro_text ?? ''); + + // find core Tool by title (e.g., 'course_homepage') + $toolEntity = $toolRepo->findOneBy(['title' => $toolKey]); + if (!$toolEntity) { + $this->dlog('restore_tool_intro: missing Tool entity, skipping', ['tool' => $toolKey]); + continue; + } + + // find or create the CTool row for this course+session+title + $cTool = $cToolRepo->findOneBy([ + 'course' => $course, + 'session' => $session, + 'title' => $toolKey, + ]); + + if (!$cTool) { + $cTool = (new CTool()) + ->setTool($toolEntity) + ->setTitle($toolKey) + ->setCourse($course) + ->setSession($session) + ->setPosition(1) + ->setVisibility(true) + ->setParent($course) + ->setCreator($course->getCreator() ?? null) + ->addCourseLink($course); + + $em->persist($cTool); + $em->flush(); + + $this->dlog('restore_tool_intro: CTool created', [ + 'tool' => $toolKey, + 'ctool_id' => (int)$cTool->getIid(), + ]); + } + + $intro = $introRepo->findOneBy(['courseTool' => $cTool]); + + if ($intro) { + if ($this->file_option === FILE_SKIP) { + $this->dlog('restore_tool_intro: reuse existing (SKIP)', [ + 'tool' => $toolKey, + 'intro_id' => (int)$intro->getIid(), + ]); + } else { + $intro->setIntroText($introHtml); + $em->persist($intro); + $em->flush(); + + $this->dlog('restore_tool_intro: intro overwritten', [ + 'tool' => $toolKey, + 'intro_id' => (int)$intro->getIid(), + ]); } + } else { + $intro = (new CToolIntro()) + ->setCourseTool($cTool) + ->setIntroText($introHtml) + ->setParent($course); + + $em->persist($intro); + $em->flush(); + + $this->dlog('restore_tool_intro: intro created', [ + 'tool' => $toolKey, + 'intro_id' => (int)$intro->getIid(), + ]); + } + + // map destination back into the legacy resource bag + if (!isset($this->course->resources[$bagKey][$rawId])) { + $this->course->resources[$bagKey][$rawId] = new \stdClass(); } + $this->course->resources[$bagKey][$rawId]->destination_id = (int)$intro->getIid(); } + + $this->dlog('restore_tool_intro: end'); } - /** - * Restore a link-category. - * - * @param int $id - * @param int $sessionId - * - * @return bool - */ - public function restore_link_category($id, $sessionId = 0) + + public function restore_events(int $sessionId = 0): void { - $params = []; - $sessionId = (int) $sessionId; - if (!empty($sessionId)) { - $params['session_id'] = $sessionId; + if (!$this->course->has_resources(RESOURCE_EVENT)) { + return; } - if (0 == $id) { - return 0; - } - $link_cat_table = Database::get_course_table(TABLE_LINK_CATEGORY); - $resources = $this->course->resources; - $link_cat = $resources[RESOURCE_LINKCATEGORY][$id]; - if (is_object($link_cat) && !$link_cat->is_restored()) { - $sql = "SELECT MAX(display_order) FROM $link_cat_table - WHERE c_id = ".$this->destination_course_id; - $result = Database::query($sql); - list($orderMax) = Database::fetch_array($result, 'NUM'); - $display_order = $orderMax + 1; - - $params['c_id'] = $this->destination_course_id; - $params['category_title'] = self::DBUTF8($link_cat->title); - $params['description'] = self::DBUTF8($link_cat->description); - $params['display_order'] = $display_order; - $new_id = Database::insert($link_cat_table, $params); - - if ($new_id) { - $sql = "UPDATE $link_cat_table - SET id = iid - WHERE iid = $new_id"; - Database::query($sql); - - $courseInfo = api_get_course_info_by_id($this->destination_course_id); - /*api_item_property_update( - $courseInfo, - TOOL_LINK_CATEGORY, - $new_id, - 'LinkCategoryAdded', - api_get_user_id() - );*/ - api_set_default_visibility( - $new_id, - TOOL_LINK_CATEGORY, - 0, - $courseInfo - ); + $resources = $this->course->resources ?? []; + $bag = $resources[RESOURCE_EVENT] ?? []; + $count = is_array($bag) ? count($bag) : 0; + + $this->dlog('restore_events: begin', ['count' => $count]); + + /** @var EntityManagerInterface $em */ + $em = \Database::getManager(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity($sessionId); + $group = api_get_group_entity(); + $eventRepo = Container::getCalendarEventRepository(); + $attachRepo = Container::getCalendarEventAttachmentRepository(); + + // Content rewrite helper (prefer new helper if available) + $rewriteContent = function (?string $html): string { + $html = $html ?? ''; + if ($html === '') { + return ''; + } + try { + if (method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { + return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $html, + api_get_course_entity($this->destination_course_id), + $this->course->backup_path ?? '', + array_filter([ + $this->course->destination_path ?? '', + (string) ($this->course->info['path'] ?? ''), + ]) + ); + } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); + } + + $out = \DocumentManager::replaceUrlWithNewCourseCode( + $html, + $this->course->code, + $this->course->destination_path, + $this->course->backup_path, + $this->course->info['path'] + ); + + return $out === false ? '' : (string) $out; + }; + + // Dedupe by title inside same course/session (honor sameFileNameOption) + $findExistingByTitle = function (string $title) use ($eventRepo, $course, $session) { + $qb = $eventRepo->getResourcesByCourse($course, $session, null, null, true, true); + $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1); + return $qb->getQuery()->getOneOrNullResult(); + }; + + // Attachment source in backup zip (calendar) + $originPath = rtrim((string)($this->course->backup_path ?? ''), '/').'/upload/calendar/'; + + foreach ($bag as $oldId => $raw) { + // Skip if already mapped to a positive destination id + $mapped = (int) ($raw->destination_id ?? 0); + if ($mapped > 0) { + $this->dlog('restore_events: already mapped, skipping', ['src_id' => (int)$oldId, 'dst_id' => $mapped]); + continue; + } + + // Normalize input + $title = trim((string)($raw->title ?? '')); + if ($title === '') { + $title = 'Event'; + } + + $content = $rewriteContent((string)($raw->content ?? '')); + + // Dates: accept various formats; allow empty endDate + $allDay = (bool)($raw->all_day ?? false); + $start = null; + $end = null; + try { + $s = (string)($raw->start_date ?? ''); + if ($s !== '') { $start = new \DateTime($s); } + } catch (\Throwable $e) { $start = null; } + try { + $e = (string)($raw->end_date ?? ''); + if ($e !== '') { $end = new \DateTime($e); } + } catch (\Throwable $e) { $end = null; } + + // Dedupe policy + $existing = $findExistingByTitle($title); + if ($existing) { + switch ($this->file_option) { + case FILE_SKIP: + $destId = (int)$existing->getIid(); + $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId; + $this->dlog('restore_events: reuse (SKIP)', [ + 'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $existing->getTitle() + ]); + // Try to add missing attachments (no duplicates by filename) + $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em); + break; + + case FILE_OVERWRITE: + $existing + ->setTitle($title) + ->setContent($content) + ->setAllDay($allDay) + ->setParent($course) + ->addCourseLink($course, $session, $group); + + $existing->setStartDate($start); + $existing->setEndDate($end); + + $em->persist($existing); + $em->flush(); + + $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = (int)$existing->getIid(); + + $this->dlog('restore_events: overwrite', [ + 'src_id' => (int)$oldId, 'dst_id' => (int)$existing->getIid(), 'title' => $title + ]); + + $this->restoreEventAttachments($raw, $existing, $originPath, $attachRepo, $em); + break; + + case FILE_RENAME: + default: + $base = $title; + $i = 1; + $candidate = $base; + while ($findExistingByTitle($candidate)) { + $i++; + $candidate = $base.' ('.$i.')'; + } + $title = $candidate; + break; + } } - $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id = $new_id; + // Create new entity in course context + $entity = (new CCalendarEvent()) + ->setTitle($title) + ->setContent($content) + ->setAllDay($allDay) + ->setParent($course) + ->addCourseLink($course, $session, $group); + + $entity->setStartDate($start); + $entity->setEndDate($end); + + $em->persist($entity); + $em->flush(); + + // Map new id + $destId = (int)$entity->getIid(); + $this->course->resources[RESOURCE_EVENT][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_EVENT][$oldId]->destination_id = $destId; - return $new_id; + $this->dlog('restore_events: created', ['src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $title]); + + // Attachments (backup modern / legacy) + $this->restoreEventAttachments($raw, $entity, $originPath, $attachRepo, $em); + + // (Optional) Repeat rules / reminders: + // If your backup exports recurrence/reminders, parse here and populate CCalendarEventRepeat / AgendaReminder. + // $this->restoreEventRecurrenceAndReminders($raw, $entity, $em); } - return $this->course->resources[RESOURCE_LINKCATEGORY][$id]->destination_id; + $this->dlog('restore_events: end'); } - /** - * Restore tool intro. - * - * @param int $sessionId - */ - public function restore_tool_intro($sessionId = 0) - { - if ($this->course->has_resources(RESOURCE_TOOL_INTRO)) { - $sessionId = (int) $sessionId; - $tool_intro_table = Database::get_course_table(TABLE_TOOL_INTRO); - $resources = $this->course->resources; - foreach ($resources[RESOURCE_TOOL_INTRO] as $id => $tool_intro) { - if (!$this->copySessionContent) { - $sql = "DELETE FROM $tool_intro_table - WHERE - c_id = ".$this->destination_course_id." AND - id='".self::DBUTF8escapestring($tool_intro->id)."'"; - Database::query($sql); + private function restoreEventAttachments( + object $raw, + CCalendarEvent $entity, + string $originPath, + $attachRepo, + EntityManagerInterface $em + ): void { + // Helper to actually persist + move file + $persistAttachmentFromFile = function (string $src, string $filename, ?string $comment) use ($entity, $attachRepo, $em) { + if (!is_file($src) || !is_readable($src)) { + $this->dlog('restore_events: attachment source not readable', ['src' => $src]); + return; + } + + // Avoid duplicate filenames on same event + foreach ($entity->getAttachments() as $att) { + if ($att->getFilename() === $filename) { + $this->dlog('restore_events: attachment already exists, skipping', ['filename' => $filename]); + return; } + } - $tool_intro->intro_text = DocumentManager::replaceUrlWithNewCourseCode( - $tool_intro->intro_text, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] + $attachment = (new CCalendarEventAttachment()) + ->setFilename($filename) + ->setComment($comment ?? '') + ->setEvent($entity) + ->setParent($entity) + ->addCourseLink( + api_get_course_entity($this->destination_course_id), + api_get_session_entity(0), + api_get_group_entity() ); - $params = [ - 'c_id' => $this->destination_course_id, - 'id' => false === $tool_intro->id ? '' : self::DBUTF8($tool_intro->id), - 'intro_text' => self::DBUTF8($tool_intro->intro_text), - 'session_id' => $sessionId, - ]; + $em->persist($attachment); + $em->flush(); - $id = Database::insert($tool_intro_table, $params); - if ($id) { - if (!isset($this->course->resources[RESOURCE_TOOL_INTRO][$id])) { - $this->course->resources[RESOURCE_TOOL_INTRO][$id] = new stdClass(); - } + if (method_exists($attachRepo, 'addFileFromLocalPath')) { + $attachRepo->addFileFromLocalPath($attachment, $src); + } else { + $dstDir = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/upload/calendar/'; + @mkdir($dstDir, 0775, true); + $newName = uniqid('calendar_', true); + @copy($src, $dstDir.$newName); + } - $this->course->resources[RESOURCE_TOOL_INTRO][$id]->destination_id = $id; - } + $this->dlog('restore_events: attachment created', [ + 'event_id' => (int)$entity->getIid(), + 'filename' => $filename, + ]); + }; + + // Case 1: modern backup fields on object + if (!empty($raw->attachment_path)) { + $src = rtrim($originPath, '/').'/'.$raw->attachment_path; + $filename = (string)($raw->attachment_filename ?? basename($src)); + $comment = (string)($raw->attachment_comment ?? ''); + $persistAttachmentFromFile($src, $filename, $comment); + return; + } + + // Case 2: legacy lookup from old course tables when ->orig present + if (!empty($this->course->orig)) { + $table = \Database::get_course_table(TABLE_AGENDA_ATTACHMENT); + $sql = 'SELECT path, comment, filename + FROM '.$table.' + WHERE c_id = '.$this->destination_course_id.' + AND agenda_id = '.(int)($raw->source_id ?? 0); + $res = \Database::query($sql); + while ($row = \Database::fetch_object($res)) { + $src = rtrim($originPath, '/').'/'.$row->path; + $persistAttachmentFromFile($src, (string)$row->filename, (string)$row->comment); } } } - /** - * Restore events. - * - * @param int $sessionId - */ - public function restore_events($sessionId = 0) + public function restore_course_descriptions($session_id = 0) { - if ($this->course->has_resources(RESOURCE_EVENT)) { - $sessionId = (int) $sessionId; - $table = Database::get_course_table(TABLE_AGENDA); - $resources = $this->course->resources; - foreach ($resources[RESOURCE_EVENT] as $id => $event) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $event->content = DocumentManager::replaceUrlWithNewCourseCode( - $event->content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + if (!$this->course->has_resources(RESOURCE_COURSEDESCRIPTION)) { + return; + } - $params = [ - 'c_id' => $this->destination_course_id, - 'title' => self::DBUTF8($event->title), - 'content' => false === $event->content ? '' : self::DBUTF8($event->content), - 'all_day' => $event->all_day, - 'start_date' => $event->start_date, - 'end_date' => $event->end_date, - 'session_id' => $sessionId, - ]; - $new_event_id = Database::insert($table, $params); + $resources = $this->course->resources; + $count = is_array($resources[RESOURCE_COURSEDESCRIPTION] ?? null) + ? count($resources[RESOURCE_COURSEDESCRIPTION]) + : 0; - if ($new_event_id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $new_event_id"; - Database::query($sql); + $this->dlog('restore_course_descriptions: begin', ['count' => $count]); - if (!isset($this->course->resources[RESOURCE_EVENT][$id])) { - $this->course->resources[RESOURCE_EVENT][$id] = new stdClass(); - } - $this->course->resources[RESOURCE_EVENT][$id]->destination_id = $new_event_id; - } - - // Copy event attachment - $origin_path = $this->course->backup_path.'/upload/calendar/'; - $destination_path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/upload/calendar/'; - - if (!empty($this->course->orig)) { - $table_attachment = Database::get_course_table(TABLE_AGENDA_ATTACHMENT); - $sql = 'SELECT path, comment, size, filename - FROM '.$table_attachment.' - WHERE c_id = '.$this->destination_course_id.' AND agenda_id = '.$id; - $attachment_event = Database::query($sql); - $attachment_event = Database::fetch_object($attachment_event); - - if (file_exists($origin_path.$attachment_event->path) && - !is_dir($origin_path.$attachment_event->path) - ) { - $new_filename = uniqid(''); //ass seen in the add_agenda_attachment_file() function in agenda.inc.php - $copy_result = copy( - $origin_path.$attachment_event->path, - $destination_path.$new_filename - ); - //$copy_result = true; - if ($copy_result) { - $table_attachment = Database::get_course_table(TABLE_AGENDA_ATTACHMENT); - - $params = [ - 'c_id' => $this->destination_course_id, - 'path' => self::DBUTF8($new_filename), - 'comment' => self::DBUTF8($attachment_event->comment), - 'size' => isset($attachment_event->size) ? $attachment_event->size : '', - 'filename' => isset($attachment_event->filename) ? $attachment_event->filename : '', - 'agenda_id' => $new_event_id, - ]; - $id = Database::insert($table_attachment, $params); - if ($id) { - $sql = "UPDATE $table_attachment SET id = iid WHERE iid = $id"; - Database::query($sql); - } - } - } - } else { - // get the info of the file - if (!empty($event->attachment_path) && - is_file($origin_path.$event->attachment_path) && - is_readable($origin_path.$event->attachment_path) - ) { - $new_filename = uniqid(''); //ass seen in the add_agenda_attachment_file() function in agenda.inc.php - $copy_result = copy( - $origin_path.$event->attachment_path, - $destination_path.$new_filename - ); - if ($copy_result) { - $table_attachment = Database::get_course_table(TABLE_AGENDA_ATTACHMENT); - - $params = [ - 'c_id' => $this->destination_course_id, - 'path' => self::DBUTF8($new_filename), - 'comment' => self::DBUTF8($event->attachment_comment), - 'size' => isset($event->size) ? $event->size : '', - 'filename' => isset($event->filename) ? $event->filename : '', - 'agenda_id' => $new_event_id, - ]; - $id = Database::insert($table_attachment, $params); - - if ($id) { - $sql = "UPDATE $table_attachment SET id = iid WHERE iid = $id"; - Database::query($sql); - } + $em = \Database::getManager(); + $repo = Container::getCourseDescriptionRepository(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity((int) $session_id); + + $rewriteContent = function (string $html) use ($course) { + if ($html === '') { + return ''; + } + if (method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { + try { + return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $html, + $course, + $this->course->backup_path ?? '', + array_filter([ + $this->course->destination_path ?? '', + (string)($this->course->info['path'] ?? ''), + ]) + ); + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); + } + } + $out = \DocumentManager::replaceUrlWithNewCourseCode( + $html, + $this->course->code, + $this->course->destination_path, + $this->course->backup_path, + $this->course->info['path'] + ); + + return $out === false ? '' : $out; + }; + + $findByTypeInCourse = function (int $type) use ($repo, $course, $session) { + if (method_exists($repo, 'findByTypeInCourse')) { + return $repo->findByTypeInCourse($type, $course, $session); + } + $qb = $repo->getResourcesByCourse($course, $session)->andWhere('resource.descriptionType = :t')->setParameter('t', $type); + return $qb->getQuery()->getResult(); + }; + + $findByTitleInCourse = function (string $title) use ($repo, $course, $session) { + $qb = $repo->getResourcesByCourse($course, $session) + ->andWhere('resource.title = :t') + ->setParameter('t', $title) + ->setMaxResults(1); + return $qb->getQuery()->getOneOrNullResult(); + }; + + foreach ($resources[RESOURCE_COURSEDESCRIPTION] as $oldId => $cd) { + $mapped = (int)($cd->destination_id ?? 0); + if ($mapped > 0) { + $this->dlog('restore_course_descriptions: already mapped, skipping', [ + 'src_id' => (int)$oldId, + 'dst_id' => $mapped, + ]); + continue; + } + + $rawTitle = (string)($cd->title ?? ''); + $rawContent = (string)($cd->content ?? ''); + $type = (int)($cd->description_type ?? CCourseDescription::TYPE_DESCRIPTION); + $title = trim($rawTitle) !== '' ? trim($rawTitle) : $rawTitle; + $content = $rewriteContent($rawContent); + + $existingByType = $findByTypeInCourse($type); + $existingOne = $existingByType[0] ?? null; + + if ($existingOne) { + switch ($this->file_option) { + case FILE_SKIP: + $destIid = (int)$existingOne->getIid(); + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid; + + $this->dlog('restore_course_descriptions: reuse (SKIP)', [ + 'src_id' => (int)$oldId, + 'dst_id' => $destIid, + 'type' => $type, + 'title' => (string)$existingOne->getTitle(), + ]); + break; + + case FILE_OVERWRITE: + $existingOne + ->setTitle($title !== '' ? $title : (string)$existingOne->getTitle()) + ->setContent($content) + ->setDescriptionType($type) + ->setProgress((int)($cd->progress ?? 0)); + $existingOne->setParent($course)->addCourseLink($course, $session); + + $em->persist($existingOne); + $em->flush(); + + $destIid = (int)$existingOne->getIid(); + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid; + + $this->dlog('restore_course_descriptions: overwrite', [ + 'src_id' => (int)$oldId, + 'dst_id' => $destIid, + 'type' => $type, + 'title' => (string)$existingOne->getTitle(), + ]); + break; + + case FILE_RENAME: + default: + $base = $title !== '' ? $title : (string)($cd->extra['title'] ?? 'Description'); + $i = 1; + $candidate = $base; + while ($findByTitleInCourse($candidate)) { + $i++; + $candidate = $base.' ('.$i.')'; } - } + $title = $candidate; + break; } } + + $entity = (new CCourseDescription()) + ->setTitle($title) + ->setContent($content) + ->setDescriptionType($type) + ->setProgress((int)($cd->progress ?? 0)) + ->setParent($course) + ->addCourseLink($course, $session); + + $em->persist($entity); + $em->flush(); + + $destIid = (int)$entity->getIid(); + + if (!isset($this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId])) { + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId] = new \stdClass(); + } + $this->course->resources[RESOURCE_COURSEDESCRIPTION][$oldId]->destination_id = $destIid; + + $this->dlog('restore_course_descriptions: created', [ + 'src_id' => (int)$oldId, + 'dst_id' => $destIid, + 'type' => $type, + 'title' => $title, + ]); } + + $this->dlog('restore_course_descriptions: end'); } - /** - * Restore course-description. - * - * @param int $session_id - */ - public function restore_course_descriptions($session_id = 0) + private function resourceFileAbsPathFromAnnouncementAttachment(CAnnouncementAttachment $att): ?string { - if ($this->course->has_resources(RESOURCE_COURSEDESCRIPTION)) { - $table = Database::get_course_table(TABLE_COURSE_DESCRIPTION); - $resources = $this->course->resources; - foreach ($resources[RESOURCE_COURSEDESCRIPTION] as $id => $cd) { - $courseDescription = (array) $cd; + $node = $att->getResourceNode(); + if (!$node) return null; - $content = isset($courseDescription['content']) ? $courseDescription['content'] : ''; - $descriptionType = isset($courseDescription['description_type']) ? $courseDescription['description_type'] : ''; - $title = isset($courseDescription['title']) ? $courseDescription['title'] : ''; + $file = $node->getFirstResourceFile(); + if (!$file) return null; - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $description_content = DocumentManager::replaceUrlWithNewCourseCode( - $content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + /** @var ResourceNodeRepository $rnRepo */ + $rnRepo = Container::$container->get(ResourceNodeRepository::class); + $rel = $rnRepo->getFilename($file); + if (!$rel) return null; - $params = []; - $session_id = (int) $session_id; - $params['session_id'] = $session_id; - $params['c_id'] = $this->destination_course_id; - $params['description_type'] = self::DBUTF8($descriptionType); - $params['title'] = self::DBUTF8($title); - $params['content'] = false === $description_content ? '' : self::DBUTF8($description_content); - $params['progress'] = 0; - - $id = Database::insert($table, $params); - if ($id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $id"; - Database::query($sql); - - if (!isset($this->course->resources[RESOURCE_COURSEDESCRIPTION][$id])) { - $this->course->resources[RESOURCE_COURSEDESCRIPTION][$id] = new stdClass(); - } - $this->course->resources[RESOURCE_COURSEDESCRIPTION][$id]->destination_id = $id; - } - } - } + $abs = $this->projectUploadBase().$rel; + return is_readable($abs) ? $abs : null; } - /** - * Restore announcements. - * - * @param int $sessionId - */ public function restore_announcements($sessionId = 0) { - if ($this->course->has_resources(RESOURCE_ANNOUNCEMENT)) { - $sessionId = (int) $sessionId; - $table = Database::get_course_table(TABLE_ANNOUNCEMENT); - $resources = $this->course->resources; - foreach ($resources[RESOURCE_ANNOUNCEMENT] as $id => $announcement) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $announcement->content = DocumentManager::replaceUrlWithNewCourseCode( - $announcement->content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + if (!$this->course->has_resources(RESOURCE_ANNOUNCEMENT)) { + return; + } + + $sessionId = (int) $sessionId; + $resources = $this->course->resources; + + $count = is_array($resources[RESOURCE_ANNOUNCEMENT] ?? null) + ? count($resources[RESOURCE_ANNOUNCEMENT]) + : 0; + + $this->dlog('restore_announcements: begin', ['count' => $count]); + + /** @var EntityManagerInterface $em */ + $em = \Database::getManager(); + $course = api_get_course_entity($this->destination_course_id); + $session = api_get_session_entity($sessionId); + $group = api_get_group_entity(); + $annRepo = Container::getAnnouncementRepository(); + $attachRepo = Container::getAnnouncementAttachmentRepository(); + + $rewriteContent = function (string $html) { + if ($html === '') return ''; + try { + if (class_exists(ChamiloHelper::class) + && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { + return ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $html, + api_get_course_entity($this->destination_course_id), + $this->course->backup_path ?? '', + array_filter([ + $this->course->destination_path ?? '', + (string)($this->course->info['path'] ?? ''), + ]) + ); + } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); + } + + $out = \DocumentManager::replaceUrlWithNewCourseCode( + $html, + $this->course->code, + $this->course->destination_path, + $this->course->backup_path, + $this->course->info['path'] + ); + + return $out === false ? '' : $out; + }; + + $findExistingByTitle = function (string $title) use ($annRepo, $course, $session) { + $qb = $annRepo->getResourcesByCourse($course, $session); + $qb->andWhere('resource.title = :t')->setParameter('t', $title)->setMaxResults(1); + return $qb->getQuery()->getOneOrNullResult(); + }; + + $originPath = rtrim($this->course->backup_path ?? '', '/').'/upload/announcements/'; + + foreach ($resources[RESOURCE_ANNOUNCEMENT] as $oldId => $a) { + $mapped = (int)($a->destination_id ?? 0); + if ($mapped > 0) { + $this->dlog('restore_announcements: already mapped, skipping', [ + 'src_id' => (int)$oldId, 'dst_id' => $mapped + ]); + continue; + } + + $title = trim((string)($a->title ?? '')); + if ($title === '') { $title = 'Announcement'; } + + $contentHtml = (string)($a->content ?? ''); + $contentHtml = $rewriteContent($contentHtml); + + $endDate = null; + try { + $rawDate = (string)($a->date ?? ''); + if ($rawDate !== '') { $endDate = new \DateTime($rawDate); } + } catch (\Throwable $e) { $endDate = null; } + + $emailSent = (bool)($a->email_sent ?? false); + + $existing = $findExistingByTitle($title); + if ($existing) { + switch ($this->file_option) { + case FILE_SKIP: + $destId = (int)$existing->getIid(); + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId; + $this->dlog('restore_announcements: reuse (SKIP)', [ + 'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $existing->getTitle() + ]); + break; + + case FILE_OVERWRITE: + $existing + ->setTitle($title) + ->setContent($contentHtml) + ->setParent($course) + ->addCourseLink($course, $session, $group) + ->setEmailSent($emailSent); + if ($endDate instanceof \DateTimeInterface) { $existing->setEndDate($endDate); } + $em->persist($existing); + $em->flush(); + + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = (int)$existing->getIid(); + + $this->dlog('restore_announcements: overwrite', [ + 'src_id' => (int)$oldId, 'dst_id' => (int)$existing->getIid(), 'title' => $title + ]); + + $this->restoreAnnouncementAttachments($a, $existing, $originPath, $attachRepo, $em); + continue 2; + + case FILE_RENAME: + default: + $base = $title; $i = 1; $candidate = $base; + while ($findExistingByTitle($candidate)) { $i++; $candidate = $base.' ('.$i.')'; } + $title = $candidate; + break; + } + } - $params = [ - 'c_id' => $this->destination_course_id, - 'title' => self::DBUTF8($announcement->title), - 'content' => false === $announcement->content ? '' : self::DBUTF8($announcement->content), - 'end_date' => $announcement->date, - 'display_order' => $announcement->display_order, - 'email_sent' => $announcement->email_sent, - 'session_id' => $sessionId, - ]; + $entity = (new CAnnouncement()) + ->setTitle($title) + ->setContent($contentHtml) + ->setParent($course) + ->addCourseLink($course, $session, $group) + ->setEmailSent($emailSent); + if ($endDate instanceof \DateTimeInterface) { $entity->setEndDate($endDate); } - $new_announcement_id = Database::insert($table, $params); + $em->persist($entity); + $em->flush(); - if ($new_announcement_id) { - $sql = "UPDATE $table SET id = iid WHERE iid = $new_announcement_id"; - Database::query($sql); + $destId = (int)$entity->getIid(); + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId] ??= new \stdClass(); + $this->course->resources[RESOURCE_ANNOUNCEMENT][$oldId]->destination_id = $destId; - if (!isset($this->course->resources[RESOURCE_ANNOUNCEMENT][$id])) { - $this->course->resources[RESOURCE_ANNOUNCEMENT][$id] = new stdClass(); - } - $this->course->resources[RESOURCE_ANNOUNCEMENT][$id]->destination_id = $new_announcement_id; - } - - $origin_path = $this->course->backup_path.'/upload/announcements/'; - $destination_path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/upload/announcements/'; - - // Copy announcement attachment file - if (!empty($this->course->orig)) { - $table_attachment = Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT); - $sql = 'SELECT path, comment, size, filename - FROM '.$table_attachment.' - WHERE - c_id = '.$this->destination_course_id.' AND - announcement_id = '.$id; - $attachment_event = Database::query($sql); - $attachment_event = Database::fetch_object($attachment_event); - - if (file_exists($origin_path.$attachment_event->path) && - !is_dir($origin_path.$attachment_event->path) - ) { - $new_filename = uniqid(''); //ass seen in the add_agenda_attachment_file() function in agenda.inc.php - $copy_result = copy( - $origin_path.$attachment_event->path, - $destination_path.$new_filename - ); + $this->dlog('restore_announcements: created', [ + 'src_id' => (int)$oldId, 'dst_id' => $destId, 'title' => $title + ]); + + $this->restoreAnnouncementAttachments($a, $entity, $originPath, $attachRepo, $em); + } + + $this->dlog('restore_announcements: end'); + } - if ($copy_result) { - $table_attachment = Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT); + private function restoreAnnouncementAttachments( + object $a, + CAnnouncement $entity, + string $originPath, + $attachRepo, + EntityManagerInterface $em + ): void { + $copyMode = empty($this->course->backup_path); + + if ($copyMode) { + $srcAttachmentIds = []; + if (!empty($a->attachment_source_id)) { $srcAttachmentIds[] = (int)$a->attachment_source_id; } + if (!empty($a->attachment_source_ids) && is_array($a->attachment_source_ids)) { + foreach ($a->attachment_source_ids as $sid) { $sid = (int)$sid; if ($sid > 0) $srcAttachmentIds[] = $sid; } + } + if (empty($srcAttachmentIds) && !empty($a->source_id)) { + $srcAnn = Container::getAnnouncementRepository()->find((int)$a->source_id); + if ($srcAnn) { + $srcAtts = Container::getAnnouncementAttachmentRepository()->findBy(['announcement' => $srcAnn]); + foreach ($srcAtts as $sa) { $srcAttachmentIds[] = (int)$sa->getIid(); } + } + } - $params = [ - 'c_id' => $this->destination_course_id, - 'path' => self::DBUTF8($new_filename), - 'comment' => self::DBUTF8($attachment_event->comment), - 'size' => $attachment_event->size, - 'filename' => $attachment_event->filename, - 'announcement_id' => $new_announcement_id, - ]; + if (!empty($srcAttachmentIds)) { + $attRepo = Container::getAnnouncementAttachmentRepository(); - $attachmentId = Database::insert($table_attachment, $params); + foreach (array_unique($srcAttachmentIds) as $sid) { + /** @var CAnnouncementAttachment|null $srcAtt */ + $srcAtt = $attRepo->find($sid); + if (!$srcAtt) { continue; } - if ($attachmentId) { - $sql = "UPDATE $table_attachment SET id = iid WHERE iid = $attachmentId"; - Database::query($sql); - } - } + $abs = $this->resourceFileAbsPathFromAnnouncementAttachment($srcAtt); + if (!$abs) { + $this->dlog('restore_announcements: source attachment file not readable', ['src_att_id' => $sid]); + continue; } - } else { - // get the info of the file - if (!empty($announcement->attachment_path) && - is_file($origin_path.$announcement->attachment_path) && - is_readable($origin_path.$announcement->attachment_path) - ) { - $new_filename = uniqid(''); //ass seen in the add_agenda_attachment_file() function in agenda.inc.php - $copy_result = copy($origin_path.$announcement->attachment_path, $destination_path.$new_filename); - - if ($copy_result) { - $table_attachment = Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT); - - $params = [ - 'c_id' => $this->destination_course_id, - 'path' => self::DBUTF8($new_filename), - 'comment' => self::DBUTF8($announcement->attachment_comment), - 'size' => $announcement->attachment_size, - 'filename' => $announcement->attachment_filename, - 'announcement_id' => $new_announcement_id, - ]; - - $attachmentId = Database::insert($table_attachment, $params); - - if ($attachmentId) { - $sql = "UPDATE $table_attachment SET id = iid WHERE iid = $attachmentId"; - Database::query($sql); + + $filename = $srcAtt->getFilename() ?: basename($abs); + foreach ($entity->getAttachments() as $existingA) { + if ($existingA->getFilename() === $filename) { + if ($this->file_option === FILE_SKIP) { continue 2; } + if ($this->file_option === FILE_RENAME) { + $pi = pathinfo($filename); + $base = $pi['filename'] ?? $filename; + $ext = isset($pi['extension']) && $pi['extension'] !== '' ? ('.'.$pi['extension']) : ''; + $i = 1; $candidate = $filename; + $existingNames = array_map(fn($x) => $x->getFilename(), iterator_to_array($entity->getAttachments())); + while (in_array($candidate, $existingNames, true)) { $candidate = $base.'_'.$i.$ext; $i++; } + $filename = $candidate; } } } + + $newAtt = (new CAnnouncementAttachment()) + ->setFilename($filename) + ->setComment((string)$srcAtt->getComment()) + ->setSize((int)$srcAtt->getSize()) + ->setPath(uniqid('announce_', true)) + ->setAnnouncement($entity) + ->setParent($entity) + ->addCourseLink( + api_get_course_entity($this->destination_course_id), + api_get_session_entity(0), + api_get_group_entity() + ); + + $em->persist($newAtt); + $em->flush(); + + if (method_exists($attachRepo, 'addFileFromLocalPath')) { + $attachRepo->addFileFromLocalPath($newAtt, $abs); + } else { + $tmp = tempnam(sys_get_temp_dir(), 'ann_'); + @copy($abs, $tmp); + $_FILES['user_upload'] = [ + 'name' => $filename, + 'type' => function_exists('mime_content_type') ? (mime_content_type($tmp) ?: 'application/octet-stream') : 'application/octet-stream', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => filesize($tmp) ?: (int)$srcAtt->getSize(), + ]; + $attachRepo->addFileFromFileRequest($newAtt, 'user_upload'); + @unlink($tmp); + } + + $this->dlog('restore_announcements: attachment copied from ResourceFile', [ + 'dst_announcement_id' => (int)$entity->getIid(), + 'filename' => $newAtt->getFilename(), + 'size' => $newAtt->getSize(), + ]); + } + } + return; + } + + $meta = null; + if (!empty($a->attachment_path)) { + $src = rtrim($originPath, '/').'/'.$a->attachment_path; + if (is_file($src) && is_readable($src)) { + $meta = [ + 'src' => $src, + 'filename' => (string)($a->attachment_filename ?? basename($src)), + 'comment' => (string)($a->attachment_comment ?? ''), + 'size' => (int)($a->attachment_size ?? (filesize($src) ?: 0)), + ]; + } + } + if (!$meta && !empty($this->course->orig)) { + $table = \Database::get_course_table(TABLE_ANNOUNCEMENT_ATTACHMENT); + $sql = 'SELECT path, comment, size, filename + FROM '.$table.' + WHERE c_id = '.$this->destination_course_id.' + AND announcement_id = '.(int)($a->source_id ?? 0); + $res = \Database::query($sql); + if ($row = \Database::fetch_object($res)) { + $src = rtrim($originPath, '/').'/'.$row->path; + if (is_file($src) && is_readable($src)) { + $meta = [ + 'src' => $src, + 'filename' => (string)$row->filename, + 'comment' => (string)$row->comment, + 'size' => (int)$row->size, + ]; } } } + if (!$meta) { return; } + + $attachment = (new CAnnouncementAttachment()) + ->setFilename($meta['filename']) + ->setPath(uniqid('announce_', true)) + ->setComment($meta['comment']) + ->setSize($meta['size']) + ->setAnnouncement($entity) + ->setParent($entity) + ->addCourseLink( + api_get_course_entity($this->destination_course_id), + api_get_session_entity(0), + api_get_group_entity() + ); + + $em->persist($attachment); + $em->flush(); + + $tmp = tempnam(sys_get_temp_dir(), 'ann_'); + @copy($meta['src'], $tmp); + $_FILES['user_upload'] = [ + 'name' => $meta['filename'], + 'type' => function_exists('mime_content_type') ? (mime_content_type($tmp) ?: 'application/octet-stream') : 'application/octet-stream', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => filesize($tmp) ?: $meta['size'], + ]; + $attachRepo->addFileFromFileRequest($attachment, 'user_upload'); + @unlink($tmp); + + $this->dlog('restore_announcements: attachment stored (ZIP)', [ + 'announcement_id' => (int)$entity->getIid(), + 'filename' => $attachment->getFilename(), + 'size' => $attachment->getSize(), + ]); } - /** - * Restore Quiz. - * - * @param int $session_id - * @param bool $respect_base_content - */ - public function restore_quizzes( - $session_id = 0, - $respect_base_content = false - ) { - if ($this->course->has_resources(RESOURCE_QUIZ)) { - $table_qui = Database::get_course_table(TABLE_QUIZ_TEST); - $table_rel = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION); - $table_doc = Database::get_course_table(TABLE_DOCUMENT); - $resources = $this->course->resources; + public function restore_quizzes($session_id = 0, $respect_base_content = false) + { + if (!$this->course->has_resources(RESOURCE_QUIZ)) { + error_log('RESTORE_QUIZ: No quiz resources in backup.'); + return; + } - foreach ($resources[RESOURCE_QUIZ] as $id => $quiz) { - if (isset($quiz->obj)) { - // For new imports - $quiz = $quiz->obj; - } else { - // For backward compatibility - $quiz->obj = $quiz; - } - - $doc = ''; - if (!empty($quiz->sound)) { - if (isset($this->course->resources[RESOURCE_DOCUMENT][$quiz->sound]) && - $this->course->resources[RESOURCE_DOCUMENT][$quiz->sound]->is_restored()) { - $sql = "SELECT path FROM $table_doc - WHERE - c_id = ".$this->destination_course_id.' AND - id = '.$resources[RESOURCE_DOCUMENT][$quiz->sound]->destination_id; - $doc = Database::query($sql); - $doc = Database::fetch_object($doc); - $doc = str_replace('/audio/', '', $doc->path); - } + $em = Database::getManager(); + $resources = $this->course->resources; + $courseEntity = api_get_course_entity($this->destination_course_id); + $sessionEntity = !empty($session_id) ? api_get_session_entity((int)$session_id) : api_get_session_entity(); + + $rewrite = function (?string $html) use ($courseEntity) { + if ($html === null || $html === false) return ''; + if (class_exists(ChamiloHelper::class) + && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { + try { + $backupRoot = $this->course->backup_path ?? ''; + return ChamiloHelper::rewriteLegacyCourseUrlsToAssets($html, $courseEntity, $backupRoot); + } catch (\Throwable $e) { + error_log('RESTORE_QUIZ: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); + return $html; } + } + return $html; + }; - if (-1 != $id) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $quiz->description = DocumentManager::replaceUrlWithNewCourseCode( - $quiz->description, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + if (empty($this->course->resources[RESOURCE_QUIZQUESTION]) + && !empty($this->course->resources['Exercise_Question'])) { + $this->course->resources[RESOURCE_QUIZQUESTION] = $this->course->resources['Exercise_Question']; + $resources = $this->course->resources; + error_log('RESTORE_QUIZ: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.'); + } - $quiz->start_time = '0000-00-00 00:00:00' == $quiz->start_time ? null : $quiz->start_time; - $quiz->end_time = '0000-00-00 00:00:00' == $quiz->end_time ? null : $quiz->end_time; + foreach ($resources[RESOURCE_QUIZ] as $id => $quizWrap) { + $quiz = isset($quizWrap->obj) ? $quizWrap->obj : $quizWrap; - global $_custom; - if (isset($_custom['exercises_clean_dates_when_restoring']) && - $_custom['exercises_clean_dates_when_restoring'] - ) { - $quiz->start_time = null; - $quiz->end_time = null; - } + $description = $rewrite($quiz->description ?? ''); + $quiz->start_time = ($quiz->start_time === '0000-00-00 00:00:00') ? null : ($quiz->start_time ?? null); + $quiz->end_time = ($quiz->end_time === '0000-00-00 00:00:00') ? null : ($quiz->end_time ?? null); - $params = [ - 'c_id' => $this->destination_course_id, - 'title' => self::DBUTF8($quiz->title), - 'description' => false === $quiz->description ? '' : self::DBUTF8($quiz->description), - 'type' => isset($quiz->quiz_type) ? (int) $quiz->quiz_type : $quiz->type, - 'random' => (int) $quiz->random, - 'active' => $quiz->active, - 'sound' => self::DBUTF8($doc), - 'max_attempt' => (int) $quiz->max_attempt, - 'results_disabled' => (int) $quiz->results_disabled, - 'access_condition' => $quiz->access_condition, - 'pass_percentage' => $quiz->pass_percentage, - 'feedback_type' => (int) $quiz->feedback_type, - 'random_answers' => (int) $quiz->random_answers, - 'random_by_category' => (int) $quiz->random_by_category, - 'review_answers' => (int) $quiz->review_answers, - 'propagate_neg' => (int) $quiz->propagate_neg, - 'text_when_finished' => (string) $quiz->text_when_finished, - 'text_when_finished_failure' => (string) $quiz->text_when_finished_failure, - 'expired_time' => (int) $quiz->expired_time, - 'start_time' => $quiz->start_time, - 'end_time' => $quiz->end_time, - 'save_correct_answers' => 0, - 'display_category_name' => 0, - 'save_correct_answers' => isset($quiz->save_correct_answers) ? $quiz->save_correct_answers : 0, - 'hide_question_title' => isset($quiz->hide_question_title) ? $quiz->hide_question_title : 0, - ]; + global $_custom; + if (!empty($_custom['exercises_clean_dates_when_restoring'])) { + $quiz->start_time = null; + $quiz->end_time = null; + } - $allow = ('true' === api_get_setting('exercise.allow_notification_setting_per_exercise')); - if ($allow) { - $params['notifications'] = isset($quiz->notifications) ? $quiz->notifications : ''; - } + if ((int)$id === -1) { + $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = -1; + error_log('RESTORE_QUIZ: Skipping virtual quiz (id=-1).'); + continue; + } - if ($respect_base_content) { - $my_session_id = $quiz->session_id; - if (!empty($quiz->session_id)) { - $my_session_id = $session_id; - } - $params['session_id'] = $my_session_id; - } else { - if (!empty($session_id)) { - $session_id = (int) $session_id; - $params['session_id'] = $session_id; - } - } - $new_id = Database::insert($table_qui, $params); + $entity = (new CQuiz()) + ->setParent($courseEntity) + ->addCourseLink( + $courseEntity, + $respect_base_content ? $sessionEntity : (!empty($session_id) ? $sessionEntity : api_get_session_entity()), + api_get_group_entity() + ) + ->setTitle((string) $quiz->title) + ->setDescription($description) + ->setType(isset($quiz->quiz_type) ? (int) $quiz->quiz_type : (int) $quiz->type) + ->setRandom((int) $quiz->random) + ->setRandomAnswers((bool) $quiz->random_answers) + ->setResultsDisabled((int) $quiz->results_disabled) + ->setMaxAttempt((int) $quiz->max_attempt) + ->setFeedbackType((int) $quiz->feedback_type) + ->setExpiredTime((int) $quiz->expired_time) + ->setReviewAnswers((int) $quiz->review_answers) + ->setRandomByCategory((int) $quiz->random_by_category) + ->setTextWhenFinished((string) ($quiz->text_when_finished ?? '')) + ->setTextWhenFinishedFailure((string) ($quiz->text_when_finished_failure ?? '')) + ->setDisplayCategoryName((int) ($quiz->display_category_name ?? 0)) + ->setSaveCorrectAnswers(isset($quiz->save_correct_answers) ? (int) $quiz->save_correct_answers : 0) + ->setPropagateNeg((int) $quiz->propagate_neg) + ->setHideQuestionTitle((bool) ($quiz->hide_question_title ?? false)) + ->setHideQuestionNumber((int) ($quiz->hide_question_number ?? 0)) + ->setStartTime(!empty($quiz->start_time) ? new \DateTime($quiz->start_time) : null) + ->setEndTime(!empty($quiz->end_time) ? new \DateTime($quiz->end_time) : null); + + if (isset($quiz->access_condition) && $quiz->access_condition !== '') { + $entity->setAccessCondition((string)$quiz->access_condition); + } + if (isset($quiz->pass_percentage) && $quiz->pass_percentage !== '' && $quiz->pass_percentage !== null) { + $entity->setPassPercentage((int)$quiz->pass_percentage); + } + if (isset($quiz->question_selection_type) && $quiz->question_selection_type !== '' && $quiz->question_selection_type !== null) { + $entity->setQuestionSelectionType((int)$quiz->question_selection_type); + } + if ('true' === api_get_setting('exercise.allow_notification_setting_per_exercise')) { + $entity->setNotifications((string)($quiz->notifications ?? '')); + } + + $em->persist($entity); + $em->flush(); + + $newQuizId = (int)$entity->getIid(); + $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = $newQuizId; + + $qCount = isset($quiz->question_ids) ? count((array)$quiz->question_ids) : 0; + error_log('RESTORE_QUIZ: Created quiz iid='.$newQuizId.' title="'.(string)$quiz->title.'" with '.$qCount.' question ids.'); - if ($new_id) { - $sql = "UPDATE $table_qui SET id = iid WHERE iid = $new_id"; - Database::query($sql); + $order = 0; + if (!empty($quiz->question_ids)) { + foreach ($quiz->question_ids as $index => $question_id) { + $qid = $this->restore_quiz_question($question_id); + if (!$qid) { + error_log('RESTORE_QUIZ: restore_quiz_question returned 0 for src_question_id='.$question_id); + continue; } - } else { - // $id = -1 identifies the fictionary test for collecting - // orphan questions. We do not store it in the database. - $new_id = -1; - } - - $this->course->resources[RESOURCE_QUIZ][$id]->destination_id = $new_id; - $order = 0; - if (!empty($quiz->question_ids)) { - foreach ($quiz->question_ids as $index => $question_id) { - $qid = $this->restore_quiz_question($question_id); - $question_order = $quiz->question_orders[$index] ?: $order; - $order++; - $sql = "INSERT IGNORE INTO $table_rel SET - c_id = ".$this->destination_course_id.", - question_id = $qid , - quiz_id = $new_id , - question_order = ".$question_order; - Database::query($sql); + + $question_order = !empty($quiz->question_orders[$index]) + ? (int)$quiz->question_orders[$index] + : $order; + + $order++; + + $questionEntity = $em->getRepository(CQuizQuestion::class)->find($qid); + if (!$questionEntity) { + error_log('RESTORE_QUIZ: Question entity not found after insert. qid='.$qid); + continue; } + + $rel = (new CQuizRelQuestion()) + ->setQuiz($entity) + ->setQuestion($questionEntity) + ->setQuestionOrder($question_order); + + $em->persist($rel); + $em->flush(); } + } else { + error_log('RESTORE_QUIZ: No questions bound to quiz src_id='.$id.' (title="'.(string)$quiz->title.'").'); } } } + /** - * Restore quiz-questions. - * - * @params int $id question id + * Restore quiz-questions. Returns new question IID. */ public function restore_quiz_question($id) { - $em = Database::getManager(); + $em = Database::getManager(); $resources = $this->course->resources; - /** @var QuizQuestion $question */ - $question = isset($resources[RESOURCE_QUIZQUESTION][$id]) ? $resources[RESOURCE_QUIZQUESTION][$id] : null; - $new_id = 0; - if (is_object($question)) { - if ($question->is_restored()) { - return $question->destination_id; - } - $table_que = Database::get_course_table(TABLE_QUIZ_QUESTION); - $table_ans = Database::get_course_table(TABLE_QUIZ_ANSWER); - $table_options = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION); + if (empty($resources[RESOURCE_QUIZQUESTION]) && !empty($resources['Exercise_Question'])) { + $resources[RESOURCE_QUIZQUESTION] = $this->course->resources[RESOURCE_QUIZQUESTION] + = $this->course->resources['Exercise_Question']; + error_log('RESTORE_QUESTION: Aliased Exercise_Question -> RESOURCE_QUIZQUESTION for restore.'); + } - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $question->description = DocumentManager::replaceUrlWithNewCourseCode( - $question->description, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + /** @var object|null $question */ + $question = $resources[RESOURCE_QUIZQUESTION][$id] ?? null; + if (!is_object($question)) { + error_log('RESTORE_QUESTION: Question not found in resources. src_id='.$id); + return 0; + } + if (method_exists($question, 'is_restored') && $question->is_restored()) { + return (int)$question->destination_id; + } - $imageNewId = ''; - if (preg_match('/^quiz-.*$/', $question->picture) && - isset($resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture]) - ) { - $imageNewId = $resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture]['destination_id']; - } else { - if (isset($resources[RESOURCE_DOCUMENT][$question->picture])) { - $documentsToRestore = $resources[RESOURCE_DOCUMENT][$question->picture]; - $imageNewId = $documentsToRestore->destination_id; + $courseEntity = api_get_course_entity($this->destination_course_id); + + $rewrite = function (?string $html) use ($courseEntity) { + if ($html === null || $html === false) return ''; + if (class_exists(ChamiloHelper::class) + && method_exists(ChamiloHelper::class, 'rewriteLegacyCourseUrlsToAssets')) { + try { + return ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)$html, $courseEntity, null); + } catch (\ArgumentCountError $e) { + return ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)$html, $courseEntity); + } catch (\Throwable $e) { + error_log('RESTORE_QUESTION: rewriteLegacyCourseUrlsToAssets failed: '.$e->getMessage()); + return $html; } } - $question->question = DocumentManager::replaceUrlWithNewCourseCode( - $question->question, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - $params = [ - 'c_id' => $this->destination_course_id, - 'question' => self::DBUTF8($question->question), - 'description' => false === $question->description ? '' : self::DBUTF8($question->description), - 'ponderation' => self::DBUTF8($question->ponderation), - 'position' => self::DBUTF8($question->position), - 'type' => self::DBUTF8($question->quiz_type), - 'picture' => self::DBUTF8($imageNewId), - 'level' => self::DBUTF8($question->level), - 'extra' => self::DBUTF8($question->extra), - ]; + return $html; + }; + + $question->description = $rewrite($question->description ?? ''); + $question->question = $rewrite($question->question ?? ''); + + $imageNewId = ''; + if (!empty($question->picture)) { + if (isset($resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture])) { + $imageNewId = (string) $resources[RESOURCE_DOCUMENT]['image_quiz'][$question->picture]['destination_id']; + } elseif (isset($resources[RESOURCE_DOCUMENT][$question->picture])) { + $imageNewId = (string) $resources[RESOURCE_DOCUMENT][$question->picture]->destination_id; + } + } - $new_id = Database::insert($table_que, $params); + $qType = (int) ($question->quiz_type ?? $question->type); + $entity = (new CQuizQuestion()) + ->setParent($courseEntity) + ->addCourseLink($courseEntity, api_get_session_entity(), api_get_group_entity()) + ->setQuestion($question->question) + ->setDescription($question->description) + ->setPonderation((float) ($question->ponderation ?? 0)) + ->setPosition((int) ($question->position ?? 1)) + ->setType($qType) + ->setPicture($imageNewId) + ->setLevel((int) ($question->level ?? 1)) + ->setExtra((string) ($question->extra ?? '')); + + $em->persist($entity); + $em->flush(); + + $new_id = (int)$entity->getIid(); + if (!$new_id) { + error_log('RESTORE_QUESTION: Failed to obtain new question iid for src_id='.$id); + return 0; + } - if ($new_id) { - $sql = "UPDATE $table_que SET id = iid WHERE iid = $new_id"; - Database::query($sql); - } else { - return 0; - } - - $correctAnswers = []; - $allAnswers = []; - $onlyAnswers = []; - - if (in_array($question->quiz_type, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE])) { - $tempAnswerList = $question->answers; - foreach ($tempAnswerList as &$value) { - $value['answer'] = DocumentManager::replaceUrlWithNewCourseCode( - $value['answer'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - } - $allAnswers = array_column($tempAnswerList, 'answer', 'id'); - } + $answers = (array)($question->answers ?? []); + error_log('RESTORE_QUESTION: Creating question src_id='.$id.' dst_iid='.$new_id.' answers_count='.count($answers)); - if (in_array($question->quiz_type, [MATCHING, MATCHING_DRAGGABLE])) { - $temp = []; - foreach ($question->answers as $index => $answer) { - $temp[$answer['position']] = $answer; - } + $isMatchingFamily = in_array($qType, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE], true); + $correctMapSrcToDst = []; // dstAnsId => srcCorrectRef + $allSrcAnswersById = []; // srcAnsId => text + $dstAnswersByIdText = []; // dstAnsId => text - foreach ($temp as $index => $answer) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $answer['answer'] = DocumentManager::replaceUrlWithNewCourseCode( - $answer['answer'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + if ($isMatchingFamily) { + foreach ($answers as $a) { + $allSrcAnswersById[$a['id']] = $rewrite($a['answer'] ?? ''); + } + } - $answer['comment'] = DocumentManager::replaceUrlWithNewCourseCode( - $answer['comment'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + foreach ($answers as $a) { + $ansText = $rewrite($a['answer'] ?? ''); + $comment = $rewrite($a['comment'] ?? ''); + + $ans = (new CQuizAnswer()) + ->setQuestion($entity) + ->setAnswer((string)$ansText) + ->setComment((string)$comment) + ->setPonderation((float)($a['ponderation'] ?? 0)) + ->setPosition((int)($a['position'] ?? 0)) + ->setHotspotCoordinates(isset($a['hotspot_coordinates']) ? (string)$a['hotspot_coordinates'] : null) + ->setHotspotType(isset($a['hotspot_type']) ? (string)$a['hotspot_type'] : null); + + if (isset($a['correct']) && $a['correct'] !== '' && $a['correct'] !== null) { + $ans->setCorrect((int)$a['correct']); + } - $quizAnswer = new CQuizAnswer(); - $quizAnswer - ->setCId($this->destination_course_id) - ->setQuestionId($new_id) - ->setAnswer(self::DBUTF8($answer['answer'])) - ->setCorrect($answer['correct']) - ->setComment(false === $answer['comment'] ? '' : self::DBUTF8($answer['comment'])) - ->setPonderation($answer['ponderation']) - ->setPosition($answer['position']) - ->setHotspotCoordinates($answer['hotspot_coordinates']) - ->setHotspotType($answer['hotspot_type']); - - $em->persist($quizAnswer); - $em->flush(); + $em->persist($ans); + $em->flush(); - $answerId = $quizAnswer->getIid(); + if ($isMatchingFamily) { + $correctMapSrcToDst[(int)$ans->getIid()] = $a['correct'] ?? null; + $dstAnswersByIdText[(int)$ans->getIid()] = $ansText; + } + } - if ($answerId) { - $correctAnswers[$answerId] = $answer['correct']; - $onlyAnswers[$answerId] = $answer['answer']; + if ($isMatchingFamily && $correctMapSrcToDst) { + foreach ($entity->getAnswers() as $dstAns) { + $dstAid = (int)$dstAns->getIid(); + $srcRef = $correctMapSrcToDst[$dstAid] ?? null; + if ($srcRef === null) continue; + + if (isset($allSrcAnswersById[$srcRef])) { + $needle = $allSrcAnswersById[$srcRef]; + $newDst = null; + foreach ($dstAnswersByIdText as $candId => $txt) { + if ($txt === $needle) { $newDst = $candId; break; } + } + if ($newDst !== null) { + $dstAns->setCorrect((int)$newDst); + $em->persist($dstAns); } } - } else { - foreach ($question->answers as $index => $answer) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $answer['answer'] = DocumentManager::replaceUrlWithNewCourseCode( - $answer['answer'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + } + $em->flush(); + } - $answer['comment'] = DocumentManager::replaceUrlWithNewCourseCode( - $answer['comment'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + if (defined('MULTIPLE_ANSWER_TRUE_FALSE') && MULTIPLE_ANSWER_TRUE_FALSE === $qType) { + $newOptByOld = []; + if (isset($question->question_options) && is_iterable($question->question_options)) { + foreach ($question->question_options as $optWrap) { + $opt = $optWrap->obj ?? $optWrap; + $optEntity = (new CQuizQuestionOption()) + ->setQuestion($entity) + ->setTitle((string)$opt->name) + ->setPosition((int)$opt->position); + $em->persist($optEntity); + $em->flush(); + $newOptByOld[$opt->id] = (int)$optEntity->getIid(); + } + foreach ($entity->getAnswers() as $dstAns) { + $corr = $dstAns->getCorrect(); + if ($corr !== null && isset($newOptByOld[$corr])) { + $dstAns->setCorrect((int)$newOptByOld[$corr]); + $em->persist($dstAns); + } + } + $em->flush(); + } + } - $params = [ - 'c_id' => $this->destination_course_id, - 'question_id' => $new_id, - 'answer' => self::DBUTF8($answer['answer']), - 'correct' => $answer['correct'], - 'comment' => false === $answer['comment'] ? '' : self::DBUTF8($answer['comment']), - 'ponderation' => $answer['ponderation'], - 'position' => $answer['position'], - 'hotspot_coordinates' => $answer['hotspot_coordinates'], - 'hotspot_type' => $answer['hotspot_type'], - 'id_auto' => 0, - 'destination' => '', - ]; + $this->course->resources[RESOURCE_QUIZQUESTION][$id]->destination_id = $new_id; - $answerId = Database::insert($table_ans, $params); + return $new_id; + } - if ($answerId) { - $sql = "UPDATE $table_ans SET id = iid, id_auto = iid WHERE iid = $answerId"; - Database::query($sql); - } + public function restore_surveys($sessionId = 0) + { + if (!$this->course->has_resources(RESOURCE_SURVEY)) { + $this->debug && error_log('COURSE_DEBUG: restore_surveys: no survey resources in backup.'); + return; + } - $correctAnswers[$answerId] = $answer['correct']; - $onlyAnswers[$answerId] = $answer['answer']; - } - } + $em = Database::getManager(); + $surveyRepo = Container::getSurveyRepository(); + $courseEntity = api_get_course_entity($this->destination_course_id); + $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; - // Current course id - $course_id = api_get_course_int_id(); + $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + if ($backupRoot === '') { + $this->debug && error_log('COURSE_DEBUG: restore_surveys: backupRoot empty; URL rewriting may be partial.'); + } - // Moving quiz_question_options - if (MULTIPLE_ANSWER_TRUE_FALSE == $question->quiz_type) { - $question_option_list = Question::readQuestionOption($id, $course_id); + $resources = $this->course->resources; - // Question copied from the current platform - if ($question_option_list) { - $old_option_ids = []; - foreach ($question_option_list as $item) { - $old_id = $item['iid']; - unset($item['iid']); - if (isset($item['iid'])) { - unset($item['iid']); - } - $item['question_id'] = $new_id; - $item['c_id'] = $this->destination_course_id; - $question_option_id = Database::insert($table_options, $item); - if ($question_option_id) { - $old_option_ids[$old_id] = $question_option_id; - $sql = "UPDATE $table_options SET id = iid WHERE iid = $question_option_id"; - Database::query($sql); - } - } - if ($old_option_ids) { - $new_answers = Database::select( - 'iid, correct', - $table_ans, - [ - 'WHERE' => [ - 'question_id = ? AND c_id = ? ' => [ - $new_id, - $this->destination_course_id, - ], - ], - ] - ); + foreach ($resources[RESOURCE_SURVEY] as $legacySurveyId => $surveyObj) { + try { + $code = (string)($surveyObj->code ?? ''); + $lang = (string)($surveyObj->lang ?? ''); - foreach ($new_answers as $answer_item) { - $params = []; - $params['correct'] = $old_option_ids[$answer_item['correct']]; - Database::update( - $table_ans, - $params, - [ - 'iid = ? AND c_id = ? AND question_id = ? ' => [ - $answer_item['iid'], - $this->destination_course_id, - $new_id, - ], - ], - false - ); - } + $title = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->title ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->title ?? ''); + $subtitle = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->subtitle ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->subtitle ?? ''); + $intro = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->intro ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->intro ?? ''); + $surveyThanks = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($surveyObj->surveythanks ?? ''), $courseEntity, $backupRoot) ?? (string)($surveyObj->surveythanks ?? ''); + + $onePerPage = !empty($surveyObj->one_question_per_page); + $shuffle = isset($surveyObj->shuffle) ? (bool)$surveyObj->shuffle : (!empty($surveyObj->suffle)); + $anonymous = (string)((int)($surveyObj->anonymous ?? 0)); + + try { $creationDate = !empty($surveyObj->creation_date) ? new \DateTime((string)$surveyObj->creation_date) : new \DateTime(); } catch (\Throwable) { $creationDate = new \DateTime(); } + try { $availFrom = !empty($surveyObj->avail_from) ? new \DateTime((string)$surveyObj->avail_from) : null; } catch (\Throwable) { $availFrom = null; } + try { $availTill = !empty($surveyObj->avail_till) ? new \DateTime((string)$surveyObj->avail_till) : null; } catch (\Throwable) { $availTill = null; } + + $visibleResults = isset($surveyObj->visible_results) ? (int)$surveyObj->visible_results : null; + $displayQuestionNumber = isset($surveyObj->display_question_number) ? (bool)$surveyObj->display_question_number : true; + + $existing = null; + try { + if (method_exists($surveyRepo, 'findOneByCodeAndLangInCourse')) { + $existing = $surveyRepo->findOneByCodeAndLangInCourse($courseEntity, $code, $lang); + } else { + $existing = $surveyRepo->findOneBy(['code' => $code, 'lang' => $lang]); } - } else { - $new_options = []; - if (isset($question->question_options)) { - foreach ($question->question_options as $obj) { - $item = []; - $item['question_id'] = $new_id; - $item['c_id'] = $this->destination_course_id; - $item['name'] = $obj->obj->name; - $item['position'] = $obj->obj->position; - $question_option_id = Database::insert($table_options, $item); - - if ($question_option_id) { - $new_options[$obj->obj->id] = $question_option_id; - $sql = "UPDATE $table_options SET id = iid WHERE iid = $question_option_id"; - Database::query($sql); + } catch (\Throwable $e) { + $this->debug && error_log('COURSE_DEBUG: restore_surveys: duplicate check skipped: '.$e->getMessage()); + } + + if ($existing instanceof CSurvey) { + switch ($this->file_option) { + case FILE_SKIP: + $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int)$existing->getIid(); + $this->debug && error_log("COURSE_DEBUG: restore_surveys: survey exists code='$code' (skip)."); + continue 2; + + case FILE_RENAME: + $base = $code.'_'; + $i = 1; + $try = $base.$i; + while (!$this->is_survey_code_available($try)) { + $try = $base.(++$i); } - } + $code = $try; + $this->debug && error_log("COURSE_DEBUG: restore_surveys: renaming to '$code'."); + break; - foreach ($correctAnswers as $answer_id => $correct_answer) { - $params = []; - $params['correct'] = isset($new_options[$correct_answer]) ? $new_options[$correct_answer] : ''; - Database::update( - $table_ans, - $params, - [ - 'id = ? AND c_id = ? AND question_id = ? ' => [ - $answer_id, - $this->destination_course_id, - $new_id, - ], - ], - false - ); - } + case FILE_OVERWRITE: + \SurveyManager::deleteSurvey($existing); + $em->flush(); + $this->debug && error_log("COURSE_DEBUG: restore_surveys: existing survey deleted (overwrite)."); + break; + + default: + $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = (int)$existing->getIid(); + continue 2; } } - } - // Fix correct answers - if (in_array($question->quiz_type, [DRAGGABLE, MATCHING, MATCHING_DRAGGABLE])) { - foreach ($correctAnswers as $answer_id => $correct_answer) { - $params = []; + // --- Create survey --- + $newSurvey = new CSurvey(); + $newSurvey + ->setCode($code) + ->setTitle($title) + ->setSubtitle($subtitle) + ->setLang($lang) + ->setAvailFrom($availFrom) + ->setAvailTill($availTill) + ->setIsShared((string)($surveyObj->is_shared ?? '0')) + ->setTemplate((string)($surveyObj->template ?? 'template')) + ->setIntro($intro) + ->setSurveythanks($surveyThanks) + ->setCreationDate($creationDate) + ->setInvited(0) + ->setAnswered(0) + ->setInviteMail((string)($surveyObj->invite_mail ?? '')) + ->setReminderMail((string)($surveyObj->reminder_mail ?? '')) + ->setOneQuestionPerPage($onePerPage) + ->setShuffle($shuffle) + ->setAnonymous($anonymous) + ->setDisplayQuestionNumber($displayQuestionNumber); + + if (method_exists($newSurvey, 'setParent')) { + $newSurvey->setParent($courseEntity); + } + $newSurvey->addCourseLink($courseEntity, $sessionEntity); + + if (method_exists($surveyRepo, 'create')) { + $surveyRepo->create($newSurvey); + } else { + $em->persist($newSurvey); + $em->flush(); + } - if (isset($allAnswers[$correct_answer])) { - $correct = ''; - foreach ($onlyAnswers as $key => $value) { - if ($value == $allAnswers[$correct_answer]) { - $correct = $key; + $newId = (int)$newSurvey->getIid(); + $this->course->resources[RESOURCE_SURVEY][$legacySurveyId]->destination_id = $newId; - break; - } + // --- Restore questions --- + $questionIds = is_array($surveyObj->question_ids ?? null) ? $surveyObj->question_ids : []; + if (empty($questionIds) && !empty($resources[RESOURCE_SURVEYQUESTION])) { + foreach ($resources[RESOURCE_SURVEYQUESTION] as $qid => $qWrap) { + $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; + if ((int)($q->survey_id ?? 0) === (int)$legacySurveyId) { + $questionIds[] = (int)$qid; } - - $params['correct'] = $correct; - Database::update( - $table_ans, - $params, - [ - 'id = ? AND c_id = ? AND question_id = ? ' => [ - $answer_id, - $this->destination_course_id, - $new_id, - ], - ] - ); } } - } - $this->course->resources[RESOURCE_QUIZQUESTION][$id]->destination_id = $new_id; - } + foreach ($questionIds as $legacyQid) { + $this->restore_survey_question((int)$legacyQid, $newId); + } - return $new_id; + $this->debug && error_log("COURSE_DEBUG: restore_surveys: created survey iid={$newId}, questions=".count($questionIds)); + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_surveys: failed: '.$e->getMessage()); + } + } } + /** - * @todo : add session id when used for session + * Restore survey-questions (legacy signature). $survey_id is the NEW iid. */ - public function restore_test_category($session_id, $respect_base_content, $destination_course_code) + public function restore_survey_question($id, $survey_id) { - if (!empty($session_id)) { - return false; + $resources = $this->course->resources; + $qWrap = $resources[RESOURCE_SURVEYQUESTION][$id] ?? null; + + if (!$qWrap || !is_object($qWrap)) { + $this->debug && error_log("COURSE_DEBUG: restore_survey_question: legacy question $id not found."); + return 0; + } + if (method_exists($qWrap, 'is_restored') && $qWrap->is_restored()) { + return $qWrap->destination_id; } - $destinationCourseId = $this->destination_course_info['real_id']; - // Let's restore the categories - $categoryOldVsNewList = []; // used to build the quiz_question_rel_category table - if ($this->course->has_resources(RESOURCE_TEST_CATEGORY)) { - $resources = $this->course->resources; - foreach ($resources[RESOURCE_TEST_CATEGORY] as $id => $courseCopyTestCategory) { - $categoryOldVsNewList[$courseCopyTestCategory->source_id] = $id; - // check if this test_category already exist in the destination BDD - // do not Database::escape_string $title and $description, it will be done later - $title = $courseCopyTestCategory->title; - $description = $courseCopyTestCategory->description; - if (TestCategory::categoryTitleExists($title, $destinationCourseId)) { - switch ($this->file_option) { - case FILE_SKIP: - //Do nothing - break; - case FILE_RENAME: - $new_title = $title.'_'; - while (TestCategory::categoryTitleExists($new_title, $destinationCourseId)) { - $new_title .= '_'; - } - $test_category = new TestCategory(); - $test_category->name = $new_title; - $test_category->description = $description; - $new_id = $test_category->save($destinationCourseId); - $categoryOldVsNewList[$courseCopyTestCategory->source_id] = $new_id; - break; - case FILE_OVERWRITE: - // get category from source - $destinationCategoryId = TestCategory::get_category_id_for_title( - $title, - $destinationCourseId - ); - if ($destinationCategoryId) { - $my_cat = new TestCategory(); - $my_cat = $my_cat->getCategory($destinationCategoryId, $destinationCourseId); - $my_cat->name = $title; - $my_cat->description = $description; - $my_cat->modifyCategory($destinationCourseId); - $categoryOldVsNewList[$courseCopyTestCategory->source_id] = $destinationCategoryId; - } + $surveyRepo = Container::getSurveyRepository(); + $em = Database::getManager(); + $courseEntity = api_get_course_entity($this->destination_course_id); - break; - } - } else { - // create a new test_category - $test_category = new TestCategory(); - $test_category->name = $title; - $test_category->description = $description; - $new_id = $test_category->save($destinationCourseId); - $categoryOldVsNewList[$courseCopyTestCategory->source_id] = $new_id; - } - $this->course->resources[RESOURCE_TEST_CATEGORY][$id]->destination_id = $categoryOldVsNewList[$courseCopyTestCategory->source_id]; - } - } - - // lets check if quizzes-question are restored too, - // to redo the link between test_category and quizzes question for questions restored - // we can use the source_id field - // question source_id => category source_id - if ($this->course->has_resources(RESOURCE_QUIZQUESTION)) { - // check the category number of each question restored - if (!empty($resources[RESOURCE_QUIZQUESTION])) { - foreach ($resources[RESOURCE_QUIZQUESTION] as $id => $courseCopyQuestion) { - $newQuestionId = $resources[RESOURCE_QUIZQUESTION][$id]->destination_id; - $questionCategoryId = $courseCopyQuestion->question_category; - if ($newQuestionId > 0 && - $questionCategoryId > 0 && - isset($categoryOldVsNewList[$questionCategoryId]) - ) { - TestCategory::addCategoryToQuestion( - $categoryOldVsNewList[$questionCategoryId], - $newQuestionId, - $destinationCourseId - ); - } + $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + + $survey = $surveyRepo->find((int)$survey_id); + if (!$survey instanceof CSurvey) { + $this->debug && error_log("COURSE_DEBUG: restore_survey_question: target survey $survey_id not found."); + return 0; + } + + $q = (isset($qWrap->obj) && is_object($qWrap->obj)) ? $qWrap->obj : $qWrap; + + // Rewrite HTML + $questionText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($q->survey_question ?? ''), $courseEntity, $backupRoot) ?? (string)($q->survey_question ?? ''); + $commentText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($q->survey_question_comment ?? ''), $courseEntity, $backupRoot) ?? (string)($q->survey_question_comment ?? ''); + + try { + $question = new CSurveyQuestion(); + $question + ->setSurvey($survey) + ->setSurveyQuestion($questionText) + ->setSurveyQuestionComment($commentText) + ->setType((string)($q->survey_question_type ?? $q->type ?? 'open')) + ->setDisplay((string)($q->display ?? 'vertical')) + ->setSort((int)($q->sort ?? 0)); + + if (isset($q->shared_question_id) && method_exists($question, 'setSharedQuestionId')) { + $question->setSharedQuestionId((int)$q->shared_question_id); + } + if (isset($q->max_value) && method_exists($question, 'setMaxValue')) { + $question->setMaxValue((int)$q->max_value); + } + if (isset($q->is_required)) { + if (method_exists($question, 'setIsMandatory')) { + $question->setIsMandatory((bool)$q->is_required); + } elseif (method_exists($question, 'setIsRequired')) { + $question->setIsRequired((bool)$q->is_required); } } + + $em->persist($question); + $em->flush(); + + // Options (value NOT NULL: default to 0 if missing) + $answers = is_array($q->answers ?? null) ? $q->answers : []; + foreach ($answers as $idx => $answer) { + $optText = ChamiloHelper::rewriteLegacyCourseUrlsToAssets((string)($answer['option_text'] ?? ''), $courseEntity, $backupRoot) ?? (string)($answer['option_text'] ?? ''); + $value = isset($answer['value']) && $answer['value'] !== null ? (int)$answer['value'] : 0; + $sort = (int)($answer['sort'] ?? ($idx + 1)); + + $opt = new CSurveyQuestionOption(); + $opt + ->setSurvey($survey) + ->setQuestion($question) + ->setOptionText($optText) + ->setSort($sort) + ->setValue($value); + + $em->persist($opt); + } + $em->flush(); + + $this->course->resources[RESOURCE_SURVEYQUESTION][$id]->destination_id = (int)$question->getIid(); + + return (int)$question->getIid(); + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_survey_question: failed: '.$e->getMessage()); + return 0; + } + } + + + public function is_survey_code_available($survey_code) + { + $survey_code = (string)$survey_code; + $surveyRepo = Container::getSurveyRepository(); + + try { + $hit = $surveyRepo->findOneBy(['code' => $survey_code]); + return $hit ? false : true; + } catch (\Throwable $e) { + $this->debug && error_log('COURSE_DEBUG: is_survey_code_available: fallback failed: '.$e->getMessage()); + return true; } } /** - * Restore surveys. - * - * @param int $sessionId Optional. The session id + * @param int $sessionId + * @param bool $baseContent */ - public function restore_surveys($sessionId = 0) + public function restore_learnpath_category(int $sessionId = 0, bool $baseContent = false): void { - $sessionId = (int) $sessionId; - if ($this->course->has_resources(RESOURCE_SURVEY)) { - $table_sur = Database::get_course_table(TABLE_SURVEY); - $table_que = Database::get_course_table(TABLE_SURVEY_QUESTION); - $table_ans = Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION); - $resources = $this->course->resources; - foreach ($resources[RESOURCE_SURVEY] as $id => $survey) { - $sql = 'SELECT survey_id FROM '.$table_sur.' - WHERE - c_id = '.$this->destination_course_id.' AND - code = "'.self::DBUTF8escapestring($survey->code).'" AND - lang = "'.self::DBUTF8escapestring($survey->lang).'" '; - - $result_check = Database::query($sql); - - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $survey->title = DocumentManager::replaceUrlWithNewCourseCode( - $survey->title, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - - $survey->subtitle = DocumentManager::replaceUrlWithNewCourseCode( - $survey->subtitle, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + $reuseExisting = false; + if (isset($this->tool_copy_settings['learnpath_category']['reuse_existing']) && + true === $this->tool_copy_settings['learnpath_category']['reuse_existing']) { + $reuseExisting = true; + } - $survey->intro = DocumentManager::replaceUrlWithNewCourseCode( - $survey->intro, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + if (!$this->course->has_resources(RESOURCE_LEARNPATH_CATEGORY)) { + return; + } - $survey->surveythanks = DocumentManager::replaceUrlWithNewCourseCode( - $survey->surveythanks, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + $tblLpCategory = Database::get_course_table(TABLE_LP_CATEGORY); + $resources = $this->course->resources; - $params = [ - 'c_id' => $this->destination_course_id, - 'code' => self::DBUTF8($survey->code), - 'title' => false === $survey->title ? '' : self::DBUTF8($survey->title), - 'subtitle' => false === $survey->subtitle ? '' : self::DBUTF8($survey->subtitle), - 'author' => self::DBUTF8($survey->author), - 'lang' => self::DBUTF8($survey->lang), - 'avail_from' => self::DBUTF8($survey->avail_from), - 'avail_till' => self::DBUTF8($survey->avail_till), - 'is_shared' => self::DBUTF8($survey->is_shared), - 'template' => self::DBUTF8($survey->template), - 'intro' => false === $survey->intro ? '' : self::DBUTF8($survey->intro), - 'surveythanks' => false === $survey->surveythanks ? '' : self::DBUTF8($survey->surveythanks), - 'creation_date' => self::DBUTF8($survey->creation_date), - 'invited' => '0', - 'answered' => '0', - 'invite_mail' => self::DBUTF8($survey->invite_mail), - 'reminder_mail' => self::DBUTF8($survey->reminder_mail), - 'session_id' => $sessionId, - 'one_question_per_page' => isset($survey->one_question_per_page) ? $survey->one_question_per_page : 0, - 'shuffle' => isset($survey->suffle) ? $survey->suffle : 0, - ]; + /** @var LearnPathCategory $item */ + foreach ($resources[RESOURCE_LEARNPATH_CATEGORY] as $id => $item) { + /** @var CLpCategory|null $lpCategory */ + $lpCategory = $item->object; - // An existing survey exists with the same code and the same language - if (1 == Database::num_rows($result_check)) { - switch ($this->file_option) { - case FILE_SKIP: - //Do nothing - break; - case FILE_RENAME: - $survey_code = $survey->code.'_'; - $i = 1; - $temp_survey_code = $survey_code.$i; - while (!$this->is_survey_code_available($temp_survey_code)) { - $temp_survey_code = $survey_code.++$i; - } - $survey_code = $temp_survey_code; - - $params['code'] = $survey_code; - $new_id = Database::insert($table_sur, $params); - if ($new_id) { - $sql = "UPDATE $table_sur SET survey_id = iid WHERE iid = $new_id"; - Database::query($sql); - - $this->course->resources[RESOURCE_SURVEY][$id]->destination_id = $new_id; - foreach ($survey->question_ids as $index => $question_id) { - $qid = $this->restore_survey_question($question_id, $new_id); - $sql = "UPDATE $table_que SET survey_id = $new_id - WHERE c_id = ".$this->destination_course_id." AND question_id = $qid"; - Database::query($sql); - $sql = "UPDATE $table_ans SET survey_id = $new_id - WHERE c_id = ".$this->destination_course_id." AND question_id = $qid"; - Database::query($sql); - } - } + if (!$lpCategory) { + continue; + } - break; - case FILE_OVERWRITE: - // Delete the existing survey with the same code and language and - // import the one of the source course - // getting the information of the survey (used for when the survey is shared) - $sql = "SELECT * FROM $table_sur - WHERE - c_id = ".$this->destination_course_id." AND - survey_id='".self::DBUTF8escapestring(Database::result($result_check, 0, 0))."'"; - $result = Database::query($sql); - $survey_data = Database::fetch_assoc($result); - - // if the survey is shared => also delete the shared content - if (isset($survey_data['survey_share']) && is_numeric($survey_data['survey_share'])) { - SurveyManager::delete_survey( - $survey_data['survey_share'], - true, - $this->destination_course_id - ); - } - SurveyManager::delete_survey( - $survey_data['survey_id'], - false, - $this->destination_course_id - ); + $title = trim($lpCategory->getTitle()); + if ($title === '') { + continue; + } - // Insert the new source survey - $new_id = Database::insert($table_sur, $params); + $categoryId = 0; + + $existing = Database::select( + 'iid', + $tblLpCategory, + [ + 'WHERE' => [ + 'c_id = ? AND name = ?' => [$this->destination_course_id, $title], + ], + ], + 'first' + ); - if ($new_id) { - $sql = "UPDATE $table_sur SET survey_id = iid WHERE iid = $new_id"; - Database::query($sql); + if ($reuseExisting && !empty($existing) && !empty($existing['iid'])) { + $categoryId = (int) $existing['iid']; + } else { + $values = [ + 'c_id' => $this->destination_course_id, + 'name' => $title, + ]; - $this->course->resources[RESOURCE_SURVEY][$id]->destination_id = $new_id; - foreach ($survey->question_ids as $index => $question_id) { - $qid = $this->restore_survey_question( - $question_id, - $new_id - ); - $sql = "UPDATE $table_que SET survey_id = $new_id - WHERE c_id = ".$this->destination_course_id." AND question_id = $qid"; - Database::query($sql); - $sql = "UPDATE $table_ans SET survey_id = $new_id - WHERE c_id = ".$this->destination_course_id." AND question_id = $qid"; - Database::query($sql); - } - } + $categoryId = (int) learnpath::createCategory($values); + } - break; - default: - break; - } - } else { - // No existing survey with the same language and the same code, we just copy the survey - $new_id = Database::insert($table_sur, $params); - - if ($new_id) { - $sql = "UPDATE $table_sur SET survey_id = iid WHERE iid = $new_id"; - Database::query($sql); - - $this->course->resources[RESOURCE_SURVEY][$id]->destination_id = $new_id; - foreach ($survey->question_ids as $index => $question_id) { - $qid = $this->restore_survey_question( - $question_id, - $new_id - ); - $sql = "UPDATE $table_que SET survey_id = $new_id - WHERE c_id = ".$this->destination_course_id." AND question_id = $qid"; - Database::query($sql); - $sql = "UPDATE $table_ans SET survey_id = $new_id - WHERE c_id = ".$this->destination_course_id." AND question_id = $qid"; - Database::query($sql); - } - } - } + if ($categoryId > 0) { + $this->course->resources[RESOURCE_LEARNPATH_CATEGORY][$id]->destination_id = $categoryId; } } } /** - * Check availability of a survey code. - * - * @param string $survey_code + * Zip a SCORM folder (must contain imsmanifest.xml) into a temp ZIP. + * Returns absolute path to the temp ZIP or null on error. */ - public function is_survey_code_available($survey_code): bool + private function zipScormFolder(string $folderAbs): ?string { - $table_sur = Database::get_course_table(TABLE_SURVEY); - $sql = "SELECT * FROM $table_sur - WHERE - c_id = ".$this->destination_course_id." AND - code = '".self::DBUTF8escapestring($survey_code)."'"; - $result = Database::query($sql); - if (Database::num_rows($result) > 0) { - return false; - } else { - return true; + $folderAbs = rtrim($folderAbs, '/'); + $manifest = $folderAbs.'/imsmanifest.xml'; + if (!is_file($manifest)) { + error_log("SCORM ZIPPER: 'imsmanifest.xml' not found in folder: $folderAbs"); + return null; + } + + $tmpZip = sys_get_temp_dir().'/scorm_'.uniqid('', true).'.zip'; + + try { + $zip = new ZipFile(); + // Put folder contents at the ZIP root – important for SCORM imports + $zip->addDirRecursive($folderAbs, ''); + $zip->saveAsFile($tmpZip); + $zip->close(); + } catch (\Throwable $e) { + error_log("SCORM ZIPPER: Failed to create temp zip: ".$e->getMessage()); + return null; + } + + if (!is_file($tmpZip) || filesize($tmpZip) === 0) { + @unlink($tmpZip); + error_log("SCORM ZIPPER: Temp zip is empty or missing: $tmpZip"); + return null; } + + return $tmpZip; } /** - * Restore survey-questions. + * Find a SCORM package for a given LP. + * It returns ['zip' => , 'temp' => true if zip is temporary]. * - * @param int $id - * @param string $survey_id + * Search order: + * 1) resources[SCORM] entries bound to this LP (zip or path). + * - If 'path' is a folder containing imsmanifest.xml, it will be zipped on the fly. + * 2) Heuristics: scan typical folders for *.zip + * 3) Heuristics: scan backup recursively for an imsmanifest.xml, then zip that folder. */ - public function restore_survey_question($id, $survey_id) + private function findScormPackageForLp(int $srcLpId): array { - $resources = $this->course->resources; - $question = $resources[RESOURCE_SURVEYQUESTION][$id]; - $new_id = 0; + $out = ['zip' => null, 'temp' => false]; + $base = rtrim($this->course->backup_path, '/'); + + // 1) Direct mapping from SCORM bucket + if (!empty($this->course->resources[RESOURCE_SCORM]) && is_array($this->course->resources[RESOURCE_SCORM])) { + foreach ($this->course->resources[RESOURCE_SCORM] as $sc) { + $src = isset($sc->source_lp_id) ? (int) $sc->source_lp_id : 0; + $dst = isset($sc->lp_id_dest) ? (int) $sc->lp_id_dest : 0; + $match = ($src && $src === $srcLpId); + + if ( + !$match && + $dst && + !empty($this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id) + ) { + $match = ($dst === (int) $this->course->resources[RESOURCE_LEARNPATH][$srcLpId]->destination_id); + } + if (!$match) { continue; } - if (is_object($question)) { - if ($question->is_restored()) { - return $question->destination_id; - } - $table_que = Database::get_course_table(TABLE_SURVEY_QUESTION); - $table_ans = Database::get_course_table(TABLE_SURVEY_QUESTION_OPTION); + $cands = []; + if (!empty($sc->zip)) { $cands[] = $base.'/'.ltrim((string) $sc->zip, '/'); } + if (!empty($sc->path)) { $cands[] = $base.'/'.ltrim((string) $sc->path, '/'); } - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $question->survey_question = DocumentManager::replaceUrlWithNewCourseCode( - $question->survey_question, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + foreach ($cands as $abs) { + if (is_file($abs) && is_readable($abs)) { + $out['zip'] = $abs; + $out['temp'] = false; + return $out; + } + if (is_dir($abs) && is_readable($abs)) { + $tmp = $this->zipScormFolder($abs); + if ($tmp) { + $out['zip'] = $tmp; + $out['temp'] = true; + return $out; + } + } + } + } + } - $params = [ - 'c_id' => $this->destination_course_id, - 'survey_id' => self::DBUTF8($survey_id), - 'survey_question' => false === $question->survey_question ? '' : self::DBUTF8($question->survey_question), - 'survey_question_comment' => self::DBUTF8($question->survey_question_comment), - 'type' => self::DBUTF8($question->survey_question_type), - 'display' => self::DBUTF8($question->display), - 'sort' => self::DBUTF8($question->sort), - 'shared_question_id' => self::DBUTF8($question->shared_question_id), - 'max_value' => self::DBUTF8($question->max_value), - ]; - if (isset($question->is_required)) { - $params['is_required'] = $question->is_required; - } - - $new_id = Database::insert($table_que, $params); - if ($new_id) { - $sql = "UPDATE $table_que SET question_id = iid WHERE iid = $new_id"; - Database::query($sql); - - foreach ($question->answers as $index => $answer) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $answer['option_text'] = DocumentManager::replaceUrlWithNewCourseCode( - $answer['option_text'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + // 2) Heuristic: typical folders with *.zip + foreach (['/scorm','/document/scorm','/documents/scorm'] as $dir) { + $full = $base.$dir; + if (!is_dir($full)) { continue; } + $glob = glob($full.'/*.zip') ?: []; + if (!empty($glob)) { + $out['zip'] = $glob[0]; + $out['temp'] = false; + return $out; + } + } - $params = [ - 'c_id' => $this->destination_course_id, - 'question_id' => $new_id, - 'option_text' => false === $answer['option_text'] ? '' : self::DBUTF8($answer['option_text']), - 'sort' => $answer['sort'], - 'survey_id' => self::DBUTF8($survey_id), - ]; - $answerId = Database::insert($table_ans, $params); - if ($answerId) { - $sql = "UPDATE $table_ans SET question_option_id = iid - WHERE iid = $answerId"; - Database::query($sql); + // 3) Heuristic: look for imsmanifest.xml anywhere, then zip that folder + $riiFlags = \FilesystemIterator::SKIP_DOTS; + try { + $rii = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($base, $riiFlags), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($rii as $f) { + if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') { + $folder = $f->getPath(); + $tmp = $this->zipScormFolder($folder); + if ($tmp) { + $out['zip'] = $tmp; + $out['temp'] = true; + return $out; } } - $this->course->resources[RESOURCE_SURVEYQUESTION][$id]->destination_id = $new_id; } + } catch (\Throwable $e) { + error_log("SCORM FINDER: Recursive scan failed: ".$e->getMessage()); } - return $new_id; + return $out; } /** - * @param int $sessionId - * @param bool $baseContent + * Restore SCORM ZIPs under Documents (Learning paths) for traceability. + * Accepts real zips and on-the-fly temporary ones (temp will be deleted after upload). */ - public function restore_learnpath_category($sessionId = 0, $baseContent = false) + public function restore_scorm_documents(): void { - $reuseExisting = false; + $logp = 'RESTORE_SCORM_ZIP: '; - if (isset($this->tool_copy_settings['learnpath_category']) && - isset($this->tool_copy_settings['learnpath_category']['reuse_existing']) && - true === $this->tool_copy_settings['learnpath_category']['reuse_existing'] - ) { - $reuseExisting = true; - } + $getBucket = function(string $type) { + if (!empty($this->course->resources[$type]) && is_array($this->course->resources[$type])) { + return $this->course->resources[$type]; + } + foreach ($this->course->resources ?? [] as $k => $v) { + if (is_string($k) && strtolower($k) === strtolower($type) && is_array($v)) { + return $v; + } + } + return []; + }; - $tblLpCategory = Database::get_course_table(TABLE_LP_CATEGORY); + /** @var \Chamilo\CourseBundle\Repository\CDocumentRepository $docRepo */ + $docRepo = Container::getDocumentRepository(); + $em = Database::getManager(); - if ($this->course->has_resources(RESOURCE_LEARNPATH_CATEGORY)) { - $resources = $this->course->resources; - /** @var LearnPathCategory $item */ - foreach ($resources[RESOURCE_LEARNPATH_CATEGORY] as $id => $item) { - /** @var CLpCategory $lpCategory */ - $lpCategory = $item->object; - - if ($lpCategory) { - $categoryId = 0; - - $existingLpCategory = Database::select( - 'iid', - $tblLpCategory, - [ - 'WHERE' => [ - 'c_id = ? AND name = ?' => [$this->destination_course_id, $lpCategory->getTitle()], - ], - ], - 'first' - ); + $courseInfo = $this->destination_course_info; + if (empty($courseInfo) || empty($courseInfo['real_id'])) { error_log($logp.'missing courseInfo/real_id'); return; } - if ($reuseExisting && !empty($existingLpCategory)) { - $categoryId = $existingLpCategory['iid']; - } else { - $values = [ - 'c_id' => $this->destination_course_id, - 'name' => $lpCategory->getTitle(), - ]; - $categoryId = learnpath::createCategory($values); - } + $courseEntity = api_get_course_entity((int) $courseInfo['real_id']); + if (!$courseEntity) { error_log($logp.'api_get_course_entity failed'); return; } - if ($categoryId) { - $this->course->resources[RESOURCE_LEARNPATH_CATEGORY][$id]->destination_id = $categoryId; - } + $sid = property_exists($this, 'current_session_id') ? (int) $this->current_session_id : 0; + $session = api_get_session_entity($sid); + + $entries = []; + + // A) direct SCORM bucket + $scormBucket = $getBucket(RESOURCE_SCORM); + foreach ($scormBucket as $sc) { $entries[] = $sc; } + + // B) also try LPs that are SCORM + $lpBucket = $getBucket(RESOURCE_LEARNPATH); + foreach ($lpBucket as $srcLpId => $lpObj) { + $lpType = (int)($lpObj->lp_type ?? $lpObj->type ?? 1); + if ($lpType === CLp::SCORM_TYPE) { + $entries[] = (object)[ + 'source_lp_id' => (int)$srcLpId, + 'lp_id_dest' => (int)($lpObj->destination_id ?? 0), + ]; + } + } + + error_log($logp.'entries='.count($entries)); + if (empty($entries)) { return; } + + $lpTop = $docRepo->ensureLearningPathSystemFolder($courseEntity, $session); + + foreach ($entries as $sc) { + // Locate package (zip or folder → temp zip) + $srcLpId = (int)($sc->source_lp_id ?? 0); + $pkg = $this->findScormPackageForLp($srcLpId); + if (empty($pkg['zip'])) { + error_log($logp.'No package (zip/folder) found for a SCORM entry'); + continue; + } + $zipAbs = $pkg['zip']; + $zipTemp = (bool)$pkg['temp']; + + // Map LP title/dest for folder name + $lpId = 0; $lpTitle = 'Untitled'; + if (!empty($sc->lp_id_dest)) { + $lpId = (int) $sc->lp_id_dest; + } elseif ($srcLpId && !empty($lpBucket[$srcLpId]->destination_id)) { + $lpId = (int) $lpBucket[$srcLpId]->destination_id; + } + $lpEntity = $lpId ? Container::getLpRepository()->find($lpId) : null; + if ($lpEntity) { $lpTitle = $lpEntity->getTitle() ?: $lpTitle; } + + $cleanTitle = preg_replace('/\s+/', ' ', trim(str_replace(['/', '\\'], '-', (string)$lpTitle))) ?: 'Untitled'; + $folderTitleBase = sprintf('SCORM - %d - %s', $lpId ?: 0, $cleanTitle); + $folderTitle = $folderTitleBase; + + $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle); + if ($exists) { + if ($this->file_option === FILE_SKIP) { + error_log($logp."Skip due to folder name collision: '$folderTitle'"); + if ($zipTemp) { @unlink($zipAbs); } + continue; + } + if ($this->file_option === FILE_RENAME) { + $i = 1; + do { + $folderTitle = $folderTitleBase.' ('.$i.')'; + $exists = $docRepo->findChildNodeByTitle($lpTop, $folderTitle); + $i++; + } while ($exists); + } + if ($this->file_option === FILE_OVERWRITE && $lpEntity) { + $docRepo->purgeScormZip($courseEntity, $lpEntity); + $em->flush(); } } + + // Upload ZIP under Documents + $uploaded = new UploadedFile( + $zipAbs, basename($zipAbs), 'application/zip', null, true + ); + $lpFolder = $docRepo->ensureFolder( + $courseEntity, $lpTop, $folderTitle, + ResourceLink::VISIBILITY_DRAFT, $session + ); + $docRepo->createFileInFolder( + $courseEntity, $lpFolder, $uploaded, + sprintf('SCORM ZIP for LP #%d', $lpId), + ResourceLink::VISIBILITY_DRAFT, $session + ); + $em->flush(); + + if ($zipTemp) { @unlink($zipAbs); } + error_log($logp."ZIP stored under folder '$folderTitle'"); } } /** - * Restoring learning paths. - * - * @param int $session_id - * @param bool|false $respect_base_content + * Restore learnpaths (SCORM-aware). + * For SCORM LPs, it accepts a real zip or zips a folder-on-the-fly if needed. + * This version adds strict checks, robust logging and a guaranteed fallback LP. */ - public function restore_learnpaths($session_id = 0, $respect_base_content = false) + public function restore_learnpaths($session_id = 0, $respect_base_content = false, $destination_course_code = '') { - $session_id = (int) $session_id; - if ($this->course->has_resources(RESOURCE_LEARNPATH)) { - $table_main = Database::get_course_table(TABLE_LP_MAIN); - $table_item = Database::get_course_table(TABLE_LP_ITEM); - $table_tool = Database::get_course_table(TABLE_TOOL_LIST); + $logp = 'RESTORE_LP: '; - $resources = $this->course->resources; - $origin_path = $this->course->backup_path.'/upload/learning_path/images/'; - $destination_path = api_get_path(SYS_COURSE_PATH).$this->course->destination_path.'/upload/learning_path/images/'; - - // Choose default visibility - $toolVisibility = api_get_setting('tool_visible_by_default_at_creation'); - $defaultLpVisibility = 'invisible'; - if (isset($toolVisibility['learning_path']) && 'true' == $toolVisibility['learning_path']) { - $defaultLpVisibility = 'visible'; - } - - foreach ($resources[RESOURCE_LEARNPATH] as $id => $lp) { - $condition_session = ''; - if (!empty($session_id)) { - if ($respect_base_content) { - $my_session_id = $lp->session_id; - if (!empty($lp->session_id)) { - $my_session_id = $session_id; - } - $condition_session = $my_session_id; - } else { - $session_id = (int) $session_id; - $condition_session = $session_id; - } - } + // --- REQUIRED INITIALIZATION (avoid "Undefined variable $courseEntity") --- + $courseInfo = $this->destination_course_info ?? []; + $courseId = (int)($courseInfo['real_id'] ?? 0); + if ($courseId <= 0) { + error_log($logp.'Missing destination course id; aborting.'); + return; + } - // Adding the author's image - if (!empty($lp->preview_image)) { - $new_filename = uniqid('').substr( - $lp->preview_image, - strlen($lp->preview_image) - 7, - strlen($lp->preview_image) - ); + $courseEntity = api_get_course_entity($courseId); + if (!$courseEntity) { + error_log($logp.'api_get_course_entity() returned null for id='.$courseId.'; aborting.'); + return; + } - if (file_exists($origin_path.$lp->preview_image) && - !is_dir($origin_path.$lp->preview_image) - ) { - $copy_result = copy( - $origin_path.$lp->preview_image, - $destination_path.$new_filename - ); - if ($copy_result) { - $lp->preview_image = $new_filename; - // Create 64 version from original - $temp = new Image($destination_path.$new_filename); - $temp->resize(64); - $pathInfo = pathinfo($new_filename); - if ($pathInfo) { - $filename = $pathInfo['filename']; - $extension = $pathInfo['extension']; - $temp->send_image($destination_path.'/'.$filename.'.64.'.$extension); - } - } else { - $lp->preview_image = ''; - } - } - } + // Session entity is optional + $session = $session_id ? api_get_session_entity((int)$session_id) : null; + + $em = Database::getManager(); + $lpRepo = Container::getLpRepository(); + + /** + * Resolve a resource "bucket" by type (constant or string) and return [key, data]. + * - Normalizes common aliases (case-insensitive). + * - Keeps original bucket key so we can write back destination_id on the right slot. + */ + $getBucketWithKey = function (int|string $type) use ($logp) { + // Map constants to canonical strings + if (is_int($type)) { + $type = match ($type) { + defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : -1 => 'learnpath', + defined('RESOURCE_SCORM') ? RESOURCE_SCORM : -2 => 'scorm', + default => (string)$type, + }; + } - if ($this->add_text_in_items) { - $lp->name .= ' '.get_lang('Copy'); - } + // Common legacy aliases + $aliases = [ + 'learnpath' => ['learnpath','coursecopylearnpath','CourseCopyLearnpath','learning_path'], + 'scorm' => ['scorm','scormdocument','ScormDocument'], + ]; - if (isset($this->tool_copy_settings['learnpaths'])) { - if (isset($this->tool_copy_settings['learnpaths']['reset_dates']) && - $this->tool_copy_settings['learnpaths']['reset_dates'] - ) { - $lp->created_on = api_get_utc_datetime(); - $lp->modified_on = api_get_utc_datetime(); - $lp->published_on = null; - } - } + $want = strtolower((string)$type); + $wantedKeys = array_unique(array_merge([$type], $aliases[$want] ?? [])); - $lp->expired_on = isset($lp->expired_on) && '0000-00-00 00:00:00' === $lp->expired_on ? null : $lp->expired_on; - $lp->published_on = isset($lp->published_on) && '0000-00-00 00:00:00' === $lp->published_on ? null : $lp->published_on; + $res = is_array($this->course->resources ?? null) ? $this->course->resources : []; + if (empty($res)) { + error_log($logp."resources array is empty or invalid"); + return [null, []]; + } - if (isset($lp->categoryId)) { - $lp->categoryId = (int) $lp->categoryId; + // 1) Exact match + foreach ($wantedKeys as $k) { + if (isset($res[$k]) && is_array($res[$k])) { + error_log($logp."bucket '". $type ."' found as '$k' (".count($res[$k]).")"); + return [$k, $res[$k]]; } - - $categoryId = 0; - if (!empty($lp->categoryId)) { - if (isset($resources[RESOURCE_LEARNPATH_CATEGORY][$lp->categoryId])) { - $categoryId = $resources[RESOURCE_LEARNPATH_CATEGORY][$lp->categoryId]->destination_id; - } + } + // 2) Case-insensitive match + $lowerWanted = array_map('strtolower', $wantedKeys); + foreach ($res as $k => $v) { + if (is_string($k) && in_array(strtolower($k), $lowerWanted, true) && is_array($v)) { + error_log($logp."bucket '". $type ."' found as '$k' (".count($v).")"); + return [$k, $v]; } - $params = [ - 'c_id' => $this->destination_course_id, - 'lp_type' => $lp->lp_type, - 'name' => self::DBUTF8($lp->name), - 'path' => self::DBUTF8($lp->path), - 'ref' => $lp->ref, - 'description' => self::DBUTF8($lp->description), - 'content_local' => self::DBUTF8($lp->content_local), - 'default_encoding' => self::DBUTF8($lp->default_encoding), - 'default_view_mod' => self::DBUTF8($lp->default_view_mod), - 'prevent_reinit' => self::DBUTF8($lp->prevent_reinit), - 'force_commit' => self::DBUTF8($lp->force_commit), - 'content_maker' => self::DBUTF8($lp->content_maker), - 'display_order' => self::DBUTF8($lp->display_order), - 'js_lib' => self::DBUTF8($lp->js_lib), - 'content_license' => self::DBUTF8($lp->content_license), - 'author' => self::DBUTF8($lp->author), - //'preview_image' => self::DBUTF8($lp->preview_image), - 'use_max_score' => self::DBUTF8($lp->use_max_score), - 'autolaunch' => self::DBUTF8(isset($lp->autolaunch) ? $lp->autolaunch : ''), - 'created_on' => empty($lp->created_on) ? api_get_utc_datetime() : self::DBUTF8($lp->created_on), - 'modified_on' => empty($lp->modified_on) ? api_get_utc_datetime() : self::DBUTF8($lp->modified_on), - 'published_on' => empty($lp->published_on) ? api_get_utc_datetime() : self::DBUTF8($lp->published_on), - 'expired_on' => self::DBUTF8($lp->expired_on), - 'debug' => self::DBUTF8($lp->debug), - 'theme' => '', - 'session_id' => $session_id, - 'prerequisite' => 0, - 'hide_toc_frame' => 0, - 'seriousgame_mode' => 0, - 'category_id' => $categoryId, - 'max_attempts' => 0, - 'subscribe_users' => 0, - ]; + } - if (!empty($condition_session)) { - $params['session_id'] = $condition_session; - } + error_log($logp."bucket '".(string)$type."' not found"); + return [null, []]; + }; + + // Resolve learnpath bucket (returning its actual key to write back destination_id) + [$lpBucketKey, $lpBucket] = $getBucketWithKey(defined('RESOURCE_LEARNPATH') ? RESOURCE_LEARNPATH : 'learnpath'); + if (empty($lpBucket)) { + error_log($logp."No LPs to process"); + return; + } - $new_lp_id = Database::insert($table_main, $params); + // Optional: resolve scorm bucket (may be used by other helpers) + [$_scormKey, $scormBucket] = $getBucketWithKey(defined('RESOURCE_SCORM') ? RESOURCE_SCORM : 'scorm'); + error_log($logp."LPs=".count($lpBucket).", SCORM entries=".count($scormBucket)); - if ($new_lp_id) { - // The following only makes sense if a new LP was - // created in the destination course - $sql = "UPDATE $table_main SET id = iid WHERE iid = $new_lp_id"; - Database::query($sql); + foreach ($lpBucket as $srcLpId => $lpObj) { + $lpName = $lpObj->name ?? ($lpObj->title ?? ('LP '.$srcLpId)); + $lpType = (int)($lpObj->lp_type ?? $lpObj->type ?? 1); // 2 = SCORM + $encoding = $lpObj->default_encoding ?? 'UTF-8'; - if ($lp->visibility) { - $params = [ - 'c_id' => $this->destination_course_id, - 'name' => self::DBUTF8($lp->name), - 'link' => "lp/lp_controller.php?action=view&lp_id=$new_lp_id&sid=$session_id", - 'image' => 'scormbuilder.gif', - 'visibility' => '0', - 'admin' => '0', - 'address' => 'squaregrey.gif', - 'session_id' => $session_id, - ]; - $insertId = Database::insert($table_tool, $params); - if ($insertId) { - $sql = "UPDATE $table_tool SET id = iid WHERE iid = $insertId"; - Database::query($sql); + error_log($logp."LP src=$srcLpId, name='". $lpName ."', type=".$lpType); + + // ---- SCORM ---- + if ($lpType === CLp::SCORM_TYPE) { + $createdLpId = 0; + $zipAbs = null; + $zipTemp = false; + + try { + // Find a real SCORM ZIP (or zip a folder on-the-fly) + $pkg = $this->findScormPackageForLp((int)$srcLpId); + $zipAbs = $pkg['zip'] ?? null; + $zipTemp = !empty($pkg['temp']); + + if (!$zipAbs || !is_readable($zipAbs)) { + error_log($logp."SCORM LP src=$srcLpId: NO ZIP found/readable"); + } else { + error_log($logp."SCORM LP src=$srcLpId ZIP=".$zipAbs); + + // Try to resolve currentDir from the BACKUP (folder or ZIP) + $currentDir = ''; + $tmpExtractDir = ''; + $bp = (string) ($this->course->backup_path ?? ''); + + // Case A: backup_path is an extracted directory + if ($bp && is_dir($bp)) { + try { + $rii = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($bp, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($rii as $f) { + if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') { + $currentDir = $f->getPath(); + break; + } + } + } catch (\Throwable $e) { + error_log($logp.'Scan BACKUP dir failed: '.$e->getMessage()); + } } - } - if (isset($lp->extraFields) && !empty($lp->extraFields)) { - $extraFieldValue = new ExtraFieldValue('lp'); - foreach ($lp->extraFields as $extraField) { - $params = [ - 'item_id' => $new_lp_id, - 'value' => $extraField['value'], - 'variable' => $extraField['variable'], - ]; - $extraFieldValue->save($params); + // Case B: backup_path is a ZIP under var/cache/course_backups + if (!$currentDir && $bp && is_file($bp) && preg_match('/\.zip$/i', $bp)) { + $tmpExtractDir = rtrim(sys_get_temp_dir(), '/').'/scorm_restore_'.uniqid('', true); + @mkdir($tmpExtractDir, 0777, true); + try { + $zf = new ZipFile(); + $zf->openFile($bp); + $zf->extractTo($tmpExtractDir); + $zf->close(); + + $rii = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($tmpExtractDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($rii as $f) { + if ($f->isFile() && strtolower($f->getFilename()) === 'imsmanifest.xml') { + $currentDir = $f->getPath(); + break; + } + } + } catch (\Throwable $e) { + error_log($logp.'TMP unzip failed: '.$e->getMessage()); + } } - } - /*api_item_property_update( - $this->destination_course_info, - TOOL_LEARNPATH, - $new_lp_id, - 'LearnpathAdded', - api_get_user_id(), - 0, - 0, - 0, - 0, - $session_id - );*/ - - // Set the new LP to visible - /*api_item_property_update( - $this->destination_course_info, - TOOL_LEARNPATH, - $new_lp_id, - $defaultLpVisibility, - api_get_user_id(), - 0, - 0, - 0, - 0, - $session_id - );*/ - - $new_item_ids = []; - $parent_item_ids = []; - $previous_item_ids = []; - $next_item_ids = []; - $old_prerequisite = []; - $old_refs = []; - $prerequisite_ids = []; - - foreach ($lp->get_items() as $index => $item) { - // we set the ref code here and then we update in a for loop - $ref = $item['ref']; - - // Dealing with path the same way as ref as some data has - // been put into path when it's a local resource - // Only fix the path for no scos - if ('sco' === $item['item_type']) { - $path = $item['path']; + + if ($currentDir) { + error_log($logp.'Resolved currentDir from BACKUP: '.$currentDir); } else { - $path = $this->get_new_id($item['item_type'], $item['path']); + error_log($logp.'Could not resolve currentDir from backup; import_package will derive it'); } - $item['item_type'] = 'dokeos_chapter' == $item['item_type'] ? 'dir' : $item['item_type']; + // Import in scorm class (import_manifest will create LP + items) + $sc = new \scorm(); + $fileInfo = ['tmp_name' => $zipAbs, 'name' => basename($zipAbs)]; + + $ok = $sc->import_package($fileInfo, $currentDir); - $masteryScore = $item['mastery_score']; - // If item is a chamilo quiz, then use the max score as mastery_score - if ('quiz' == $item['item_type']) { - if (empty($masteryScore)) { - $masteryScore = $item['max_score']; + // Cleanup tmp if we extracted the backup ZIP + if ($tmpExtractDir && is_dir($tmpExtractDir)) { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($tmpExtractDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $p) { + $p->isDir() ? @rmdir($p->getPathname()) : @unlink($p->getPathname()); } + @rmdir($tmpExtractDir); } - $prerequisiteMinScore = $item['prerequisite_min_score'] ?? null; - $prerequisiteMaxScore = $item['prerequisite_max_score'] ?? null; - $params = [ - 'c_id' => $this->destination_course_id, - 'lp_id' => self::DBUTF8($new_lp_id), - 'item_type' => self::DBUTF8($item['item_type']), - 'ref' => self::DBUTF8($ref), - 'path' => self::DBUTF8($path), - 'title' => self::DBUTF8($item['title']), - 'description' => self::DBUTF8($item['description']), - 'min_score' => self::DBUTF8($item['min_score']), - 'max_score' => self::DBUTF8($item['max_score']), - 'mastery_score' => self::DBUTF8($masteryScore), - 'prerequisite_min_score' => $prerequisiteMinScore, - 'prerequisite_max_score' => $prerequisiteMaxScore, - 'parent_item_id' => self::DBUTF8($item['parent_item_id']), - 'previous_item_id' => self::DBUTF8($item['previous_item_id']), - 'next_item_id' => self::DBUTF8($item['next_item_id']), - 'display_order' => self::DBUTF8($item['display_order']), - 'prerequisite' => self::DBUTF8($item['prerequisite']), - 'parameters' => self::DBUTF8($item['parameters']), - 'audio' => self::DBUTF8($item['audio']), - 'launch_data' => self::DBUTF8($item['launch_data']), - ]; + if ($ok !== true) { + error_log($logp."import_package() returned false"); + } else { + if (empty($sc->manifestToString)) { + error_log($logp."manifestToString empty after import_package()"); + } else { + // Parse & import manifest (creates LP + items) + $sc->parse_manifest(); + + /** @var CLp|null $lp */ + $lp = $sc->import_manifest($courseId, 1, (int) $session_id); + if ($lp instanceof CLp) { + if (property_exists($lpObj, 'content_local')) { + $lp->setContentLocal((int) $lpObj->content_local); + } + if (property_exists($lpObj, 'content_maker')) { + $lp->setContentMaker((string) $lpObj->content_maker); + } + $lp->setDefaultEncoding((string) $encoding); - $new_item_id = Database::insert($table_item, $params); - if ($new_item_id) { - $sql = "UPDATE $table_item SET id = iid WHERE iid = $new_item_id"; - Database::query($sql); - - //save a link between old and new item IDs - $new_item_ids[$item['id']] = $new_item_id; - //save a reference of items that need a parent_item_id refresh - $parent_item_ids[$new_item_id] = $item['parent_item_id']; - //save a reference of items that need a previous_item_id refresh - $previous_item_ids[$new_item_id] = $item['previous_item_id']; - //save a reference of items that need a next_item_id refresh - $next_item_ids[$new_item_id] = $item['next_item_id']; - - if (!empty($item['prerequisite'])) { - if ('2' == $lp->lp_type) { - // if is an sco - $old_prerequisite[$new_item_id] = $item['prerequisite']; - } else { - $old_prerequisite[$new_item_id] = isset($new_item_ids[$item['prerequisite']]) ? $new_item_ids[$item['prerequisite']] : ''; - } - } + $em->persist($lp); + $em->flush(); - if (!empty($ref)) { - if ('2' == $lp->lp_type) { - // if is an sco - $old_refs[$new_item_id] = $ref; - } elseif (isset($new_item_ids[$ref])) { - $old_refs[$new_item_id] = $new_item_ids[$ref]; + $createdLpId = (int)$lp->getIid(); + if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) { + $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = $createdLpId; + } + error_log($logp."SCORM LP created id=".$createdLpId." (via manifest)"); + } else { + error_log($logp."import_manifest() returned NULL"); } } - $prerequisite_ids[$new_item_id] = $item['prerequisite']; } } + } catch (\Throwable $e) { + error_log($logp.'EXCEPTION: '.$e->getMessage()); + } finally { + if (empty($createdLpId)) { + $lp = (new CLp()) + ->setLpType(CLp::SCORM_TYPE) + ->setTitle((string) $lpName) + ->setDefaultEncoding((string) $encoding) + ->setJsLib('scorm_api.php') + ->setUseMaxScore(1) + ->setParent($courseEntity); + + if (method_exists($lp, 'addCourseLink')) { + // pass session only if available + $lp->addCourseLink($courseEntity, $session ?: null); + } - // Updating prerequisites - foreach ($old_prerequisite as $key => $my_old_prerequisite) { - if ('' != $my_old_prerequisite) { - $my_old_prerequisite = Database::escape_string($my_old_prerequisite); - $sql = "UPDATE $table_item SET prerequisite = '$my_old_prerequisite' - WHERE c_id = ".$this->destination_course_id." AND id = '".$key."' "; - Database::query($sql); + $lpRepo->createLp($lp); + $em->flush(); + + $createdLpId = (int) $lp->getIid(); + if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) { + $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = $createdLpId; } + error_log($logp."SCORM LP created id=".$createdLpId." (FALLBACK)"); } - // Updating refs - foreach ($old_refs as $key => $my_old_ref) { - if ('' != $my_old_ref) { - $my_old_ref = Database::escape_string($my_old_ref); - $sql = "UPDATE $table_item SET ref = '$my_old_ref' - WHERE c_id = ".$this->destination_course_id." AND id = $key"; - Database::query($sql); - } + // Remove temp ZIP if we created it in findScormPackageForLp() + if (!empty($zipTemp) && !empty($zipAbs) && is_file($zipAbs)) { + @unlink($zipAbs); } + } - foreach ($parent_item_ids as $new_item_id => $parent_item_old_id) { - $new_item_id = (int) $new_item_id; - $parent_new_id = 0; - if (0 != $parent_item_old_id) { - $parent_new_id = isset($new_item_ids[$parent_item_old_id]) ? $new_item_ids[$parent_item_old_id] : 0; - } + continue; // next LP + } - $parent_new_id = Database::escape_string($parent_new_id); - $sql = "UPDATE $table_item SET parent_item_id = '$parent_new_id' - WHERE c_id = ".$this->destination_course_id." AND id = $new_item_id"; - Database::query($sql); - } + // ---- Non-SCORM ---- + $lp = (new CLp()) + ->setLpType(CLp::LP_TYPE) + ->setTitle((string) $lpName) + ->setDefaultEncoding((string) $encoding) + ->setJsLib('scorm_api.php') + ->setUseMaxScore(1) + ->setParent($courseEntity); + + if (method_exists($lp, 'addCourseLink')) { + $lp->addCourseLink($courseEntity, $session ?: null); + } - foreach ($previous_item_ids as $new_item_id => $previous_item_old_id) { - $new_item_id = (int) $new_item_id; - $previous_new_id = 0; - if (0 != $previous_item_old_id) { - $previous_new_id = isset($new_item_ids[$previous_item_old_id]) ? $new_item_ids[$previous_item_old_id] : 0; - } - $previous_new_id = Database::escape_string($previous_new_id); - $sql = "UPDATE $table_item SET previous_item_id = '$previous_new_id' - WHERE c_id = ".$this->destination_course_id." AND id = '".$new_item_id."'"; - Database::query($sql); - } + $lpRepo->createLp($lp); + $em->flush(); + error_log($logp."Standard LP created id=".$lp->getIid()); - foreach ($next_item_ids as $new_item_id => $next_item_old_id) { - $new_item_id = (int) $new_item_id; - $next_new_id = 0; - if (0 != $next_item_old_id) { - $next_new_id = isset($new_item_ids[$next_item_old_id]) ? $new_item_ids[$next_item_old_id] : 0; - } - $next_new_id = Database::escape_string($next_new_id); - $sql = "UPDATE $table_item SET next_item_id = '$next_new_id' - WHERE c_id = ".$this->destination_course_id." AND id = '".$new_item_id."'"; - Database::query($sql); - } + if ($lpBucketKey !== null && isset($this->course->resources[$lpBucketKey][$srcLpId])) { + $this->course->resources[$lpBucketKey][$srcLpId]->destination_id = (int) $lp->getIid(); + } - foreach ($prerequisite_ids as $new_item_id => $prerequisite_old_id) { - $new_item_id = (int) $new_item_id; - $prerequisite_new_id = 0; - if (0 != $prerequisite_old_id) { - $prerequisite_new_id = $new_item_ids[$prerequisite_old_id]; - } - $sql = "UPDATE $table_item SET prerequisite = $prerequisite_new_id - WHERE c_id = ".$this->destination_course_id." AND id = $new_item_id"; - Database::query($sql); - } - $this->course->resources[RESOURCE_LEARNPATH][$id]->destination_id = $new_lp_id; + // Manual items (only for non-SCORM if present in backup) + if (!empty($lpObj->items) && is_array($lpObj->items)) { + $lpItemRepo = Container::getLpItemRepository(); + $rootItem = $lpItemRepo->getRootItem($lp->getIid()); + $parents = [0 => $rootItem]; + + foreach ($lpObj->items as $it) { + $level = (int) ($it['level'] ?? 0); + if (!isset($parents[$level])) { $parents[$level] = end($parents); } + $parentEntity = $parents[$level] ?? $rootItem; + + $lpItem = (new CLpItem()) + ->setTitle((string) ($it['title'] ?? '')) + ->setItemType((string) ($it['item_type'] ?? 'dir')) + ->setRef((string) ($it['identifier'] ?? '')) + ->setPath((string) ($it['path'] ?? '')) + ->setMinScore(0) + ->setMaxScore((int) ($it['max_score'] ?? 100)) + ->setPrerequisite((string) ($it['prerequisites'] ?? '')) + ->setLaunchData((string) ($it['datafromlms'] ?? '')) + ->setParameters((string) ($it['parameters'] ?? '')) + ->setLp($lp) + ->setParent($parentEntity); + + $lpItemRepo->create($lpItem); + $parents[$level+1] = $lpItem; } + $em->flush(); + error_log($logp."Standard LP id=".$lp->getIid()." items=".count($lpObj->items)); } } } /** - * Gets the new ID of one specific tool item from the tool name and the old ID. - * - * @param string Tool name - * @param int Old ID - * - * @return int New ID + * Restore glossary. */ - public function get_new_id($tool, $ref) + public function restore_glossary($sessionId = 0) { - // Check if the value exist in the current array. - if ('hotpotatoes' === $tool) { - $tool = 'document'; + if (!$this->course->has_resources(RESOURCE_GLOSSARY)) { + $this->debug && error_log('COURSE_DEBUG: restore_glossary: no glossary resources in backup.'); + return; } - if ('student_publication' === $tool) { - $tool = RESOURCE_WORK; + $em = Database::getManager(); + /** @var CGlossaryRepository $repo */ + $repo = $em->getRepository(CGlossary::class); + /** @var CourseEntity $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int) $sessionId) : null; + + $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + if ($backupRoot === '') { + $this->debug && error_log('COURSE_DEBUG: restore_glossary: backupRoot empty; URL rewriting may be partial.'); } - if (isset($this->course->resources[$tool][$ref]) && - isset($this->course->resources[$tool][$ref]->destination_id) && - !empty($this->course->resources[$tool][$ref]->destination_id) - ) { - return $this->course->resources[$tool][$ref]->destination_id; - } + $resources = $this->course->resources; - // Check if the course is the same (last hope). - if ($this->course_origin_id == $this->destination_course_id) { - return $ref; - } + foreach ($resources[RESOURCE_GLOSSARY] as $legacyId => $gls) { + try { + $title = (string) ($gls->name ?? $gls->title ?? ''); + $desc = (string) ($gls->description ?? ''); + $order = (int) ($gls->display_order ?? 0); + + $desc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets($desc, $courseEntity, $backupRoot) ?? $desc; + + $existing = null; + if (method_exists($repo, 'getResourcesByCourse')) { + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity) + ->andWhere('resource.title = :title') + ->setParameter('title', $title) + ->setMaxResults(1); + $existing = $qb->getQuery()->getOneOrNullResult(); + } else { + $existing = $repo->findOneBy(['title' => $title]); + } - return ''; - } + if ($existing instanceof CGlossary) { + switch ($this->file_option) { + case FILE_SKIP: + $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int)$existing->getIid(); + $this->debug && error_log("COURSE_DEBUG: restore_glossary: term exists title='{$title}' (skip)."); + continue 2; - /** - * Restore glossary. - */ - public function restore_glossary($sessionId = 0) - { - $sessionId = (int) $sessionId; - if ($this->course->has_resources(RESOURCE_GLOSSARY)) { - $table_glossary = Database::get_course_table(TABLE_GLOSSARY); - $resources = $this->course->resources; - foreach ($resources[RESOURCE_GLOSSARY] as $id => $glossary) { - $params = []; - if (!empty($sessionId)) { - $params['session_id'] = $sessionId; - } + case FILE_RENAME: + $base = $title === '' ? 'Glossary term' : $title; + $try = $base; + $i = 1; + $isTaken = static function($repo, $courseEntity, $sessionEntity, $titleTry) { + if (method_exists($repo, 'getResourcesByCourse')) { + $qb = $repo->getResourcesByCourse($courseEntity, $sessionEntity) + ->andWhere('resource.title = :t')->setParameter('t', $titleTry) + ->setMaxResults(1); + return (bool)$qb->getQuery()->getOneOrNullResult(); + } + return (bool)$repo->findOneBy(['title' => $titleTry]); + }; + while ($isTaken($repo, $courseEntity, $sessionEntity, $try)) { + $try = $base.' ('.($i++).')'; + } + $title = $try; + $this->debug && error_log("COURSE_DEBUG: restore_glossary: renaming to '{$title}'."); + break; - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $glossary->description = DocumentManager::replaceUrlWithNewCourseCode( - $glossary->description, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + case FILE_OVERWRITE: + $em->remove($existing); + $em->flush(); + $this->debug && error_log("COURSE_DEBUG: restore_glossary: existing term deleted (overwrite)."); + break; - $params['c_id'] = $this->destination_course_id; - $params['description'] = false === $glossary->description ? '' : self::DBUTF8($glossary->description); - $params['display_order'] = $glossary->display_order; - $params['name'] = self::DBUTF8($glossary->name); - $params['glossary_id'] = 0; - $my_id = Database::insert($table_glossary, $params); - if ($my_id) { - $sql = "UPDATE $table_glossary SET glossary_id = iid WHERE iid = $my_id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - TOOL_GLOSSARY, - $my_id, - 'GlossaryAdded', - api_get_user_id() - );*/ - - if (!isset($this->course->resources[RESOURCE_GLOSSARY][$id])) { - $this->course->resources[RESOURCE_GLOSSARY][$id] = new stdClass(); + default: + $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = (int)$existing->getIid(); + continue 2; } + } + + $entity = new CGlossary(); + $entity + ->setTitle($title) + ->setDescription($desc); + + if (method_exists($entity, 'setParent')) { + $entity->setParent($courseEntity); + } + + if (method_exists($entity, 'addCourseLink')) { + $entity->addCourseLink($courseEntity, $sessionEntity); + } + + if (method_exists($repo, 'create')) { + $repo->create($entity); + } else { + $em->persist($entity); + $em->flush(); + } + + if ($order && method_exists($entity, 'setDisplayOrder')) { + $entity->setDisplayOrder($order); + $em->flush(); + } - $this->course->resources[RESOURCE_GLOSSARY][$id]->destination_id = $my_id; + $newId = (int)$entity->getIid(); + if (!isset($this->course->resources[RESOURCE_GLOSSARY][$legacyId])) { + $this->course->resources[RESOURCE_GLOSSARY][$legacyId] = new \stdClass(); } + $this->course->resources[RESOURCE_GLOSSARY][$legacyId]->destination_id = $newId; + + $this->debug && error_log("COURSE_DEBUG: restore_glossary: created term iid={$newId}, title='{$title}'"); + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_glossary: failed: '.$e->getMessage()); + continue; } } } @@ -3145,81 +3638,217 @@ public function restore_glossary($sessionId = 0) */ public function restore_wiki($sessionId = 0) { - if ($this->course->has_resources(RESOURCE_WIKI)) { - // wiki table of the target course - $table_wiki = Database::get_course_table(TABLE_WIKI); - $table_wiki_conf = Database::get_course_table(TABLE_WIKI_CONF); + if (!$this->course->has_resources(RESOURCE_WIKI)) { + $this->debug && error_log('COURSE_DEBUG: restore_wiki: no wiki resources in backup.'); + return; + } - // storing all the resources that have to be copied in an array - $resources = $this->course->resources; + $em = Database::getManager(); + /** @var CWikiRepository $repo */ + $repo = $em->getRepository(CWiki::class); + /** @var CourseEntity $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; + + $cid = (int)$this->destination_course_id; + $sid = (int)($sessionEntity?->getId() ?? 0); + + $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + if ($backupRoot === '') { + $this->debug && error_log('COURSE_DEBUG: restore_wiki: backupRoot empty; URL rewriting may be partial.'); + } + + $resources = $this->course->resources; + + foreach ($resources[RESOURCE_WIKI] as $legacyId => $w) { + try { + $rawTitle = (string)($w->title ?? $w->name ?? ''); + $reflink = (string)($w->reflink ?? ''); + $content = (string)($w->content ?? ''); + $comment = (string)($w->comment ?? ''); + $progress = (string)($w->progress ?? ''); + $version = (int) ($w->version ?? 1); + $groupId = (int) ($w->group_id ?? 0); + $userId = (int) ($w->user_id ?? api_get_user_id()); + $dtimeStr = (string)($w->dtime ?? ''); + $dtime = null; + try { $dtime = $dtimeStr !== '' ? new \DateTime($dtimeStr) : new \DateTime('now', new \DateTimeZone('UTC')); } + catch (\Throwable) { $dtime = new \DateTime('now', new \DateTimeZone('UTC')); } + + $content = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $content, + $courseEntity, + $backupRoot + ) ?? $content; + + if ($rawTitle === '') { + $rawTitle = 'Wiki page'; + } + if ($content === '') { + $content = '

 

'; + } + + $makeSlug = static function (string $s): string { + $s = strtolower(trim($s)); + $s = preg_replace('/[^\p{L}\p{N}]+/u', '-', $s) ?: ''; + $s = trim($s, '-'); + return $s === '' ? 'page' : $s; + }; + $reflink = $reflink !== '' ? $makeSlug($reflink) : $makeSlug($rawTitle); + + $qbExists = $repo->createQueryBuilder('w') + ->select('w.iid') + ->andWhere('w.cId = :cid')->setParameter('cid', $cid) + ->andWhere('w.reflink = :r')->setParameter('r', $reflink) + ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId); + if ($sid > 0) { + $qbExists->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); + } else { + $qbExists->andWhere('COALESCE(w.sessionId,0) = 0'); + } + $exists = (bool)$qbExists->getQuery()->getOneOrNullResult(); + + if ($exists) { + switch ($this->file_option) { + case FILE_SKIP: + $qbLast = $repo->createQueryBuilder('w') + ->andWhere('w.cId = :cid')->setParameter('cid', $cid) + ->andWhere('w.reflink = :r')->setParameter('r', $reflink) + ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId) + ->orderBy('w.version', 'DESC')->setMaxResults(1); + if ($sid > 0) { $qbLast->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); } + else { $qbLast->andWhere('COALESCE(w.sessionId,0) = 0'); } + + /** @var CWiki|null $last */ + $last = $qbLast->getQuery()->getOneOrNullResult(); + $dest = $last ? (int)($last->getPageId() ?: $last->getIid()) : 0; + $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = $dest; + $this->debug && error_log("COURSE_DEBUG: restore_wiki: reflink '{$reflink}' exists → skip (page_id={$dest})."); + continue 2; + + case FILE_RENAME: + $baseSlug = $reflink; + $baseTitle = $rawTitle; + $i = 1; + $trySlug = $baseSlug.'-'.$i; + $isTaken = function (string $slug) use ($repo, $cid, $sid, $groupId): bool { + $qb = $repo->createQueryBuilder('w') + ->select('w.iid') + ->andWhere('w.cId = :cid')->setParameter('cid', $cid) + ->andWhere('w.reflink = :r')->setParameter('r', $slug) + ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId); + if ($sid > 0) $qb->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); + else $qb->andWhere('COALESCE(w.sessionId,0) = 0'); + $qb->setMaxResults(1); + return (bool)$qb->getQuery()->getOneOrNullResult(); + }; + while ($isTaken($trySlug)) { $trySlug = $baseSlug.'-'.(++$i); } + $reflink = $trySlug; + $rawTitle = $baseTitle.' ('.$i.')'; + $this->debug && error_log("COURSE_DEBUG: restore_wiki: renamed reflink to '{$reflink}' / title='{$rawTitle}'."); + break; + + case FILE_OVERWRITE: + $qbAll = $repo->createQueryBuilder('w') + ->andWhere('w.cId = :cid')->setParameter('cid', $cid) + ->andWhere('w.reflink = :r')->setParameter('r', $reflink) + ->andWhere('COALESCE(w.groupId,0) = :gid')->setParameter('gid', $groupId); + if ($sid > 0) $qbAll->andWhere('COALESCE(w.sessionId,0) = :sid')->setParameter('sid', $sid); + else $qbAll->andWhere('COALESCE(w.sessionId,0) = 0'); + + foreach ($qbAll->getQuery()->getResult() as $old) { + $em->remove($old); + } + $em->flush(); + $this->debug && error_log("COURSE_DEBUG: restore_wiki: removed previous pages for reflink '{$reflink}' (overwrite)."); + break; - foreach ($resources[RESOURCE_WIKI] as $id => $wiki) { - // the sql statement to insert the groups from the old course to the new course - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $wiki->content = DocumentManager::replaceUrlWithNewCourseCode( - $wiki->content, - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + default: + $this->debug && error_log("COURSE_DEBUG: restore_wiki: unknown file_option → skip."); + continue 2; + } + } - $params = [ - 'c_id' => $this->destination_course_id, - 'page_id' => self::DBUTF8($wiki->page_id), - 'reflink' => self::DBUTF8($wiki->reflink), - 'title' => self::DBUTF8($wiki->title), - 'content' => false === $wiki->content ? '' : self::DBUTF8($wiki->content), - 'user_id' => (int) ($wiki->user_id), - 'group_id' => (int) ($wiki->group_id), - 'dtime' => self::DBUTF8($wiki->dtime), - 'progress' => self::DBUTF8($wiki->progress), - 'version' => (int) ($wiki->version), - 'session_id' => !empty($session_id) ? (int) $session_id : 0, - 'addlock' => 0, - 'editlock' => 0, - 'visibility' => 0, - 'addlock_disc' => 0, - 'visibility_disc' => 0, - 'ratinglock_disc' => 0, - 'assignment' => 0, - 'comment' => '', - 'is_editing' => 0, - 'linksto' => 0, - 'tag' => '', - 'user_ip' => '', - ]; + $wiki = new CWiki(); + $wiki->setCId($cid); + $wiki->setSessionId($sid); + $wiki->setGroupId($groupId); + $wiki->setReflink($reflink); + $wiki->setTitle($rawTitle); + $wiki->setContent($content); + $wiki->setComment($comment); + $wiki->setProgress($progress); + $wiki->setVersion($version > 0 ? $version : 1); + $wiki->setUserId($userId); + $wiki->setDtime($dtime); + $wiki->setIsEditing(0); + $wiki->setTimeEdit(null); + $wiki->setHits((int) ($w->hits ?? 0)); + $wiki->setAddlock((int) ($w->addlock ?? 1)); + $wiki->setEditlock((int) ($w->editlock ?? 0)); + $wiki->setVisibility((int) ($w->visibility ?? 1)); + $wiki->setAddlockDisc((int) ($w->addlock_disc ?? 1)); + $wiki->setVisibilityDisc((int) ($w->visibility_disc ?? 1)); + $wiki->setRatinglockDisc((int) ($w->ratinglock_disc ?? 1)); + $wiki->setAssignment((int) ($w->assignment ?? 0)); + $wiki->setScore(isset($w->score) ? (int) $w->score : 0); + $wiki->setLinksto((string) ($w->linksto ?? '')); + $wiki->setTag((string) ($w->tag ?? '')); + $wiki->setUserIp((string) ($w->user_ip ?? api_get_real_ip())); + + if (method_exists($wiki, 'setParent')) { + $wiki->setParent($courseEntity); + } + if (method_exists($wiki, 'setCreator')) { + $wiki->setCreator(api_get_user_entity()); + } + $groupEntity = $groupId ? api_get_group_entity($groupId) : null; + if (method_exists($wiki, 'addCourseLink')) { + $wiki->addCourseLink($courseEntity, $sessionEntity, $groupEntity); + } - $new_id = Database::insert($table_wiki, $params); - - if ($new_id) { - $sql = "UPDATE $table_wiki SET page_id = '$new_id', id = iid - WHERE c_id = ".$this->destination_course_id." AND iid = '$new_id'"; - Database::query($sql); - - $this->course->resources[RESOURCE_WIKI][$id]->destination_id = $new_id; - - // we also add an entry in wiki_conf - $params = [ - 'c_id' => $this->destination_course_id, - 'page_id' => $new_id, - 'task' => '', - 'feedback1' => '', - 'feedback2' => '', - 'feedback3' => '', - 'fprogress1' => '', - 'fprogress2' => '', - 'fprogress3' => '', - 'max_size' => 0, - 'max_text' => 0, - 'max_version' => 0, - 'startdate_assig' => null, - 'enddate_assig' => null, - 'delayedsubmit' => 0, - ]; + $em->persist($wiki); + $em->flush(); - Database::insert($table_wiki_conf, $params); + if (empty($w->page_id)) { + $wiki->setPageId((int) $wiki->getIid()); + $em->flush(); + } else { + $pid = (int) $w->page_id; + $wiki->setPageId($pid > 0 ? $pid : (int) $wiki->getIid()); + $em->flush(); } + + $conf = new CWikiConf(); + $conf->setCId($cid); + $conf->setPageId((int) $wiki->getPageId()); + $conf->setTask((string) ($w->task ?? '')); + $conf->setFeedback1((string) ($w->feedback1 ?? '')); + $conf->setFeedback2((string) ($w->feedback2 ?? '')); + $conf->setFeedback3((string) ($w->feedback3 ?? '')); + $conf->setFprogress1((string) ($w->fprogress1 ?? '')); + $conf->setFprogress2((string) ($w->fprogress2 ?? '')); + $conf->setFprogress3((string) ($w->fprogress3 ?? '')); + $conf->setMaxText(isset($w->max_text) ? (int) $w->max_text : 0); + $conf->setMaxVersion(isset($w->max_version) ? (int) $w->max_version : 0); + try { + $conf->setStartdateAssig(!empty($w->startdate_assig) ? new \DateTime((string) $w->startdate_assig) : null); + } catch (\Throwable) { $conf->setStartdateAssig(null); } + try { + $conf->setEnddateAssig(!empty($w->enddate_assig) ? new \DateTime((string) $w->enddate_assig) : null); + } catch (\Throwable) { $conf->setEnddateAssig(null); } + $conf->setDelayedsubmit(isset($w->delayedsubmit) ? (int) $w->delayedsubmit : 0); + + $em->persist($conf); + $em->flush(); + + $this->course->resources[RESOURCE_WIKI][$legacyId]->destination_id = (int) $wiki->getPageId(); + + $this->debug && error_log("COURSE_DEBUG: restore_wiki: created page iid=".(int) $wiki->getIid()." page_id=".(int) $wiki->getPageId()." reflink='{$reflink}'"); + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_wiki: failed: '.$e->getMessage()); + continue; } } } @@ -3231,87 +3860,148 @@ public function restore_wiki($sessionId = 0) */ public function restore_thematic($sessionId = 0) { - if ($this->course->has_resources(RESOURCE_THEMATIC)) { - $table_thematic = Database::get_course_table(TABLE_THEMATIC); - $table_thematic_advance = Database::get_course_table(TABLE_THEMATIC_ADVANCE); - $table_thematic_plan = Database::get_course_table(TABLE_THEMATIC_PLAN); + if (!$this->course->has_resources(RESOURCE_THEMATIC)) { + $this->debug && error_log('COURSE_DEBUG: restore_thematic: no thematic resources.'); + return; + } - $resources = $this->course->resources; - foreach ($resources[RESOURCE_THEMATIC] as $id => $thematic) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $thematic->params['content'] = DocumentManager::replaceUrlWithNewCourseCode( - $thematic->params['content'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); - $thematic->params['c_id'] = $this->destination_course_id; - unset($thematic->params['id']); - unset($thematic->params['iid']); - - $last_id = Database::insert($table_thematic, $thematic->params, false); - - if ($last_id) { - $sql = "UPDATE $table_thematic SET id = iid WHERE iid = $last_id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - 'thematic', - $last_id, - 'ThematicAdded', - api_get_user_id() - );*/ - - foreach ($thematic->thematic_advance_list as $thematic_advance) { - unset($thematic_advance['id']); - unset($thematic_advance['iid']); - $thematic_advance['attendance_id'] = 0; - $thematic_advance['thematic_id'] = $last_id; - $thematic_advance['c_id'] = $this->destination_course_id; - - $my_id = Database::insert( - $table_thematic_advance, - $thematic_advance, - false - ); + $em = Database::getManager(); + /** @var CourseEntity $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; + + $cid = (int)$this->destination_course_id; + $sid = (int)($sessionEntity?->getId() ?? 0); + + $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + + $resources = $this->course->resources; + + foreach ($resources[RESOURCE_THEMATIC] as $legacyId => $t) { + try { + $p = (array)($t->params ?? []); + $title = trim((string)($p['title'] ?? $p['name'] ?? '')); + $content = (string)($p['content'] ?? ''); + $active = (bool) ($p['active'] ?? true); + + if ($content !== '') { + $content = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $content, + $courseEntity, + $backupRoot + ) ?? $content; + } + + if ($title === '') { + $title = 'Thematic'; + } + + $thematic = new CThematic(); + $thematic + ->setTitle($title) + ->setContent($content) + ->setActive($active); + + if (method_exists($thematic, 'setParent')) { + $thematic->setParent($courseEntity); + } + if (method_exists($thematic, 'setCreator')) { + $thematic->setCreator(api_get_user_entity()); + } + if (method_exists($thematic, 'addCourseLink')) { + $thematic->addCourseLink($courseEntity, $sessionEntity); + } - if ($my_id) { - $sql = "UPDATE $table_thematic_advance SET id = iid WHERE iid = $my_id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - 'thematic_advance', - $my_id, - 'ThematicAdvanceAdded', - api_get_user_id() - );*/ + $em->persist($thematic); + $em->flush(); + + $this->course->resources[RESOURCE_THEMATIC][$legacyId]->destination_id = (int)$thematic->getIid(); + + $advList = (array)($t->thematic_advance_list ?? []); + foreach ($advList as $adv) { + if (!is_array($adv)) { $adv = (array)$adv; } + + $advContent = (string)($adv['content'] ?? ''); + if ($advContent !== '') { + $advContent = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $advContent, + $courseEntity, + $backupRoot + ) ?? $advContent; + } + + $startStr = (string)($adv['start_date'] ?? $adv['startDate'] ?? ''); + try { + $startDate = $startStr !== '' ? new \DateTime($startStr) : new \DateTime('now', new \DateTimeZone('UTC')); + } catch (\Throwable) { + $startDate = new \DateTime('now', new \DateTimeZone('UTC')); + } + + $duration = (int)($adv['duration'] ?? 1); + $doneAdvance = (bool)($adv['done_advance'] ?? $adv['doneAdvance'] ?? false); + + $advance = new CThematicAdvance(); + $advance + ->setThematic($thematic) + ->setContent($advContent) + ->setStartDate($startDate) + ->setDuration($duration) + ->setDoneAdvance($doneAdvance); + + $attId = (int)($adv['attendance_id'] ?? 0); + if ($attId > 0) { + $att = $em->getRepository(CAttendance::class)->find($attId); + if ($att) { + $advance->setAttendance($att); } } - foreach ($thematic->thematic_plan_list as $thematic_plan) { - unset($thematic_plan['id']); - unset($thematic_plan['iid']); - $thematic_plan['thematic_id'] = $last_id; - $thematic_plan['c_id'] = $this->destination_course_id; - $my_id = Database::insert($table_thematic_plan, $thematic_plan, false); - - if ($my_id) { - $sql = "UPDATE $table_thematic_plan SET id = iid WHERE iid = $my_id"; - Database::query($sql); - - /*api_item_property_update( - $this->destination_course_info, - 'thematic_plan', - $my_id, - 'ThematicPlanAdded', - api_get_user_id() - );*/ + $roomId = (int)($adv['room_id'] ?? 0); + if ($roomId > 0) { + $room = $em->getRepository(Room::class)->find($roomId); + if ($room) { + $advance->setRoom($room); } } + + $em->persist($advance); + } + + $planList = (array)($t->thematic_plan_list ?? []); + foreach ($planList as $pl) { + if (!is_array($pl)) { $pl = (array)$pl; } + + $plTitle = trim((string)($pl['title'] ?? '')); + if ($plTitle === '') { $plTitle = 'Plan'; } + + $plDesc = (string)($pl['description'] ?? ''); + if ($plDesc !== '') { + $plDesc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $plDesc, + $courseEntity, + $backupRoot + ) ?? $plDesc; + } + + $descType = (int)($pl['description_type'] ?? $pl['descriptionType'] ?? 0); + + $plan = new CThematicPlan(); + $plan + ->setThematic($thematic) + ->setTitle($plTitle) + ->setDescription($plDesc) + ->setDescriptionType($descType); + + $em->persist($plan); } + + $em->flush(); + + $this->debug && error_log("COURSE_DEBUG: restore_thematic: created thematic iid=".(int)$thematic->getIid()." (advances=".count($advList).", plans=".count($planList).")"); + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_thematic: failed: '.$e->getMessage()); + continue; } } } @@ -3323,55 +4013,110 @@ public function restore_thematic($sessionId = 0) */ public function restore_attendance($sessionId = 0) { - if ($this->course->has_resources(RESOURCE_ATTENDANCE)) { - $table_attendance = Database::get_course_table(TABLE_ATTENDANCE); - $table_attendance_calendar = Database::get_course_table(TABLE_ATTENDANCE_CALENDAR); + if (!$this->course->has_resources(RESOURCE_ATTENDANCE)) { + $this->debug && error_log('COURSE_DEBUG: restore_attendance: no attendance resources.'); + return; + } - $resources = $this->course->resources; - foreach ($resources[RESOURCE_ATTENDANCE] as $id => $obj) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $obj->params['description'] = DocumentManager::replaceUrlWithNewCourseCode( - $obj->params['description'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] - ); + $em = Database::getManager(); + /** @var CourseEntity $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity((int)$sessionId) : null; - unset($obj->params['id']); - unset($obj->params['iid']); - $obj->params['c_id'] = $this->destination_course_id; - $last_id = Database::insert($table_attendance, $obj->params); - - if (is_numeric($last_id)) { - $sql = "UPDATE $table_attendance SET id = iid WHERE iid = $last_id"; - Database::query($sql); - - $this->course->resources[RESOURCE_ATTENDANCE][$id]->destination_id = $last_id; - - /*api_item_property_update( - $this->destination_course_info, - TOOL_ATTENDANCE, - $last_id, - 'AttendanceAdded', - api_get_user_id() - );*/ - - foreach ($obj->attendance_calendar as $attendance_calendar) { - unset($attendance_calendar['id']); - unset($attendance_calendar['iid']); - - $attendance_calendar['attendance_id'] = $last_id; - $attendance_calendar['c_id'] = $this->destination_course_id; - $attendanceCalendarId = Database::insert( - $table_attendance_calendar, - $attendance_calendar - ); + $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + + $resources = $this->course->resources; + + foreach ($resources[RESOURCE_ATTENDANCE] as $legacyId => $att) { + try { + $p = (array)($att->params ?? []); + + $title = trim((string)($p['title'] ?? 'Attendance')); + $desc = (string)($p['description'] ?? ''); + $active = (int)($p['active'] ?? 1); + + if ($desc !== '') { + $desc = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $desc, + $courseEntity, + $backupRoot + ) ?? $desc; + } + + $qualTitle = isset($p['attendance_qualify_title']) ? (string)$p['attendance_qualify_title'] : null; + $qualMax = (int)($p['attendance_qualify_max'] ?? 0); + $weight = (float)($p['attendance_weight'] ?? 0.0); + $locked = (int)($p['locked'] ?? 0); + + $a = new CAttendance(); + $a->setTitle($title) + ->setDescription($desc) + ->setActive($active) + ->setAttendanceQualifyTitle($qualTitle ?? '') + ->setAttendanceQualifyMax($qualMax) + ->setAttendanceWeight($weight) + ->setLocked($locked); + + if (method_exists($a, 'setParent')) { + $a->setParent($courseEntity); + } + if (method_exists($a, 'setCreator')) { + $a->setCreator(api_get_user_entity()); + } + if (method_exists($a, 'addCourseLink')) { + $a->addCourseLink($courseEntity, $sessionEntity); + } + + $em->persist($a); + $em->flush(); + + $this->course->resources[RESOURCE_ATTENDANCE][$legacyId]->destination_id = (int)$a->getIid(); + + $calList = (array)($att->attendance_calendar ?? []); + foreach ($calList as $c) { + if (!is_array($c)) { $c = (array)$c; } - $sql = "UPDATE $table_attendance_calendar SET id = iid WHERE iid = $attendanceCalendarId"; - Database::query($sql); + $rawDt = (string)($c['date_time'] ?? $c['dateTime'] ?? $c['start_date'] ?? ''); + try { + $dt = $rawDt !== '' ? new \DateTime($rawDt) : new \DateTime('now', new \DateTimeZone('UTC')); + } catch (\Throwable) { + $dt = new \DateTime('now', new \DateTimeZone('UTC')); + } + + $done = (bool)($c['done_attendance'] ?? $c['doneAttendance'] ?? false); + $blocked = (bool)($c['blocked'] ?? false); + $duration = isset($c['duration']) ? (int)$c['duration'] : null; + + $cal = new CAttendanceCalendar(); + $cal->setAttendance($a) + ->setDateTime($dt) + ->setDoneAttendance($done) + ->setBlocked($blocked) + ->setDuration($duration); + + $em->persist($cal); + $em->flush(); + + $groupId = (int)($c['group_id'] ?? 0); + if ($groupId > 0) { + try { + $repo = $em->getRepository(CAttendanceCalendarRelGroup::class); + if (method_exists($repo, 'addGroupToCalendar')) { + $repo->addGroupToCalendar((int)$cal->getIid(), $groupId); + } + } catch (\Throwable $e) { + $this->debug && error_log('COURSE_DEBUG: restore_attendance: calendar group link skipped: '.$e->getMessage()); + } } } + + $em->flush(); + $this->debug && error_log('COURSE_DEBUG: restore_attendance: created attendance iid='.(int)$a->getIid().' (cal='.count($calList).')'); + + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_attendance: failed: '.$e->getMessage()); + continue; } } } @@ -3381,228 +4126,437 @@ public function restore_attendance($sessionId = 0) * * @param int $sessionId */ - public function restore_works($sessionId = 0) + public function restore_works(int $sessionId = 0): void { - if ($this->course->has_resources(RESOURCE_WORK)) { - $table = Database::get_course_table(TABLE_STUDENT_PUBLICATION_ASSIGNMENT); + if (!$this->course->has_resources(RESOURCE_WORK)) { + return; + } - $resources = $this->course->resources; - foreach ($resources[RESOURCE_WORK] as $obj) { - // check resources inside html from ckeditor tool and copy correct urls into recipient course - $obj->params['description'] = DocumentManager::replaceUrlWithNewCourseCode( - $obj->params['description'], - $this->course->code, - $this->course->destination_path, - $this->course->backup_path, - $this->course->info['path'] + $em = Database::getManager(); + /** @var CourseEntity $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null; + + $backupRoot = is_string($this->course->backup_path ?? null) ? rtrim($this->course->backup_path, '/') : ''; + + /** @var CStudentPublicationRepository $pubRepo */ + $pubRepo = Container::getStudentPublicationRepository(); + + foreach ($this->course->resources[RESOURCE_WORK] as $legacyId => $obj) { + try { + $p = (array)($obj->params ?? []); + + $title = trim((string)($p['title'] ?? 'Work')); + if ($title === '') { $title = 'Work'; } + + $description = (string)($p['description'] ?? ''); + if ($description !== '') { + $description = ChamiloHelper::rewriteLegacyCourseUrlsToAssets( + $description, + $courseEntity, + $backupRoot + ) ?? $description; + } + + $enableQualification = (bool)($p['enable_qualification'] ?? false); + $addToCalendar = (int)($p['add_to_calendar'] ?? 0) === 1; + $expiresOn = !empty($p['expires_on']) ? new \DateTime($p['expires_on']) : null; + $endsOn = !empty($p['ends_on']) ? new \DateTime($p['ends_on']) : null; + + $weight = isset($p['weight']) ? (float)$p['weight'] : 0.0; + $qualification = isset($p['qualification']) ? (float)$p['qualification'] : 0.0; + $allowText = (int)($p['allow_text_assignment'] ?? 0); + $defaultVisibility = (bool)($p['default_visibility'] ?? 0); + $studentMayDelete = (bool)($p['student_delete_own_publication'] ?? 0); + $extensions = isset($p['extensions']) ? (string)$p['extensions'] : null; + $groupCategoryWorkId = (int)($p['group_category_work_id'] ?? 0); + $postGroupId = (int)($p['post_group_id'] ?? 0); + + $existingQb = $pubRepo->findAllByCourse( + $courseEntity, + $sessionEntity, + $title, + null, + 'folder' ); + $existing = $existingQb + ->andWhere('resource.publicationParent IS NULL') + ->andWhere('resource.active IN (0,1)') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if (!$existing) { + $pub = new CStudentPublication(); + $pub->setTitle($title) + ->setDescription($description) + ->setFiletype('folder') + ->setContainsFile(0) + ->setWeight($weight) + ->setQualification($qualification) + ->setAllowTextAssignment($allowText) + ->setDefaultVisibility($defaultVisibility) + ->setStudentDeleteOwnPublication($studentMayDelete) + ->setExtensions($extensions) + ->setGroupCategoryWorkId($groupCategoryWorkId) + ->setPostGroupId($postGroupId); + + if (method_exists($pub, 'setParent')) { + $pub->setParent($courseEntity); + } + if (method_exists($pub, 'setCreator')) { + $pub->setCreator(api_get_user_entity()); + } + if (method_exists($pub, 'addCourseLink')) { + $pub->addCourseLink($courseEntity, $sessionEntity); + } - $id_work = $obj->params['id']; - $obj->params['id'] = null; - $obj->params['c_id'] = $this->destination_course_info['real_id']; + $em->persist($pub); + $em->flush(); - // re-create dir - // @todo check security against injection of dir in crafted course backup here! - $path = $obj->params['url']; - $path = '/'.str_replace('/', '', substr($path, 1)); + // Assignment + $assignment = new CStudentPublicationAssignment(); + $assignment->setPublication($pub) + ->setEnableQualification($enableQualification || $qualification > 0); - $workData = []; + if ($expiresOn) { $assignment->setExpiresOn($expiresOn); } + if ($endsOn) { $assignment->setEndsOn($endsOn); } - switch ($this->file_option) { - case FILE_SKIP: - $workData = get_work_data_by_path( - $path, - $this->destination_course_info['real_id'] - ); - if (!empty($workData)) { - break; - } + $em->persist($assignment); + $em->flush(); - break; - case FILE_OVERWRITE: - if (!empty($this->course_origin_id)) { - $sql = 'SELECT * FROM '.$table.' - WHERE - c_id = '.$this->course_origin_id.' AND - publication_id = '.$id_work; - $result = Database::query($sql); - $cant = Database::num_rows($result); - if ($cant > 0) { - $row = Database::fetch_assoc($result); + // Calendar (URL “Chamilo 2”: Router/UUID) + if ($addToCalendar) { + $eventTitle = sprintf(get_lang('Handing over of task %s'), $pub->getTitle()); + + // URL por UUID o Router + $publicationUrl = null; + $uuid = $pub->getResourceNode()?->getUuid(); + if ($uuid) { + if (property_exists($this, 'router') && $this->router instanceof RouterInterface) { + try { + $publicationUrl = $this->router->generate( + 'student_publication_view', + ['uuid' => (string) $uuid], + UrlGeneratorInterface::ABSOLUTE_PATH + ); + } catch (\Throwable) { + $publicationUrl = '/r/student_publication/'. $uuid; + } + } else { + $publicationUrl = '/r/student_publication/'. $uuid; } - - $obj->params['enableExpiryDate'] = empty($row['expires_on']) ? false : true; - $obj->params['enableEndDate'] = empty($row['ends_on']) ? false : true; - $obj->params['expires_on'] = $row['expires_on']; - $obj->params['ends_on'] = $row['ends_on']; - $obj->params['enable_qualification'] = $row['enable_qualification']; - $obj->params['add_to_calendar'] = !empty($row['add_to_calendar']) ? 1 : 0; } - //no break - case FILE_RENAME: - $workData = get_work_data_by_path( - $path, - $this->destination_course_info['real_id'] + + $content = sprintf( + '
%s
%s', + $publicationUrl + ? sprintf('%s', $publicationUrl, $pub->getTitle()) + : htmlspecialchars($pub->getTitle(), ENT_QUOTES), + $pub->getDescription() ); - break; - } + $start = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC')); + $end = $expiresOn ? clone $expiresOn : new \DateTime('now', new \DateTimeZone('UTC')); - $obj->params['work_title'] = $obj->params['title']; - $obj->params['new_dir'] = $obj->params['title']; + $color = CCalendarEvent::COLOR_STUDENT_PUBLICATION; + if ($colors = api_get_setting('agenda.agenda_colors')) { + if (!empty($colors['student_publication'])) { + $color = $colors['student_publication']; + } + } - if (empty($workData)) { - $workId = addDir( - $obj->params, - api_get_user_id(), - $this->destination_course_info, - 0, - $sessionId - ); - $this->course->resources[RESOURCE_WORK][$id_work]->destination_id = $workId; + $event = (new CCalendarEvent()) + ->setTitle($eventTitle) + ->setContent($content) + ->setParent($courseEntity) + ->setCreator($pub->getCreator()) + ->addLink(clone $pub->getFirstResourceLink()) + ->setStartDate($start) + ->setEndDate($end) + ->setColor($color); + + $em->persist($event); + $em->flush(); + + $assignment->setEventCalendarId((int)$event->getIid()); + $em->flush(); + } + + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int)$pub->getIid(); } else { - $workId = $workData['iid']; - updateWork( - $workId, - $obj->params, - $this->destination_course_info, - $sessionId - ); - updatePublicationAssignment( - $workId, - $obj->params, - $this->destination_course_info, - 0 - ); - $this->course->resources[RESOURCE_WORK][$id_work]->destination_id = $workId; + $existing + ->setDescription($description) + ->setWeight($weight) + ->setQualification($qualification) + ->setAllowTextAssignment($allowText) + ->setDefaultVisibility($defaultVisibility) + ->setStudentDeleteOwnPublication($studentMayDelete) + ->setExtensions($extensions) + ->setGroupCategoryWorkId($groupCategoryWorkId) + ->setPostGroupId($postGroupId); + + $em->persist($existing); + $em->flush(); + + $assignment = $existing->getAssignment(); + if (!$assignment) { + $assignment = new CStudentPublicationAssignment(); + $assignment->setPublication($existing); + $em->persist($assignment); + } + + $assignment->setEnableQualification($enableQualification || $qualification > 0); + $assignment->setExpiresOn($expiresOn); + $assignment->setEndsOn($endsOn); + if (!$addToCalendar) { + $assignment->setEventCalendarId(0); + } + $em->flush(); + + $this->course->resources[RESOURCE_WORK][$legacyId]->destination_id = (int)$existing->getIid(); } + } catch (\Throwable $e) { + error_log('COURSE_DEBUG: restore_works: '.$e->getMessage()); + continue; } } } - /** - * Restore gradebook. - * - * @param int $sessionId - * - * @return bool - */ - public function restore_gradebook($sessionId = 0) + + public function restore_gradebook(int $sessionId = 0): void { - if (in_array($this->file_option, [FILE_SKIP, FILE_RENAME])) { - return false; + if (\in_array($this->file_option, [FILE_SKIP, FILE_RENAME], true)) { + return; } - // if overwrite - if ($this->course->has_resources(RESOURCE_GRADEBOOK)) { - $resources = $this->course->resources; - $destinationCourseCode = $this->destination_course_info['code']; - // Delete destination gradebook - $cats = Category::load( - null, - null, - api_get_course_int_id($destinationCourseCode), - null, - null, - $sessionId - ); - if (!empty($cats)) { - /** @var Category $cat */ - foreach ($cats as $cat) { - $cat->delete_all(); - } + if (!$this->course->has_resources(RESOURCE_GRADEBOOK)) { + $this->dlog('restore_gradebook: no gradebook resources'); + return; + } + + /** @var EntityManagerInterface $em */ + $em = \Database::getManager(); + + /** @var Course $courseEntity */ + $courseEntity = api_get_course_entity($this->destination_course_id); + /** @var SessionEntity|null $sessionEntity */ + $sessionEntity = $sessionId ? api_get_session_entity($sessionId) : null; + /** @var User $currentUser */ + $currentUser = api_get_user_entity(); + + $catRepo = $em->getRepository(GradebookCategory::class); + + // 1) Clean destination (overwrite semantics) + try { + $existingCats = $catRepo->findBy([ + 'course' => $courseEntity, + 'session' => $sessionEntity, + ]); + foreach ($existingCats as $cat) { + $em->remove($cat); // cascades remove evaluations/links } + $em->flush(); + $this->dlog('restore_gradebook: destination cleaned', ['removed' => count($existingCats)]); + } catch (\Throwable $e) { + $this->dlog('restore_gradebook: clean failed (continuing)', ['error' => $e->getMessage()]); + } - /** @var GradeBookBackup $obj */ - foreach ($resources[RESOURCE_GRADEBOOK] as $id => $obj) { - if (!empty($obj->categories)) { - $categoryIdList = []; - /** @var Category $cat */ - foreach ($obj->categories as $cat) { - $cat->set_course_code($destinationCourseCode); - $cat->set_session_id($sessionId); + $oldIdToNewCat = []; + + // 2) First pass: create all categories (no parent yet) + foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) { + $categories = (array) ($gbItem->categories ?? []); + foreach ($categories as $rawCat) { + $c = is_array($rawCat) ? $rawCat : (array) $rawCat; + + $oldId = (int) ($c['id'] ?? $c['iid'] ?? 0); + $title = (string)($c['title'] ?? 'Category'); + $desc = (string)($c['description'] ?? ''); + $weight = (float) ($c['weight'] ?? 0.0); + $visible = (bool) ($c['visible'] ?? true); + $locked = (int) ($c['locked'] ?? 0); + + $new = new GradebookCategory(); + $new->setCourse($courseEntity); + $new->setSession($sessionEntity); + $new->setUser($currentUser); + $new->setTitle($title); + $new->setDescription($desc); + $new->setWeight($weight); + $new->setVisible($visible); + $new->setLocked($locked); + + // Optional fields if present in backup + if (isset($c['generate_certificates'])) { + $new->setGenerateCertificates((bool)$c['generate_certificates']); + } elseif (isset($c['generateCertificates'])) { + $new->setGenerateCertificates((bool)$c['generateCertificates']); + } + if (isset($c['certificate_validity_period'])) { + $new->setCertificateValidityPeriod((int)$c['certificate_validity_period']); + } elseif (isset($c['certificateValidityPeriod'])) { + $new->setCertificateValidityPeriod((int)$c['certificateValidityPeriod']); + } + if (isset($c['is_requirement'])) { + $new->setIsRequirement((bool)$c['is_requirement']); + } elseif (isset($c['isRequirement'])) { + $new->setIsRequirement((bool)$c['isRequirement']); + } + if (isset($c['default_lowest_eval_exclude'])) { + $new->setDefaultLowestEvalExclude((bool)$c['default_lowest_eval_exclude']); + } elseif (isset($c['defaultLowestEvalExclude'])) { + $new->setDefaultLowestEvalExclude((bool)$c['defaultLowestEvalExclude']); + } + if (array_key_exists('minimum_to_validate', $c)) { + $new->setMinimumToValidate((int)$c['minimum_to_validate']); + } elseif (array_key_exists('minimumToValidate', $c)) { + $new->setMinimumToValidate((int)$c['minimumToValidate']); + } + if (array_key_exists('gradebooks_to_validate_in_dependence', $c)) { + $new->setGradeBooksToValidateInDependence((int)$c['gradebooks_to_validate_in_dependence']); + } elseif (array_key_exists('gradeBooksToValidateInDependence', $c)) { + $new->setGradeBooksToValidateInDependence((int)$c['gradeBooksToValidateInDependence']); + } + if (array_key_exists('allow_skills_by_subcategory', $c)) { + $new->setAllowSkillsBySubcategory((int)$c['allow_skills_by_subcategory']); + } elseif (array_key_exists('allowSkillsBySubcategory', $c)) { + $new->setAllowSkillsBySubcategory((int)$c['allowSkillsBySubcategory']); + } + if (!empty($c['grade_model_id'])) { + $gm = $em->find(GradeModel::class, (int)$c['grade_model_id']); + if ($gm) { $new->setGradeModel($gm); } + } - $parentId = $cat->get_parent_id(); - if (!empty($parentId)) { - if (isset($categoryIdList[$parentId])) { - $cat->set_parent_id($categoryIdList[$parentId]); - } - } - $oldId = $cat->get_id(); - $categoryId = $cat->add(); - $categoryIdList[$oldId] = $categoryId; - if (!empty($cat->evaluations)) { - /** @var Evaluation $evaluation */ - foreach ($cat->evaluations as $evaluation) { - $evaluation->set_category_id($categoryId); - $evaluation->set_course_code($destinationCourseCode); - $evaluation->setSessionId($sessionId); - $evaluation->add(); - } - } + $em->persist($new); + $em->flush(); - if (!empty($cat->links)) { - /** @var AbstractLink $link */ - foreach ($cat->links as $link) { - $link->set_category_id($categoryId); - $link->set_course_code($destinationCourseCode); - $link->set_session_id($sessionId); - $import = false; - $itemId = $link->get_ref_id(); - switch ($link->get_type()) { - case LINK_EXERCISE: - $type = RESOURCE_QUIZ; + if ($oldId > 0) { + $oldIdToNewCat[$oldId] = $new; + } + } + } - break; - /*case LINK_DROPBOX: - break;*/ - case LINK_STUDENTPUBLICATION: - $type = RESOURCE_WORK; + // 3) Second pass: wire parents + foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) { + $categories = (array) ($gbItem->categories ?? []); + foreach ($categories as $rawCat) { + $c = is_array($rawCat) ? $rawCat : (array) $rawCat; + $oldId = (int)($c['id'] ?? $c['iid'] ?? 0); + $parentOld = (int)($c['parent_id'] ?? $c['parentId'] ?? 0); + if ($oldId > 0 && isset($oldIdToNewCat[$oldId]) && $parentOld > 0 && isset($oldIdToNewCat[$parentOld])) { + $cat = $oldIdToNewCat[$oldId]; + $cat->setParent($oldIdToNewCat[$parentOld]); + $em->persist($cat); + } + } + } + $em->flush(); + + // 4) Evaluations + Links + foreach ($this->course->resources[RESOURCE_GRADEBOOK] as $gbItem) { + $categories = (array) ($gbItem->categories ?? []); + foreach ($categories as $rawCat) { + $c = is_array($rawCat) ? $rawCat : (array) $rawCat; + $oldId = (int)($c['id'] ?? $c['iid'] ?? 0); + if ($oldId <= 0 || !isset($oldIdToNewCat[$oldId])) { continue; } + + $dstCat = $oldIdToNewCat[$oldId]; + + // Evaluations + foreach ((array)($c['evaluations'] ?? []) as $rawEval) { + $e = is_array($rawEval) ? $rawEval : (array) $rawEval; + + $eval = new GradebookEvaluation(); + $eval->setCourse($courseEntity); + $eval->setCategory($dstCat); + $eval->setTitle((string)($e['title'] ?? 'Evaluation')); + $eval->setDescription((string)($e['description'] ?? '')); + $eval->setWeight((float)($e['weight'] ?? 0.0)); + $eval->setMax((float)($e['max'] ?? 100.0)); + $eval->setType((string)($e['type'] ?? 'manual')); + $eval->setVisible((int)($e['visible'] ?? 1)); + $eval->setLocked((int)($e['locked'] ?? 0)); + + if (isset($e['best_score'])) { $eval->setBestScore((float)$e['best_score']); } + if (isset($e['average_score'])) { $eval->setAverageScore((float)$e['average_score']); } + if (isset($e['score_weight'])) { $eval->setScoreWeight((float)$e['score_weight']); } + if (isset($e['min_score'])) { $eval->setMinScore((float)$e['min_score']); } + + $em->persist($eval); + } - break; - case LINK_LEARNPATH: - $type = RESOURCE_LEARNPATH; + // Links + foreach ((array)($c['links'] ?? []) as $rawLink) { + $l = is_array($rawLink) ? $rawLink : (array) $rawLink; - break; - case LINK_FORUM_THREAD: - $type = RESOURCE_FORUMTOPIC; + $linkType = (int)($l['type'] ?? $l['link_type'] ?? 0); + $legacyRef = (int)($l['ref_id'] ?? $l['refId'] ?? 0); + if ($linkType <= 0 || $legacyRef <= 0) { + $this->dlog('restore_gradebook: skipping link (missing type/ref)', $l); + continue; + } - break; - case LINK_ATTENDANCE: - $type = RESOURCE_ATTENDANCE; + $resourceType = $this->gb_guessResourceTypeByLinkType($linkType); + $newRefId = $this->gb_resolveDestinationId($resourceType, $legacyRef); + if ($newRefId <= 0) { + $this->dlog('restore_gradebook: skipping link (no destination id)', ['type' => $linkType, 'legacyRef' => $legacyRef]); + continue; + } - break; - case LINK_SURVEY: - $type = RESOURCE_ATTENDANCE; + $link = new GradebookLink(); + $link->setCourse($courseEntity); + $link->setCategory($dstCat); + $link->setType($linkType); + $link->setRefId($newRefId); + $link->setWeight((float)($l['weight'] ?? 0.0)); + $link->setVisible((int)($l['visible'] ?? 1)); + $link->setLocked((int)($l['locked'] ?? 0)); + + if (isset($l['best_score'])) { $link->setBestScore((float)$l['best_score']); } + if (isset($l['average_score'])) { $link->setAverageScore((float)$l['average_score']); } + if (isset($l['score_weight'])) { $link->setScoreWeight((float)$l['score_weight']); } + if (isset($l['min_score'])) { $link->setMinScore((float)$l['min_score']); } + + $em->persist($link); + } - break; - case LINK_HOTPOTATOES: - $type = RESOURCE_QUIZ; + $em->flush(); + } + } - break; - } + $this->dlog('restore_gradebook: done'); + } - if ($this->course->has_resources($type) && - isset($this->course->resources[$type][$itemId]) - ) { - $item = $this->course->resources[$type][$itemId]; - if ($item && $item->is_restored()) { - $link->set_ref_id($item->destination_id); - $import = true; - } - } + /** Map GradebookLink type → RESOURCE_* bucket used in $this->course->resources */ + private function gb_guessResourceTypeByLinkType(int $linkType): ?int + { + return match ($linkType) { + LINK_EXERCISE => RESOURCE_QUIZ, + LINK_STUDENTPUBLICATION => RESOURCE_WORK, + LINK_LEARNPATH => RESOURCE_LEARNPATH, + LINK_FORUM_THREAD => RESOURCE_FORUMTOPIC, + LINK_ATTENDANCE => RESOURCE_ATTENDANCE, + LINK_SURVEY => RESOURCE_SURVEY, + LINK_HOTPOTATOES => RESOURCE_QUIZ, + default => null, + }; + } - if ($import) { - $link->add(); - } - } - } - } - } - } - } + /** Given a RESOURCE_* bucket and legacy id, return destination id (if that item was restored) */ + private function gb_resolveDestinationId(?int $type, int $legacyId): int + { + if (null === $type) { return 0; } + if (!$this->course->has_resources($type)) { return 0; } + $bucket = $this->course->resources[$type] ?? []; + if (!isset($bucket[$legacyId])) { return 0; } + $res = $bucket[$legacyId]; + $destId = (int)($res->destination_id ?? 0); + return $destId > 0 ? $destId : 0; } + /** * Restore course assets (not included in documents). */ @@ -3633,109 +4587,4 @@ public function restore_assets() } } } - - /** - * @param string $str - * - * @return string - */ - public function DBUTF8($str) - { - if (UTF8_CONVERT) { - $str = utf8_encode($str); - } - - return $str; - } - - /** - * @param string $str - * - * @return string - */ - public function DBUTF8escapestring($str) - { - if (UTF8_CONVERT) { - $str = utf8_encode($str); - } - - return Database::escape_string($str); - } - - /** - * @param array $array - */ - public function DBUTF8_array($array) - { - if (UTF8_CONVERT) { - foreach ($array as &$item) { - $item = utf8_encode($item); - } - - return $array; - } else { - return $array; - } - } - - /** - * @param int $groupId - * - * @return array - */ - public function checkGroupId($groupId) - { - return GroupManager::get_group_properties($groupId); - } - - /** - * @param string $documentPath - * @param string $webEditorCss - */ - public function fixEditorHtmlContent($documentPath, $webEditorCss = '') - { - $extension = pathinfo(basename($documentPath), PATHINFO_EXTENSION); - - switch ($extension) { - case 'html': - case 'htm': - $contents = file_get_contents($documentPath); - $contents = str_replace( - '{{css_editor}}', - $webEditorCss, - $contents - ); - file_put_contents($documentPath, $contents); - - break; - } - } - - /** - * Check if user exist otherwise use current user. - * - * @param int $userId - * @param bool $returnNull - * - * @return int - */ - private function checkUserId($userId, $returnNull = false) - { - if (!empty($userId)) { - $userInfo = api_get_user_info($userId); - if (empty($userInfo)) { - return api_get_user_id(); - } - } - - if ($returnNull) { - return null; - } - - if (empty($userId)) { - return api_get_user_id(); - } - - return $userId; - } } diff --git a/src/CourseBundle/Component/CourseCopy/Resources/Document.php b/src/CourseBundle/Component/CourseCopy/Resources/Document.php index b40de2b3a5c..088ec41560c 100644 --- a/src/CourseBundle/Component/CourseCopy/Resources/Document.php +++ b/src/CourseBundle/Component/CourseCopy/Resources/Document.php @@ -1,53 +1,33 @@ - */ class Document extends Resource { - public $path; - public $comment; - public $file_type; - public $size; - public $title; + public string $path; + public ?string $comment = null; + public string $file_type; + public string $size; + public string $title; - /** - * Create a new Document. - * - * @param int $id - * @param string $path - * @param string $comment - * @param string $title - * @param string $file_type (DOCUMENT or FOLDER); - * @param int $size - */ - public function __construct($id, $path, $comment, $title, $file_type, $size) + public function __construct($id, $fullPath, $comment, $title, $file_type, $size) { parent::__construct($id, RESOURCE_DOCUMENT); - $this->path = 'document'.$path; - $this->comment = $comment; - $this->title = $title; - $this->file_type = $file_type; - $this->size = $size; + $clean = ltrim((string)$fullPath, '/'); + $this->path = 'document/'.$clean; + $this->comment = $comment ?? ''; + $this->title = (string)$title; + $this->file_type = (string)$file_type; + $this->size = (string)$size; } - /** - * Show this document. - */ public function show() { parent::show(); echo preg_replace('@^document@', '', $this->path); - if (!empty($this->title)) { - if (false === strpos($this->path, $this->title)) { - echo ' - '.$this->title; - } + if (!empty($this->title) && false === strpos($this->path, $this->title)) { + echo ' - '.$this->title; } } } diff --git a/src/CourseBundle/Component/CourseCopy/Resources/ForumCategory.php b/src/CourseBundle/Component/CourseCopy/Resources/ForumCategory.php index 34638dad6c7..4fb6edaf321 100644 --- a/src/CourseBundle/Component/CourseCopy/Resources/ForumCategory.php +++ b/src/CourseBundle/Component/CourseCopy/Resources/ForumCategory.php @@ -4,28 +4,23 @@ namespace Chamilo\CourseBundle\Component\CourseCopy\Resources; -/** - * A forum-category. - * - * @author Bart Mollet - */ class ForumCategory extends Resource { - /** - * Create a new ForumCategory. - */ + public ?string $title = null; + public ?string $description = null; + public function __construct($obj) { parent::__construct($obj->cat_id, RESOURCE_FORUMCATEGORY); $this->obj = $obj; + + $this->title = (string) ($obj->cat_title ?? $obj->title ?? ''); + $this->description = (string) ($obj->cat_comment ?? $obj->description ?? ''); } - /** - * Show this resource. - */ public function show() { parent::show(); - echo $this->obj->title; + echo $this->obj->cat_title ?? $this->obj->title ?? ''; } } diff --git a/src/CourseBundle/Component/CourseCopy/Resources/ForumPost.php b/src/CourseBundle/Component/CourseCopy/Resources/ForumPost.php index 5a5635adb79..fd303963705 100644 --- a/src/CourseBundle/Component/CourseCopy/Resources/ForumPost.php +++ b/src/CourseBundle/Component/CourseCopy/Resources/ForumPost.php @@ -4,28 +4,34 @@ namespace Chamilo\CourseBundle\Component\CourseCopy\Resources; -/** - * A forum-post. - * - * @author Bart Mollet - */ class ForumPost extends Resource { - /** - * Create a new ForumPost. - */ + public ?string $title = null; + public ?string $text = null; + public ?string $poster_name = null; + public function __construct($obj) { parent::__construct($obj->post_id, RESOURCE_FORUMPOST); $this->obj = $obj; + + $this->title = (string)($obj->post_title ?? $obj->title ?? ''); + $this->text = (string)($obj->post_text ?? $obj->text ?? ''); + $this->poster_name = (string)($obj->poster_name ?? ''); } - /** - * Show this resource. - */ public function show() { parent::show(); - echo $this->obj->title.' ('.$this->obj->poster_name.', '.$this->obj->post_date.')'; + + $date = $this->obj->post_date ?? ($this->obj->time ?? null); + $dateStr = $date ? api_convert_and_format_date($date) : ''; + + $extra = $this->poster_name ? $this->poster_name : ''; + if ($dateStr) { + $extra = $extra ? ($extra.', '.$dateStr) : $dateStr; + } + + echo $this->title.($extra ? ' ('.$extra.')' : ''); } } diff --git a/src/CourseBundle/Component/CourseCopy/Resources/ForumTopic.php b/src/CourseBundle/Component/CourseCopy/Resources/ForumTopic.php index c1c726e9b19..9dd98382b06 100644 --- a/src/CourseBundle/Component/CourseCopy/Resources/ForumTopic.php +++ b/src/CourseBundle/Component/CourseCopy/Resources/ForumTopic.php @@ -4,50 +4,37 @@ namespace Chamilo\CourseBundle\Component\CourseCopy\Resources; -/** - * A forum-topic/thread. - * - * @author Bart Mollet - */ class ForumTopic extends Resource { - /** - * Create a new ForumTopic. - */ - /* function ForumTopic($id, $title, $time, $topic_poster_id, $topic_poster_name, $forum_id, $last_post, $replies, $views = 0, $sticky = 0, $locked = 0, - $time_closed = null, $weight = 0, $title_qualify = null, $qualify_max = 0) */ + public ?string $title = null; + public ?string $topic_poster_name = null; + public ?string $title_qualify = null; + public function __construct($obj) { parent::__construct($obj->thread_id, RESOURCE_FORUMTOPIC); $this->obj = $obj; - /* - $this->title = $title; - $this->time = $time; - $this->topic_poster_id = $topic_poster_id; - $this->topic_poster_name = $topic_poster_name; - $this->forum_id = $forum_id; - $this->last_post = $last_post; - $this->replies = $replies; - $this->views = $views; - $this->sticky = $sticky; - $this->locked = $locked; - $this->time_closed = $time_closed; - $this->weight = $weight; - $this->title_qualify = $title_qualify; - $this->qualify_max = $qualify_max; */ + + $this->title = (string)($obj->thread_title ?? $obj->title ?? ''); + $this->topic_poster_name = (string)($obj->thread_poster_name ?? $obj->topic_poster_name ?? ''); + $this->title_qualify = (string)($obj->thread_title_qualify ?? $obj->title_qualify ?? ''); } - /** - * Show this resource. - */ public function show() { parent::show(); - $extra = api_convert_and_format_date($this->obj->thread_date); - if ($this->obj->thread_poster_id) { - $user_info = api_get_user_info($this->obj->thread_poster_id); - $extra = $user_info['complete_name'].', '.$extra; + + $date = $this->obj->thread_date ?? ($this->obj->time ?? null); + $extra = $date ? api_convert_and_format_date($date) : ''; + + if (!empty($this->obj->thread_poster_id)) { + $ui = api_get_user_info($this->obj->thread_poster_id); + $name = $ui['complete_name'] ?? $this->topic_poster_name; + $extra = ($name ? $name.', ' : '').$extra; + } elseif (!empty($this->topic_poster_name)) { + $extra = $this->topic_poster_name.', '.$extra; } - echo $this->obj->title.' ('.$extra.')'; + + echo $this->title.($this->title_qualify ? ' ['.$this->title_qualify.']' : '').($extra ? ' ('.$extra.')' : ''); } } diff --git a/src/CourseBundle/Component/CourseCopy/Resources/Work.php b/src/CourseBundle/Component/CourseCopy/Resources/Work.php index 7148d4b5b7e..1ac00b5f3ec 100644 --- a/src/CourseBundle/Component/CourseCopy/Resources/Work.php +++ b/src/CourseBundle/Component/CourseCopy/Resources/Work.php @@ -1,32 +1,46 @@ + * Work/Assignment/Student publication backup resource wrapper. */ class Work extends Resource { - public $params = []; + /** Raw backup parameters (id, title, description, url, etc.). */ + public array $params = []; - /** - * Create a new Work. - * - * @param array $params - */ - public function __construct($params) + /** Plain properties used by legacy restorer helpers (e.g. to_system_encoding). */ + public string $title = ''; + public string $description = ''; + public ?string $url = null; + + public function __construct(array $params) { - parent::__construct($params['id'], RESOURCE_WORK); - $this->params = $params; + parent::__construct((int)($params['id'] ?? 0), RESOURCE_WORK); + + $this->params = $params; + $this->title = isset($params['title']) ? (string) $params['title'] : ''; + $this->description = isset($params['description']) ? (string) $params['description'] : ''; + $this->url = isset($params['url']) && is_string($params['url']) ? $params['url'] : null; } - public function show() + public function show(): void { parent::show(); - echo $this->params['title']; + echo htmlspecialchars($this->title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Convenience accessor for the backup path, if you prefer not to read $url directly. + */ + public function getBackupPath(): ?string + { + return $this->url + ?? (isset($this->params['url']) && is_string($this->params['url']) ? $this->params['url'] : null); } } diff --git a/src/CourseBundle/Entity/CQuizQuestionOption.php b/src/CourseBundle/Entity/CQuizQuestionOption.php index 8e64ba71db1..b553bddc28c 100644 --- a/src/CourseBundle/Entity/CQuizQuestionOption.php +++ b/src/CourseBundle/Entity/CQuizQuestionOption.php @@ -39,6 +39,11 @@ public function setTitle(string $title): self return $this; } + public function getIid() + { + return $this->iid; + } + /** * Get name. * diff --git a/src/CourseBundle/Entity/CThematicAdvance.php b/src/CourseBundle/Entity/CThematicAdvance.php index d1c2a2a4568..e921817e3b0 100644 --- a/src/CourseBundle/Entity/CThematicAdvance.php +++ b/src/CourseBundle/Entity/CThematicAdvance.php @@ -28,7 +28,7 @@ class CThematicAdvance implements Stringable // extends AbstractResource impleme #[ORM\ManyToOne(targetEntity: CAttendance::class)] #[ORM\JoinColumn(name: 'attendance_id', referencedColumnName: 'iid', onDelete: 'CASCADE')] - protected CAttendance $attendance; + protected ?CAttendance $attendance = null; #[ORM\Column(name: 'content', type: 'text', nullable: true)] protected ?string $content = null;