diff --git a/assets/vue/App.vue b/assets/vue/App.vue index a7bbddab96e..12228fe1e70 100644 --- a/assets/vue/App.vue +++ b/assets/vue/App.vue @@ -27,6 +27,8 @@ /> + + @@ -101,13 +103,43 @@ const showAccessUrlChosserLayout = computed( () => securityStore.isAuthenticated && !securityStore.isAdmin && accessUrlChooserVisible.value, ) +// ---- Embedded context detection (iframe/dialog/picker) ---- +const queryParams = computed(() => new URLSearchParams(window.location.search)) + +const isPickerContext = computed(() => { + const picker = String(queryParams.value.get("picker") || "").toLowerCase() + return picker === "tinymce" || picker === "ckeditor" +}) + +const isIframeContext = computed(() => { + // Safe checks: if cross-origin, accessing window.top can throw. + try { + return window.self !== window.top + } catch (e) { + // If we cannot access window.top, we assume we are inside an iframe. + return true + } +}) + +const isDialogContext = computed(() => { + // allow explicit opt-out via query param. + // Example: ?hideChat=1 + const hideChat = String(queryParams.value.get("hideChat") || "").toLowerCase() + return hideChat === "1" || hideChat === "true" +}) + +const isEmbeddedContext = computed(() => { + // In embedded contexts, we must not render global docked chat to avoid duplicated UI. + return isPickerContext.value || isIframeContext.value || isDialogContext.value +}) + const layout = computed(() => { if (showAccessUrlChosserLayout.value) { return AccessUrlChooserLayout } - const queryParams = new URLSearchParams(window.location.search) - const picker = String(queryParams.get("picker") || "").toLowerCase() + const qp = queryParams.value + const picker = String(qp.get("picker") || "").toLowerCase() // Force EmptyLayout for embedded editor pickers (TinyMCE/CKEditor) if (picker === "tinymce" || picker === "ckeditor") { @@ -119,8 +151,8 @@ const layout = computed(() => { } if ( - (queryParams.has("lp_id") && "view" === queryParams.get("action")) || - (queryParams.has("origin") && "learnpath" === queryParams.get("origin")) + (qp.has("lp_id") && "view" === qp.get("action")) || + (qp.has("origin") && "learnpath" === qp.get("origin")) ) { return EmptyLayout } @@ -244,7 +276,8 @@ const allowGlobalChat = computed(() => { }) const showGlobalChat = computed(() => { - return securityStore.isAuthenticated && allowGlobalChat.value + // Do not render global chat when the app is embedded (iframe/dialog/picker). + return securityStore.isAuthenticated && allowGlobalChat.value && !isEmbeddedContext.value }) watch(forbiddenMsg, (msg) => { diff --git a/assets/vue/views/documents/DocumentForHtmlEditor.vue b/assets/vue/views/documents/DocumentForHtmlEditor.vue index 00de43bfa54..115c9a47f9b 100644 --- a/assets/vue/views/documents/DocumentForHtmlEditor.vue +++ b/assets/vue/views/documents/DocumentForHtmlEditor.vue @@ -17,8 +17,67 @@ type="black" @click="uploadDocumentHandler" /> + +
+
+ + + +
+ Location: + {{ currentFolderLabel }} +
+
+ +
+ + + {{ crumb.title }} + + {{ crumb.title }} + + / + + +
+
{ id, title, parentId, resourceTypeTitle } } }, created() { this.filters.loadNode = 1 }, - mounted() { + async mounted() { this.filters.loadNode = 1 + await this.refreshNavigation() this.onUpdateOptions(this.options) }, + watch: { + async "$route.params.node"(newVal, oldVal) { + if (String(newVal) === String(oldVal)) return + + if (!this.options || typeof this.options !== "object" || Array.isArray(this.options)) { + this.options = { page: 1, itemsPerPage: 20 } + } + + this.options.page = 1 + this.filters.loadNode = 1 + + await this.refreshNavigation() + this.onUpdateOptions(this.options) + }, + }, computed: { ...mapGetters("resourcenode", { resourceNode: "getResourceNode", @@ -212,17 +284,33 @@ export default { totalItems: "totalItems", view: "view", }), - pickerType() { const raw = String(this.$route?.query?.type || "files").toLowerCase() return ["files", "images", "media"].includes(raw) ? raw : "files" }, - + uploadAccept() { + if (this.pickerType === "images") return "image/*" + if (this.pickerType === "media") return "image/*,video/*,audio/*" + return "*/*" + }, filteredItems() { const type = this.pickerType const list = Array.isArray(this.items) ? this.items : [] return list.filter((entry) => this.matchesPickerFilter(entry, type)) }, + canGoUp() { + return Array.isArray(this.folderTrail) && this.folderTrail.length > 1 + }, + isAtRoot() { + const nodeId = this.getCurrentNodeId() + const rootId = this.getRootNodeId() + return !!rootId && String(nodeId) === String(rootId) + }, + currentFolderLabel() { + if (this.currentFolderTitle) return this.currentFolderTitle + const last = this.folderTrail?.[this.folderTrail.length - 1] + return last?.title || `#${this.getCurrentNodeId()}` + }, }, methods: { sortingChanged(event) { @@ -247,7 +335,6 @@ export default { return String(entry?.resourceNode?.firstResourceFile?.mimeType || "").toLowerCase() }, matchesPickerFilter(entry, type) { - // Always keep folders for navigation if (this.isFolderEntry(entry)) return true if (type === "files") return true @@ -263,6 +350,175 @@ export default { return true }, + // ---- ID normalization (supports numeric, "123", or "/api/resource_nodes/123") ---- + normalizeNodeId(value) { + if (value == null) return null + if (typeof value === "number" && Number.isFinite(value) && value > 0) return value + + if (typeof value === "string") { + const s = value.trim() + if (!s) return null + if (/^\d+$/.test(s)) return Number(s) + + const m = s.match(/\/api\/resource_nodes\/(\d+)/) + if (m) return Number(m[1]) + } + + if (typeof value === "object") { + const id = value?.id ?? value?.["@id"] + return this.normalizeNodeId(id) + } + + return null + }, + + getCurrentNodeId() { + const raw = this.$route?.params?.node + const n = this.normalizeNodeId(raw) + return n || 1 + }, + + getContextIds() { + const q = this.$route?.query || {} + const cid = this.normalizeNodeId(q.cid) || 0 + const sid = this.normalizeNodeId(q.sid) || 0 + const gid = this.normalizeNodeId(q.gid) || 0 + return { cid, sid, gid } + }, + + getRootNodeId() { + return this.trailRootId || this.folderTrail?.[0]?.id || null + }, + + // ---- Navigation: build breadcrumb from real ResourceNode parents ---- + async fetchNodeInfo(nodeId) { + const id = this.normalizeNodeId(nodeId) + if (!id) return null + + if (this.nodeCache?.has(id)) { + return this.nodeCache.get(id) + } + + try { + const resp = await fetch(`/api/resource_nodes/${id}`, { + headers: { Accept: "application/ld+json" }, + credentials: "same-origin", + }) + + if (!resp.ok) { + console.warn("[DOC PICKER] Failed to fetch resource node info", { id, status: resp.status }) + return null + } + + const data = await resp.json() + + const title = String(data?.title || "").trim() || `#${id}` + + // parent can be IRI or embedded object + const parentRaw = data?.parent + const parentId = this.normalizeNodeId(parentRaw) + + // resourceType can be embedded; we only need title to detect course root + const rtTitle = + String(data?.resourceType?.title || data?.resourceType || "") + .trim() + .toLowerCase() || "" + + const info = { id, title, parentId, resourceTypeTitle: rtTitle } + this.nodeCache.set(id, info) + return info + } catch (e) { + console.warn("[DOC PICKER] Resource node fetch error", e) + return null + } + }, + + async refreshNavigation() { + const currentId = this.getCurrentNodeId() + + const chain = [] + let cursor = currentId + let safety = 0 + + while (cursor && safety < 30) { + const info = await this.fetchNodeInfo(cursor) + if (!info) break + + chain.push({ id: info.id, title: info.title, parentId: info.parentId, rt: info.resourceTypeTitle }) + + // Stop when we reach the course root (or when there's no parent) + if (!info.parentId) break + if (info.resourceTypeTitle === "courses") break + + cursor = info.parentId + safety++ + } + + // If we did not include a "courses" node but we have parents, keep walking until parentId is null. + // (Already handled above) + + chain.reverse() + + // Ensure we always have at least the current node + if (!chain.length) { + chain.push({ id: currentId, title: `#${currentId}` }) + } + + this.folderTrail = chain.map((x) => ({ id: x.id, title: x.title })) + this.trailRootId = this.folderTrail[0]?.id || currentId + this.currentFolderTitle = this.folderTrail[this.folderTrail.length - 1]?.title || `#${currentId}` + }, + + // ---- Navigation actions ---- + navigateToNode(nodeId) { + const id = this.normalizeNodeId(nodeId) + if (!id) return + + this.$router.push({ + name: this.$route.name, + params: { + ...this.$route.params, + node: id, + }, + query: { + ...this.$route.query, + }, + }) + }, + + goToNode(nodeId) { + this.navigateToNode(nodeId) + }, + + goToRoot() { + const rootId = this.getRootNodeId() + if (!rootId) return + this.navigateToNode(rootId) + }, + + goUpOneLevel() { + if (!this.canGoUp) return + const prev = this.folderTrail[this.folderTrail.length - 2] + if (!prev?.id) return + this.navigateToNode(prev.id) + }, + + async handleClick(entry) { + if (!entry?.resourceNode) { + console.warn("[DOC PICKER] Invalid folder entry clicked") + return + } + + // Folder navigation + if (!entry.resourceNode.firstResourceFile) { + const nextNodeId = this.normalizeNodeId(entry.resourceNode.id || entry.resourceNode["@id"]) + if (!nextNodeId) { + console.warn("[DOC PICKER] Invalid next folder id") + return + } + this.navigateToNode(nextNodeId) + } + }, openNew() { this.item = {} this.submitted = false @@ -272,28 +528,84 @@ export default { this.itemDialog = false this.submitted = false }, - saveItem() { + async saveItem() { this.submitted = true - if (this.item.title?.trim()) { - if (!this.item.id) { - this.item.filetype = "folder" - this.item.parentResourceNodeId = this.$route.params.node - this.item.resourceLinkList = JSON.stringify([ - { - gid: this.$route.query.gid, - sid: this.$route.query.sid, - cid: this.$route.query.cid, - visibility: RESOURCE_LINK_PUBLISHED, - }, - ]) - this.create(this.item) - this.showMessage("Saved") - } + if (!this.item.title?.trim()) return + + const { cid, sid, gid } = this.getContextIds() + + try { + this.item.filetype = "folder" + this.item.parentResourceNodeId = this.getCurrentNodeId() + this.item.resourceLinkList = JSON.stringify([ + { + gid, + sid, + cid, + visibility: RESOURCE_LINK_PUBLISHED, + }, + ]) + + await this.create(this.item) + + this.showMessage("Saved") this.itemDialog = false this.item = {} + + // Refresh list and breadcrumb + await this.refreshNavigation() + this.onUpdateOptions(this.options) + } catch (e) { + console.error("[DOC PICKER] Failed to create folder:", e) + } + }, + uploadDocumentHandler() { + try { + const el = this.uploadInput + if (el && typeof el.click === "function") { + el.click() + } + } catch (e) { + console.warn("[DOC PICKER] Upload input click failed", e) } }, + async handleUploadSelected(e) { + const file = e?.target?.files?.[0] + if (!file) return + + const { cid, sid, gid } = this.getContextIds() + + const payload = { + title: file.name, + filetype: "file", + uploadFile: file, // createWithFormData should map this to multipart "uploadFile" + parentResourceNodeId: this.getCurrentNodeId(), + resourceLinkList: JSON.stringify([ + { + gid, + sid, + cid, + visibility: RESOURCE_LINK_PUBLISHED, + }, + ]), + } + + try { + await this.create(payload) + this.showMessage("Saved") + + // Clear input so selecting the same file again triggers change + try { + e.target.value = "" + } catch {} + + await this.refreshNavigation() + this.onUpdateOptions(this.options) + } catch (err) { + console.error("[DOC PICKER] Upload failed:", err) + } + }, toAbsoluteUrl(url) { const raw = String(url || "").trim() if (!raw) return "" @@ -307,7 +619,6 @@ export default { const url = this.toAbsoluteUrl(item?.contentUrl) if (!url) return - // 1) Preferred: direct callback registry (most reliable) try { const qs = new URLSearchParams(window.location.search) const cbId = qs.get("cbId") @@ -317,7 +628,6 @@ export default { registry[cbId](url) delete registry[cbId] - // Close TinyMCE dialog if possible if (parent?.tinymce?.activeEditor?.windowManager) { parent.tinymce.activeEditor.windowManager.close() } @@ -327,7 +637,6 @@ export default { console.warn("[DOC MANAGER] Callback registry failed", e) } - // 2) Fallback: postMessage (keep compatibility) const payload = { mceAction: "fileSelected", content: { url } } try { @@ -337,14 +646,12 @@ export default { window.parent.postMessage({ url }, "*") } catch {} - // Close TinyMCE dialog if possible try { if (parent?.tinymce?.activeEditor?.windowManager) { parent.tinymce.activeEditor.windowManager.close() } } catch {} - // CKEditor legacy support function getUrlParam(paramName) { const reParam = new RegExp("(?:[\\?&]|&)" + paramName + "=([^&]+)", "i") const match = window.location.search.match(reParam) @@ -365,7 +672,7 @@ export default { if (this.items.length === this.totalItems) return this.isBusy = true let currentPage = this.options.page - currentPage++ // keep existing paging behavior + currentPage++ this.options.page = currentPage await this.fetchNewItems(this.options) this.isBusy = false diff --git a/assets/vue/views/documents/DocumentsList.vue b/assets/vue/views/documents/DocumentsList.vue index 7f8cda580b3..13f720877d1 100644 --- a/assets/vue/views/documents/DocumentsList.vue +++ b/assets/vue/views/documents/DocumentsList.vue @@ -1123,20 +1123,45 @@ function normalizeResourceNodeId(value) { return null } +function getDocumentsRootNodeId() { + // We want the top node of this documents tree (usually the course root node). + let node = resourceNode.value + + const fallback = normalizeResourceNodeId(route.params.node || route.query.node) + + if (!node) { + return fallback + } + + // If current node is already the course root, use it. + if (node?.resourceType?.title === "courses") { + return normalizeResourceNodeId(node.id) || fallback + } + + // Walk up until we reach the course root node. + let safety = 0 + while (node?.parent && node?.resourceType?.title !== "courses" && safety < 30) { + node = node.parent + safety++ + } + + return normalizeResourceNodeId(node?.id) || fallback +} + async function fetchFolders(nodeId = null, parentPath = "") { - const startId = normalizeResourceNodeId(nodeId || route.params.node || route.query.node) + const rootId = normalizeResourceNodeId(nodeId) ?? getDocumentsRootNodeId() const foldersList = [ { label: t("Documents"), - value: startId || "root-node-id", + value: rootId, // REAL root node id }, ] try { - let nodesToFetch = [{ id: startId, path: parentPath }] + let nodesToFetch = [{ id: rootId, path: parentPath }] let depth = 0 - const maxDepth = 5 + const maxDepth = 10 while (nodesToFetch.length > 0 && depth < maxDepth) { const currentNode = nodesToFetch.shift() @@ -1152,9 +1177,9 @@ async function fetchFolders(nodeId = null, parentPath = "") { loadNode: 1, filetype: ["folder"], "resourceNode.parent": currentNodeId, - cid, - sid, - gid, + cid: unref(cid), + sid: unref(sid), + gid: unref(gid), page: 1, itemsPerPage: 200, }, @@ -1174,7 +1199,7 @@ async function fetchFolders(nodeId = null, parentPath = "") { foldersList.push({ label: fullPath, - value: folderNodeId, // ALWAYS numeric + value: folderNodeId, }) nodesToFetch.push({ id: folderNodeId, path: fullPath }) @@ -1191,8 +1216,8 @@ async function fetchFolders(nodeId = null, parentPath = "") { } async function loadAllFolders() { - // Keep your behavior: start from current node. - folders.value = await fetchFolders() + const rootId = getDocumentsRootNodeId() + folders.value = await fetchFolders(rootId) } async function openMoveDialog(document) { @@ -1211,12 +1236,25 @@ async function moveDocument() { return } + // Optional: avoid no-op moves + const currentParentId = + normalizeResourceNodeId(item.value?.resourceNode?.parent?.id) ?? + normalizeResourceNodeId(item.value?.resourceNode?.parent) + + if (currentParentId && String(currentParentId) === String(parentId)) { + notification.showErrorNotification(t("The document is already in this folder")) + return + } + await axios.put( `/api/documents/${item.value.iid}/move`, { parentResourceNodeId: parentId }, { - // IMPORTANT: backend needs context to move the correct resource_link - params: { cid, sid, gid }, + params: { + cid: unref(cid), + sid: unref(sid), + gid: unref(gid), + }, }, ) diff --git a/src/CoreBundle/Controller/Api/BaseResourceFileAction.php b/src/CoreBundle/Controller/Api/BaseResourceFileAction.php index 8867e2668ea..06987716f4e 100644 --- a/src/CoreBundle/Controller/Api/BaseResourceFileAction.php +++ b/src/CoreBundle/Controller/Api/BaseResourceFileAction.php @@ -156,7 +156,8 @@ protected function handleCreateRequest(AbstractResource $resource, ResourceRepos if (!empty($contentData)) { $contentData = json_decode($contentData, true); $title = $contentData['title'] ?? ''; - $parentResourceNodeId = (int) ($contentData['parentResourceNodeId'] ?? 0); + $rawParent = $contentData['parentResourceNodeId'] ?? 0; + $parentResourceNodeId = (int) ($this->normalizeNodeId($rawParent) ?? 0); $resourceLinkList = $contentData['resourceLinkList'] ?? []; if (empty($resourceLinkList)) { $resourceLinkList = $contentData['resourceLinkListFromEntity'] ?? []; @@ -164,7 +165,8 @@ protected function handleCreateRequest(AbstractResource $resource, ResourceRepos } else { $contentData = $request->request->all(); $title = $request->get('title'); - $parentResourceNodeId = (int) $request->get('parentResourceNodeId'); + $rawParent = $request->get('parentResourceNodeId'); + $parentResourceNodeId = (int) ($this->normalizeNodeId($rawParent) ?? 0); $resourceLinkList = $request->get('resourceLinkList', []); if (!empty($resourceLinkList)) { $resourceLinkList = !str_contains($resourceLinkList, '[') ? json_decode('['.$resourceLinkList.']', true) : json_decode($resourceLinkList, true); @@ -196,9 +198,6 @@ protected function handleCreateRequest(AbstractResource $resource, ResourceRepos return $contentData; } - /** - * Handles the creation logic for a student publication comment resource. - */ public function handleCreateCommentRequest( AbstractResource $resource, ResourceRepository $resourceRepository, @@ -208,7 +207,8 @@ public function handleCreateCommentRequest( ?TranslatorInterface $translator = null ): array { $title = $request->get('comment', ''); - $parentResourceNodeId = (int) $request->get('parentResourceNodeId'); + $rawParent = $request->get('parentResourceNodeId'); + $parentResourceNodeId = (int) ($this->normalizeNodeId($rawParent) ?? 0); $fileType = $request->get('filetype'); $uploadedFile = null; @@ -235,9 +235,6 @@ public function handleCreateCommentRequest( ]; } - /** - * Function loaded when creating a resource using the api, then the ResourceListener is executed. - */ public function handleCreateFileRequest( AbstractResource $resource, ResourceRepository $resourceRepository, @@ -252,13 +249,15 @@ public function handleCreateFileRequest( $contentData = json_decode($contentData, true); $title = $contentData['title'] ?? ''; $comment = $contentData['comment'] ?? ''; - $parentResourceNodeId = (int) ($contentData['parentResourceNodeId'] ?? 0); + $rawParent = $contentData['parentResourceNodeId'] ?? 0; + $parentResourceNodeId = (int) ($this->normalizeNodeId($rawParent) ?? 0); $fileType = $contentData['filetype'] ?? ''; $resourceLinkList = $contentData['resourceLinkList'] ?? []; } else { $title = $request->get('title'); $comment = $request->get('comment'); - $parentResourceNodeId = (int) $request->get('parentResourceNodeId'); + $rawParent = $request->get('parentResourceNodeId'); + $parentResourceNodeId = (int) ($this->normalizeNodeId($rawParent) ?? 0); $fileType = $request->get('filetype'); $resourceLinkList = $request->get('resourceLinkList', []); if (!empty($resourceLinkList)) { @@ -289,7 +288,7 @@ public function handleCreateFileRequest( $content = $request->request->get('contentFile'); } $fileParsed = false; - // File upload. + if ($request->files->count() > 0) { if (!$request->files->has('uploadFile')) { throw new BadRequestHttpException('"uploadFile" is required'); @@ -303,9 +302,7 @@ public function handleCreateFileRequest( throw new InvalidArgumentException('title is required'); } - // Handle the appropriate action based on the fileExistsOption if (!empty($fileExistsOption)) { - // Check if a document with the same title and parent resource node already exists $existingDocument = $resourceRepository->findByTitleAndParentResourceNode($title, $parentResourceNodeId); if ($existingDocument) { if ('overwrite' === $fileExistsOption) { @@ -337,8 +334,7 @@ public function handleCreateFileRequest( } if ('rename' == $fileExistsOption) { - // Perform actions when file exists and 'rename' option is selected - $newTitle = $this->generateUniqueTitle($title); // Generate a unique title + $newTitle = $this->generateUniqueTitle($title); $resource->setResourceName($newTitle); $resource->setUploadFile($uploadedFile); if (!empty($resourceLinkList)) { @@ -347,7 +343,6 @@ public function handleCreateFileRequest( $em->persist($resource); $em->flush(); - // Return any data you need for further processing return [ 'title' => $newTitle, 'filetype' => $fileType, @@ -356,9 +351,6 @@ public function handleCreateFileRequest( } if ('nothing' == $fileExistsOption) { - // Perform actions when file exists and 'nothing' option is selected - // Display a message indicating that the file already exists - // or perform any other desired actions based on your application's requirements $resource->setResourceName($title); $flashBag = $request->getSession()->getFlashBag(); $message = $translator ? $translator->trans('The operation is impossible, a file with this name already exists.') : 'Upload already exists'; @@ -376,7 +368,6 @@ public function handleCreateFileRequest( } } - // Get data in content and create a HTML file. if (!$fileParsed && $content) { $uploadedFile = CreateUploadedFileHelper::fromString($title.'.html', 'text/html', $content); $resource->setUploadFile($uploadedFile); @@ -393,12 +384,10 @@ public function handleCreateFileRequest( break; } - // Set resource link list if exists. if (!empty($resourceLinkList)) { $resource->setResourceLinkArray($resourceLinkList); } - // Detect if file is a video $filetypeResult = $fileType; if (isset($uploadedFile) && $uploadedFile instanceof UploadedFile) { @@ -416,17 +405,28 @@ public function handleCreateFileRequest( ]; } - protected function handleCreateFileRequestUncompress(AbstractResource $resource, Request $request, EntityManager $em, KernelInterface $kernel): array - { - // Get the parameters from the request - $parentResourceNodeId = (int) $request->get('parentResourceNodeId'); + protected function handleCreateFileRequestUncompress( + AbstractResource $resource, + Request $request, + EntityManager $em, + KernelInterface $kernel + ): array { + // Accept both numeric IDs and IRIs like /api/resource_nodes/{id} + $rawParent = $request->get('parentResourceNodeId'); + $parentResourceNodeId = (int) ($this->normalizeNodeId($rawParent) ?? 0); + $fileType = $request->get('filetype'); $resourceLinkList = $request->get('resourceLinkList', []); if (!empty($resourceLinkList)) { - $resourceLinkList = !str_contains($resourceLinkList, '[') ? json_decode('['.$resourceLinkList.']', true) : json_decode($resourceLinkList, true); - if (empty($resourceLinkList)) { - $message = 'resourceLinkList is not a valid json. Use for example: [{"cid":1, "visibility":1}]'; + // Keep backward compatibility: allow string JSON or array + if (\is_string($resourceLinkList)) { + $resourceLinkList = !str_contains($resourceLinkList, '[') + ? json_decode('['.$resourceLinkList.']', true) + : json_decode($resourceLinkList, true); + } + if (empty($resourceLinkList) || !\is_array($resourceLinkList)) { + $message = 'resourceLinkList is not a valid json. Use for example: [{"cid":1, "visibility":1}]'; throw new InvalidArgumentException($message); } } @@ -439,7 +439,7 @@ protected function handleCreateFileRequestUncompress(AbstractResource $resource, throw new Exception('parentResourceNodeId int value needed'); } - if ('file' == $fileType && $request->files->count() > 0) { + if ('file' === $fileType && $request->files->count() > 0) { if (!$request->files->has('uploadFile')) { throw new BadRequestHttpException('"uploadFile" is required'); } @@ -449,12 +449,22 @@ protected function handleCreateFileRequestUncompress(AbstractResource $resource, $resource->setResourceName($resourceTitle); $resource->setUploadFile($uploadedFile); - if ('zip' === $uploadedFile->getClientOriginalExtension()) { - // Extract the files and subdirectories + // If it is a ZIP, extract and create documents/folders preserving the same links. + if ('zip' === strtolower((string) $uploadedFile->getClientOriginalExtension())) { $extractedData = $this->extractZipFile($uploadedFile, $kernel); $folderStructure = $extractedData['folderStructure']; $extractPath = $extractedData['extractPath']; - $documents = $this->saveZipContentsAsDocuments($folderStructure, $em, $resourceLinkList, $parentResourceNodeId, '', $extractPath, $processedItems); + + $processedItems = []; + $this->saveZipContentsAsDocuments( + $folderStructure, + $em, + $resourceLinkList, + $parentResourceNodeId, + '', + $extractPath, + $processedItems + ); } } @@ -478,7 +488,7 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos $contentData = json_decode($contentData, true); if (isset($contentData['parentResourceNodeId'])) { - $parentResourceNodeId = (int) $contentData['parentResourceNodeId']; + $parentResourceNodeId = (int) ($this->normalizeNodeId($contentData['parentResourceNodeId']) ?? 0); } $title = $contentData['title'] ?? null; @@ -487,6 +497,12 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos } else { $title = $request->get('title'); $content = $request->request->get('contentFile'); + + // Keep compatibility with form requests + if ($request->query->has('parentResourceNodeId') || $request->request->has('parentResourceNodeId')) { + $rawParent = $request->get('parentResourceNodeId'); + $parentResourceNodeId = (int) ($this->normalizeNodeId($rawParent) ?? 0); + } } // Only update the name when a title is explicitly provided. @@ -502,7 +518,6 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos $hasFile = $resourceNode->hasResourceFile(); if ($hasFile && !empty($content)) { - // The content is updated by the ResourceNodeListener.php $resourceNode->setContent($content); foreach ($resourceNode->getResourceFiles() as $resourceFile) { $resourceFile->setSize(\strlen($content)); @@ -513,15 +528,14 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos $link = null; if (!empty($resourceLinkList)) { foreach ($resourceLinkList as $key => &$linkArray) { - // Find the exact link. $linkId = $linkArray['id'] ?? 0; if (!empty($linkId)) { - /** @var ResourceLink $link */ - $link = $resourceNode->getResourceLinks()->filter( - static fn ($link) => $link->getId() === $linkId + $candidate = $resourceNode->getResourceLinks()->filter( + static fn ($l) => $l instanceof ResourceLink && $l->getId() === $linkId )->first(); - if (null !== $link) { + if ($candidate instanceof ResourceLink) { + $link = $candidate; $link->setVisibility((int) $linkArray['visibility']); unset($resourceLinkList[$key]); @@ -535,26 +549,21 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos } $isRecursive = !$hasFile; - // If it's a folder then change the visibility to the children (that have the same link). - if ($isRecursive && null !== $link) { + if ($isRecursive && $link instanceof ResourceLink) { $repo->copyVisibilityToChildren($resource->getResourceNode(), $link); } - // If a new parent node was provided, update the ResourceNode parent - // and the ResourceLink parent in the current context. if ($parentResourceNodeId > 0) { $parentResourceNode = $em->getRepository(ResourceNode::class)->find($parentResourceNodeId); - if ($parentResourceNode) { + if ($parentResourceNode instanceof ResourceNode) { $resourceNode->setParent($parentResourceNode); } - // Only documents use the hierarchical link structure in this way. if ($resource instanceof CDocument) { /** @var ResourceLinkRepository $linkRepo */ $linkRepo = $em->getRepository(ResourceLink::class); - // Resolve context from query parameters (course/session/group/user). $course = null; $session = null; $group = null; @@ -588,7 +597,7 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos } $parentLink = null; - if ($parentResourceNode) { + if ($parentResourceNode instanceof ResourceNode) { $parentLink = $linkRepo->findParentLinkForContext( $parentResourceNode, $course, @@ -609,7 +618,6 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos ); if (null !== $currentLink) { - // When parentLink is null, the document becomes a root-level item in this context. $currentLink->setParent($parentLink); $em->persist($currentLink); } @@ -621,6 +629,40 @@ protected function handleUpdateRequest(AbstractResource $resource, ResourceRepos return $resource; } + private function normalizeNodeId(mixed $value): ?int + { + if (\is_int($value)) { + return $value; + } + + if (\is_string($value)) { + $value = trim($value); + + if ('' === $value) { + return null; + } + + if (ctype_digit($value)) { + return (int) $value; + } + + if (preg_match('#/api/resource_nodes/(\d+)#', $value, $m)) { + return (int) $m[1]; + } + } + + if (null === $value) { + return null; + } + + // Last resort: if it is numeric-like + if (is_numeric($value)) { + return (int) $value; + } + + return null; + } + private function saveZipContentsAsDocuments(array $folderStructure, EntityManager $em, $resourceLinkList = [], $parentResourceId = null, $currentPath = '', $extractPath = '', &$processedItems = []): array { $documents = []; diff --git a/src/CoreBundle/Controller/Api/MoveDocumentAction.php b/src/CoreBundle/Controller/Api/MoveDocumentAction.php index a72d859740d..c41f7a92a0c 100644 --- a/src/CoreBundle/Controller/Api/MoveDocumentAction.php +++ b/src/CoreBundle/Controller/Api/MoveDocumentAction.php @@ -13,6 +13,7 @@ use Chamilo\CoreBundle\Repository\ResourceLinkRepository; use Chamilo\CourseBundle\Entity\CDocument; use Chamilo\CourseBundle\Entity\CGroup; +use DateTime; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -58,13 +59,23 @@ public function __invoke(CDocument $document, Request $request): CDocument throw new BadRequestHttpException('Document resource node not found.'); } + if ($docNode->getId() === $destNode->getId()) { + throw new BadRequestHttpException('Cannot move into itself.'); + } + + // Always keep ResourceNode.parent in sync for providers still relying on resourceNode.parent. + $docNode->setParent($destNode); + $docNode->setUpdatedAt(new DateTime()); + $this->em->persist($docNode); + if ($hasContext) { $course = $cid > 0 ? $this->em->getRepository(Course::class)->find($cid) : null; $session = $sid > 0 ? $this->em->getRepository(Session::class)->find($sid) : null; $group = $gid > 0 ? $this->em->getRepository(CGroup::class)->find($gid) : null; - $docLink = $this->linkRepo->findParentLinkForContext( - $docNode, + // Find the current link for THIS resource in THIS context. + $docLink = $this->linkRepo->findLinkForResourceInContext( + $document, $course, $session, $group, @@ -72,6 +83,18 @@ public function __invoke(CDocument $document, Request $request): CDocument null ); + // Backward-compatible fallback (older repos sometimes used node-based lookup). + if (!$docLink instanceof ResourceLink) { + $docLink = $this->linkRepo->findParentLinkForContext( + $docNode, + $course, + $session, + $group, + null, + null + ); + } + if (!$docLink instanceof ResourceLink) { throw new BadRequestHttpException('Document has no link in this context.'); } @@ -86,22 +109,21 @@ public function __invoke(CDocument $document, Request $request): CDocument ); if (!$destLink instanceof ResourceLink) { - throw new BadRequestHttpException('Destination folder has no link in this context.'); - } - - if ($docLink->getId() === $destLink->getId()) { - throw new BadRequestHttpException('Cannot move into itself.'); + // Safer behavior: do not silently "lose" the item in the UI. + // If destination folder has no link in this context, we keep it as root-level in link-tree + // but still update ResourceNode.parent, so the item is not lost for node-based providers. + $docLink->setParent(null); + $this->em->persist($docLink); + $this->em->flush(); + + return $document; } + // Moving folder/file under a folder link. $docLink->setParent($destLink); $this->em->persist($docLink); - $this->em->flush(); - - return $document; } - $docNode->setParent($destNode); - $this->em->persist($docNode); $this->em->flush(); return $document; diff --git a/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php b/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php index c790af8e179..f95c40656f6 100644 --- a/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php +++ b/src/CoreBundle/Controller/Api/ReplaceDocumentFileAction.php @@ -60,20 +60,25 @@ public function __invoke( throw new BadRequestHttpException(\sprintf('Failed to move the file: %s', $e->getMessage())); } - $movedFilePath = $filePath; - if (!file_exists($movedFilePath)) { + if (!file_exists($filePath)) { throw new RuntimeException('The moved file does not exist at the expected location.'); } - $fileSize = filesize($movedFilePath); + + $fileSize = filesize($filePath); $resourceFile->setSize($fileSize); $newFileName = $uploadedFile->getClientOriginalName(); + + // Keep titles consistent: entity title + node title. $document->setTitle($newFileName); + $resourceNode->setTitle($newFileName); + $resourceFile->setOriginalName($newFileName); $resourceNode->setUpdatedAt(new DateTime()); $em->persist($document); + $em->persist($resourceNode); $em->persist($resourceFile); $em->flush(); diff --git a/src/CoreBundle/State/DocumentCollectionStateProvider.php b/src/CoreBundle/State/DocumentCollectionStateProvider.php index 9381cb3a552..4ba2a9d0c3e 100644 --- a/src/CoreBundle/State/DocumentCollectionStateProvider.php +++ b/src/CoreBundle/State/DocumentCollectionStateProvider.php @@ -145,11 +145,17 @@ public function provide(Operation $operation, array $uriVariables = [], array $c ); if (null !== $parentLink) { + // If a contextual parent link exists, we must prioritize rl.parent. + // The rn.parent fallback should only include items that don't have a contextual hierarchy (rl.parent IS NULL), + // otherwise moved items may still appear in the old folder due to rn.parent not changing. $qb ->andWhere( $qb->expr()->orX( 'IDENTITY(rl.parent) = :parentLinkId', - 'IDENTITY(rn.parent) = :parentNodeId' + $qb->expr()->andX( + 'IDENTITY(rn.parent) = :parentNodeId', + 'rl.parent IS NULL' + ) ) ) ->setParameter('parentLinkId', (int) $parentLink->getId())