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())