From ab4c436f1016b7aef160a7c94e7f5bd617bee1fd Mon Sep 17 00:00:00 2001 From: raortegar Date: Sun, 31 Aug 2025 21:44:29 +0200 Subject: [PATCH 1/5] MDL-80447 core_repository: Add upload button to filepicker repository --- lang/en/repository.php | 3 + .../filemanager_modal_generallayout.mustache | 5 + repository/filepicker.js | 218 ++++++++++++++++++ 3 files changed, 226 insertions(+) diff --git a/lang/en/repository.php b/lang/en/repository.php index 62ff53560c8..0d3309f64b4 100644 --- a/lang/en/repository.php +++ b/lang/en/repository.php @@ -117,6 +117,7 @@ $string['errordoublereference'] = 'Unable to overwrite file with a link because $string['errornotyourfile'] = 'You can only pick files which you added.'; $string['erroruniquename'] = 'Repository instance name should be unique'; $string['errorpostmaxsize'] = 'The file you tried to upload is too large for the server to process.'; +$string['erroruploadfailed'] = 'File upload failed. Please check your files and try again.'; $string['errorwhilecommunicatingwith'] = 'Error while communicating with the repository \'{$a}\'.'; $string['errorwhiledownload'] = 'An error occurred while downloading the file: {$a}'; $string['existingrepository'] = 'This repository already exists'; @@ -244,6 +245,8 @@ $string['type'] = 'Type'; $string['typenotvisible'] = 'Type not visible'; $string['unknownoriginal'] = 'Unknown'; $string['upload'] = 'Upload this file'; +$string['uploaderror'] = 'File upload error'; +$string['uploadfilesdrop'] = 'Drag and drop a file to upload, or click to select'; $string['uploading'] = 'Uploading...'; $string['uploadsucc'] = 'The file has been uploaded successfully'; $string['unknownsource'] = 'Unknown source'; diff --git a/lib/templates/filemanager_modal_generallayout.mustache b/lib/templates/filemanager_modal_generallayout.mustache index 3657e112262..120c555fb22 100644 --- a/lib/templates/filemanager_modal_generallayout.mustache +++ b/lib/templates/filemanager_modal_generallayout.mustache @@ -56,6 +56,11 @@ {{#pix}}a/setting{{/pix}} +
+ + {{#pix}}i/upload{{/pix}} + +
{{#pix}}a/help{{/pix}} diff --git a/repository/filepicker.js b/repository/filepicker.js index a13248961e5..115302e09be 100644 --- a/repository/filepicker.js +++ b/repository/filepicker.js @@ -1624,6 +1624,8 @@ M.core_filepicker.init = function(Y, options) { this.active_repo.message = (data.message || ''); this.active_repo.help = data.help?data.help:null; this.active_repo.manage = data.manage?data.manage:null; + this.active_repo.uploadfile = data.uploadfile ? data.uploadfile : null; + this.active_repo.uploadurl = data.uploadurl ? data.uploadurl : null; // Warning message related to the file reference option, if applicable to the given repository. this.active_repo.filereferencewarning = data.filereferencewarning ? data.filereferencewarning : null; this.print_header(); @@ -1987,6 +1989,219 @@ M.core_filepicker.init = function(Y, options) { managelnk.simulate('click') }); + // New Upload functionality. + toolbar.one('.fp-tb-uploadfile').one('a,button').on('click', function(e) { + e.preventDefault(); + var repoId = this.active_repo.id; + var contextId = this.options.context.id; + var draftidRef = {value: null}; // Store draftid between uploads. + var uploadurl = this.active_repo.uploadurl; + + require([ + 'core/modal_factory', + 'core/modal_events', + 'core/dropzone', + 'core/notification', + 'core/str', + 'core/templates' + ], + function(ModalFactory, ModalEvents, DropZone, Notification, Str, Templates) { + // Create a Modal box with a dropzone where files can be uploaded. + function openUploadModal() { + Templates.render('core/dropzone', {}).then(function(bodyHtml) { + ModalFactory.create({ + type: ModalFactory.types.SAVE_CANCEL, + title: Str.get_string('upload'), + body: bodyHtml, + large: true + }).then(function(modal) { + modal.getRoot().on(ModalEvents.shown, function() { + initDropzone(modal); + }); + modal.getRoot().on(ModalEvents.hidden, function() { + modal.destroy(); + }); + modal.getRoot().on(ModalEvents.save, function(ev) { + commitFiles(ev, modal, toolbar); + }); + modal.getRoot().on(ModalEvents.cancel, function() { + modal.hide(); + }); + modal.show(); + }); + }); + } + openUploadModal(); + + // Create dropzone inside the modal. + function initDropzone(modal) { + const $body = modal.getBody(); + + const dropzoneContainer = $body.find('.dropzone-container').get(0); + const dz = new DropZone(dropzoneContainer, '*', function(files) { + handleDroppedFiles(files, modal); + }); + + // Asynchronously load and set the label. + Str.get_string('dropfiles', 'repository').then(function(label) { + dz.setLabel(label); + }); + + dz.init(); + } + + // Handle file upload to save it as draft. + function handleDroppedFiles(files, modal) { + var currentIndex = 0; + + function uploadNextFile() { + if (currentIndex >= files.length) { + return; + } + + var file = files[currentIndex]; + currentIndex++; + + var formData = new FormData(); + formData.append('repo_upload_file', file); + formData.append('repo_id', repoId); + formData.append('contextid', contextId); + formData.append('sesskey', M.cfg.sesskey); + formData.append('action', 'upload'); + + if (draftidRef.value) { + formData.append('itemid', draftidRef.value); + } + + var xhr = new XMLHttpRequest(); + xhr.open('POST', M.cfg.wwwroot + uploadurl, true); + + xhr.onload = function() { + var response = JSON.parse(this.responseText); + if (response.error) { + Notification.alert( + Str.get_string('uploaderror', 'repository'), + response.error, + Str.get_string('close', 'repository') + ); + } else { + if (response.draftid && !draftidRef.value) { + draftidRef.value = response.draftid; + } + renderUploadedFile(modal, response); + + // Upload next file after this one finishes. + uploadNextFile(); + } + }; + + xhr.send(formData); + } + + // Start uploading files. + uploadNextFile(); + } + + // Render the uploaded file in a given modal. + function renderUploadedFile(modal, response) { + const $body = modal.getBody(); + let uploadFilesClass = 'uploaded-files'; + let $fileList = $body.find(`.${uploadFilesClass} ul`); + + if ($fileList.length === 0) { + // Load the string asynchronously. + Str.get_string('attachedfiles', 'repository').then(function(label) { + const uploadedFiles = document.createElement('div'); + uploadedFiles.className = uploadFilesClass + ' pt-4'; + const headingElement = document.createElement('h5'); + headingElement.textContent = label; + uploadedFiles.appendChild(headingElement); + uploadedFiles.appendChild(document.createElement('ul')); + $body.append(uploadedFiles); + + $fileList = $body.find(`.${uploadFilesClass} ul`); + $fileList.append(`
  • ${response.file}
  • `); + }); + } + + $fileList.append(`
  • ${response.file}
  • `); + } + + // Commit uploaded draft files when user clicks save. + function commitFiles(ev, modal, toolbar) { + ev.preventDefault(); + + if (!draftidRef.value) { + Notification.alert( + Str.get_string('uploaderror', 'repository'), + Str.get_string('nofilesattached', 'repository'), + Str.get_string('close', 'repository') + ); + return; + } + + // Show loading spinner using Moodle's core/loading template. + Templates.render('core/loading', {size: 'lg'}).then(function(html) { + // Create a full-page overlay with Moodle classes. + const loadingOverlay = document.createElement('div'); + loadingOverlay.className = 'loading-overlay'; + loadingOverlay.className = 'fixed-top w-100 h-100 d-flex justify-content-center align-items-center'; + loadingOverlay.style.zIndex = '9999'; + loadingOverlay.style.backgroundColor = 'rgba(255, 255, 255, 0.8)'; + + // Create container for the loading spinner. + const loadingContainer = document.createElement('div'); + loadingContainer.className = 'd-flex flex-column align-items-center'; + loadingContainer.innerHTML = html; + + // Add text below the spinner. + Str.get_string('uploading', 'repository').then(function(loadingText) { + const loadingTextElement = document.createElement('p'); + loadingTextElement.className = 'mt-2'; + loadingTextElement.textContent = loadingText; + loadingContainer.appendChild(loadingTextElement); + }); + + loadingOverlay.appendChild(loadingContainer); + document.body.appendChild(loadingOverlay); + + var formData = new FormData(); + formData.append('action', 'commit'); + formData.append('repo_id', repoId); + formData.append('contextid', contextId); + formData.append('sesskey', M.cfg.sesskey); + formData.append('itemid', draftidRef.value); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', M.cfg.wwwroot + uploadurl, true); + + xhr.onload = function() { + // Remove loading overlay. + if (loadingOverlay && loadingOverlay.parentNode) { + loadingOverlay.parentNode.removeChild(loadingOverlay); + } + + let response = JSON.parse(xhr.responseText); + if (response.error) { + Notification.alert( + Str.get_string('uploaderror', 'repository'), + response.error, + Str.get_string('close', 'repository') + ); + } else { + modal.hide(); + var refreshButton = toolbar.one('.fp-tb-refresh').one('a,button'); + if (refreshButton) { + refreshButton.simulate('click'); + } + } + }; + xhr.send(formData); + }); + } + }); + }, this); + // same with .fp-tb-help var helplnk = Y.Node.create('
    '). setAttrs({id:'fp-tb-help-'+client_id+'-link', target:'_blank'}). @@ -2063,6 +2278,9 @@ M.core_filepicker.init = function(Y, options) { enable_tb_control(toolbar.one('.fp-tb-manage'), r.manage); Y.one('#fp-tb-manage-'+client_id+'-link').set('href', r.manage); + // Upload file. + enable_tb_control(toolbar.one('.fp-tb-uploadfile'), r.uploadfile); + // help url enable_tb_control(toolbar.one('.fp-tb-help'), r.help); Y.one('#fp-tb-help-'+client_id+'-link').set('href', r.help); From c8f0320b242c41323ab68fb99c9f45cf3e0d19b7 Mon Sep 17 00:00:00 2001 From: raortegar Date: Sun, 31 Aug 2025 21:51:29 +0200 Subject: [PATCH 2/5] MDL-80447 repository_googledocs: Update Google Drive OAuth2 scope Updated the Google Drive integration to use the drive.file scope instead of the broader drive scope. This change removes support for shared drives, which rely on full access. As a result, unit tests were updated to reflect this behavior by bypassing shared drive checks and expectations. --- files/converter/googledrive/lib.php | 4 +- .../classes/googledocs_content_search.php | 12 +- .../browser/googledocs_drive_content.php | 13 +- .../local/browser/googledocs_root_content.php | 17 +- repository/googledocs/classes/rest.php | 60 +++--- repository/googledocs/lib.php | 181 +++++++++++++----- repository/googledocs/repository_ajax.php | 115 +++++++++++ 7 files changed, 291 insertions(+), 111 deletions(-) create mode 100644 repository/googledocs/repository_ajax.php diff --git a/files/converter/googledrive/lib.php b/files/converter/googledrive/lib.php index 72b3a746119..7a5f00980d5 100644 --- a/files/converter/googledrive/lib.php +++ b/files/converter/googledrive/lib.php @@ -24,6 +24,8 @@ defined('MOODLE_INTERNAL') || die(); +require_once($CFG->libdir . '/google/lib.php'); + /** * Callback to get the required scopes for system account. * @@ -32,7 +34,7 @@ defined('MOODLE_INTERNAL') || die(); */ function fileconverter_googledrive_oauth2_system_scopes(\core\oauth2\issuer $issuer) { if ($issuer->get('id') == get_config('fileconverter_googledrive', 'issuerid')) { - return 'https://www.googleapis.com/auth/drive'; + return Google_Service_Drive::DRIVE_FILE; } return ''; } diff --git a/repository/googledocs/classes/googledocs_content_search.php b/repository/googledocs/classes/googledocs_content_search.php index 37b2a3d3ed9..56b7d5a2434 100644 --- a/repository/googledocs/classes/googledocs_content_search.php +++ b/repository/googledocs/classes/googledocs_content_search.php @@ -48,17 +48,11 @@ class googledocs_content_search extends googledocs_content { 'q' => $q, 'fields' => $fields, 'spaces' => 'drive', + 'supportsAllDrives' => 'true', + 'includeItemsFromAllDrives' => 'true', + 'corpora' => 'allDrives', ]; - // If shared drives exist, include the additional required parameters in order to extend the content search - // into the shared drives area as well. - $response = helper::request($this->service, 'shared_drives_list', []); - if (!empty($response->drives)) { - $params['supportsAllDrives'] = 'true'; - $params['includeItemsFromAllDrives'] = 'true'; - $params['corpora'] = 'allDrives'; - } - // Request the content through the API call. $response = helper::request($this->service, 'list', $params); diff --git a/repository/googledocs/classes/local/browser/googledocs_drive_content.php b/repository/googledocs/classes/local/browser/googledocs_drive_content.php index d0aa240719a..c23a0a5f494 100644 --- a/repository/googledocs/classes/local/browser/googledocs_drive_content.php +++ b/repository/googledocs/classes/local/browser/googledocs_drive_content.php @@ -54,18 +54,11 @@ class googledocs_drive_content extends googledocs_content { 'q' => $q, 'fields' => $fields, 'spaces' => 'drive', + 'supportsAllDrives' => 'true', + 'includeItemsFromAllDrives' => 'true', + 'corpora' => 'allDrives', ]; - // Check whether there are any shared drives. - $response = helper::request($this->service, 'shared_drives_list', []); - if (!empty($response->drives)) { - // To be able to include content from shared drives, we need to enable 'supportsAllDrives' and - // 'includeItemsFromAllDrives'. The Google Drive API requires explicit request for inclusion of content from - // shared drives and also a confirmation that the application is designed to handle files on shared drives. - $params['supportsAllDrives'] = 'true'; - $params['includeItemsFromAllDrives'] = 'true'; - } - // Request the content through the API call. $response = helper::request($this->service, 'list', $params); diff --git a/repository/googledocs/classes/local/browser/googledocs_root_content.php b/repository/googledocs/classes/local/browser/googledocs_root_content.php index 4272a949efa..84f410977e1 100644 --- a/repository/googledocs/classes/local/browser/googledocs_root_content.php +++ b/repository/googledocs/classes/local/browser/googledocs_root_content.php @@ -17,7 +17,6 @@ namespace repository_googledocs\local\browser; use repository_googledocs\googledocs_content; -use repository_googledocs\helper; /** * Utility class for browsing the content within the googledocs repository root. @@ -41,7 +40,7 @@ class googledocs_root_content extends googledocs_content { */ protected function get_contents(string $query): array { // Add 'My drive' folder into the displayed contents. - $contents = [ + return [ (object)[ 'id' => \repository_googledocs::MY_DRIVE_ROOT_ID, 'name' => get_string('mydrive', 'repository_googledocs'), @@ -49,19 +48,5 @@ class googledocs_root_content extends googledocs_content { 'modifiedTime' => '', ], ]; - - // If shared drives exists, include 'Shared drives' folder to the displayed contents. - $response = helper::request($this->service, 'shared_drives_list', []); - - if (!empty($response->drives)) { - $contents[] = (object)[ - 'id' => \repository_googledocs::SHARED_DRIVES_ROOT_ID, - 'name' => get_string('shareddrives', 'repository_googledocs'), - 'mimeType' => 'application/vnd.google-apps.folder', - 'modifiedTime' => '', - ]; - } - - return $contents; } } diff --git a/repository/googledocs/classes/rest.php b/repository/googledocs/classes/rest.php index cc42ac38ea3..2a25fd2f003 100644 --- a/repository/googledocs/classes/rest.php +++ b/repository/googledocs/classes/rest.php @@ -54,43 +54,37 @@ class rest extends \core\oauth2\rest { 'spaces' => PARAM_RAW, 'supportsAllDrives' => PARAM_RAW, 'includeItemsFromAllDrives' => PARAM_RAW, - 'corpora' => PARAM_RAW + 'corpora' => PARAM_RAW, ], - 'response' => 'json' + 'response' => 'json', ], 'get' => [ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}', 'method' => 'get', 'args' => [ 'fields' => PARAM_RAW, - 'fileid' => PARAM_RAW + 'fileid' => PARAM_RAW, + 'supportsAllDrives' => PARAM_RAW, ], - 'response' => 'json' - ], - 'copy' => [ - 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}/copy', - 'method' => 'post', - 'args' => [ - 'fields' => PARAM_RAW, - 'fileid' => PARAM_RAW - ], - 'response' => 'json' + 'response' => 'json', ], 'delete' => [ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}', 'method' => 'delete', 'args' => [ - 'fileid' => PARAM_RAW + 'fileid' => PARAM_RAW, + 'supportsAllDrives' => PARAM_RAW, ], - 'response' => 'json' + 'response' => 'json', ], 'create' => [ 'endpoint' => 'https://www.googleapis.com/drive/v3/files', 'method' => 'post', 'args' => [ - 'fields' => PARAM_RAW + 'fields' => PARAM_RAW, + 'supportsAllDrives' => PARAM_RAW, ], - 'response' => 'json' + 'response' => 'json', ], 'update' => [ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}', @@ -99,9 +93,10 @@ class rest extends \core\oauth2\rest { 'fileid' => PARAM_RAW, 'fields' => PARAM_RAW, 'addParents' => PARAM_RAW, - 'removeParents' => PARAM_RAW + 'removeParents' => PARAM_RAW, + 'supportsAllDrives' => PARAM_RAW, ], - 'response' => 'json' + 'response' => 'json', ], 'create_permission' => [ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}/permissions', @@ -111,8 +106,9 @@ class rest extends \core\oauth2\rest { 'emailMessage' => PARAM_RAW, 'sendNotificationEmail' => PARAM_RAW, 'transferOwnership' => PARAM_RAW, + 'supportsAllDrives' => PARAM_RAW, ], - 'response' => 'json' + 'response' => 'json', ], 'update_permission' => [ 'endpoint' => 'https://www.googleapis.com/drive/v3/files/{fileid}/permissions/{permissionid}', @@ -123,17 +119,25 @@ class rest extends \core\oauth2\rest { 'emailMessage' => PARAM_RAW, 'sendNotificationEmail' => PARAM_RAW, 'transferOwnership' => PARAM_RAW, + 'supportsAllDrives' => PARAM_RAW, ], - 'response' => 'json' + 'response' => 'json', ], - 'shared_drives_list' => [ - 'endpoint' => 'https://www.googleapis.com/drive/v3/drives', - 'method' => 'get', + 'upload' => [ + 'endpoint' => 'https://www.googleapis.com/upload/drive/v3/files', + 'method' => 'post', 'args' => [ - 'pageSize' => PARAM_INT, - 'pageToken' => PARAM_RAW, - 'q' => PARAM_RAW, - 'useDomainAdminAccess' => PARAM_RAW, + 'uploadType' => PARAM_RAW, + 'fields' => PARAM_RAW, + 'supportsAllDrives' => PARAM_RAW, + ], + 'response' => 'headers', + ], + 'upload_content' => [ + 'endpoint' => '{uploadurl}', + 'method' => 'put', + 'args' => [ + 'uploadurl' => PARAM_URL, ], 'response' => 'json', ], diff --git a/repository/googledocs/lib.php b/repository/googledocs/lib.php index 0c810ae71b4..8cc54c60eef 100644 --- a/repository/googledocs/lib.php +++ b/repository/googledocs/lib.php @@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/repository/lib.php'); require_once($CFG->libdir . '/filebrowser/file_browser.php'); +require_once($CFG->libdir . '/google/lib.php'); use repository_googledocs\helper; use repository_googledocs\googledocs_content_search; @@ -53,11 +54,6 @@ class repository_googledocs extends repository { */ private $issuer = null; - /** - * Additional scopes required for drive. - */ - const SCOPES = 'https://www.googleapis.com/auth/drive'; - /** @var string Defines the path node identifier for the repository root. */ const REPOSITORY_ROOT_ID = 'repository_root'; @@ -99,7 +95,7 @@ class repository_googledocs extends repository { * @param moodle_url $overrideurl - Use this url instead of the repo callback. * @return \core\oauth2\client */ - protected function get_user_oauth_client($overrideurl = false) { + public function get_user_oauth_client($overrideurl = false) { if ($this->client) { return $this->client; } @@ -112,7 +108,7 @@ class repository_googledocs extends repository { $returnurl->param('sesskey', sesskey()); } - $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, self::SCOPES, true); + $this->client = \core\oauth2\api::get_user_oauth_client($this->issuer, $returnurl, Google_Service_Drive::DRIVE_FILE, true); return $this->client; } @@ -270,6 +266,13 @@ class repository_googledocs extends repository { $path = helper::build_node_path('repository_root', $pluginname); } + // Make sure the current path points to "My Drive" before listing files. + $mydrive = get_string('mydrive', 'repository_googledocs'); + $mydrivepath = helper::build_node_path('root', $mydrive); + if (!str_contains($path, $mydrivepath)) { + $path = $path . '/' . $mydrivepath; + } + if (!$this->issuer->get('enabled')) { // Empty list of files for disabled repository. return [ @@ -306,7 +309,11 @@ class repository_googledocs extends repository { 'defaultreturntype' => $this->default_returntype(), 'path' => $contentobj->get_navigation(), 'list' => $contentobj->get_content_nodes($query, [$this, 'filter']), - 'manage' => 'https://drive.google.com/', + 'uploadfile' => true, + 'uploadurl' => '/repository/googledocs/repository_ajax.php', + 'repo_id' => $this->id, + 'contextid' => $this->context->id, + 'sesskey' => sesskey(), ]; } @@ -708,10 +715,10 @@ class repository_googledocs extends repository { $storedfile->get_filename(), $forcedownload); $url->param('sesskey', sesskey()); - $param = ($options['embed'] == true) ? false : $url; + $param = (isset($options['embed']) && $options['embed'] == true) ? false : $url; $userauth = $this->get_user_oauth_client($param); if (!$userauth->is_logged_in()) { - if ($options['embed'] == true) { + if (isset($options['embed']) && $options['embed'] == true) { // Due to Same-origin policy, we cannot redirect to googledocs login page. // If the requested file is embed and the user is not logged in, add option to log in using a popup. $this->print_login_popup(['style' => 'margin-top: 250px']); @@ -798,7 +805,7 @@ class repository_googledocs extends repository { * @return stdClass */ protected function get_file_summary(\repository_googledocs\rest $client, $fileid) { - $fields = "id,name,owners,parents"; + $fields = "id,name,owners,parents,mimeType,webContentLink,webViewLink,size,thumbnailLink,iconLink"; $params = [ 'fileid' => $fileid, 'fields' => $fields @@ -806,35 +813,6 @@ class repository_googledocs extends repository { return $client->call('get', $params); } - /** - * Copy a file and return the new file details. A side effect of the copy - * is that the owner will be the account authenticated with this oauth client. - * - * @param \repository_googledocs\rest $client Authenticated client. - * @param string $fileid The file we are copying. - * @param string $name The original filename (don't change it). - * - * @return stdClass file details. - */ - protected function copy_file(\repository_googledocs\rest $client, $fileid, $name) { - $fields = "id,name,mimeType,webContentLink,webViewLink,size,thumbnailLink,iconLink"; - $params = [ - 'fileid' => $fileid, - 'fields' => $fields, - ]; - // Keep the original name (don't put copy at the end of it). - $copyinfo = []; - if (!empty($name)) { - $copyinfo = [ 'name' => $name ]; - } - $fileinfo = $client->call('copy', $params, json_encode($copyinfo)); - if (empty($fileinfo->id)) { - $details = 'Cannot copy file:' . $fileid; - throw new repository_exception('errorwhilecommunicatingwith', 'repository', '', $details); - } - return $fileinfo; - } - /** * Add a writer to the permissions on the file (temporary). * @@ -854,7 +832,7 @@ class repository_googledocs extends repository { 'type' => 'user', 'expirationTime' => $expires->format(DateTime::RFC3339) ]; - $params = ['fileid' => $fileid, 'sendNotificationEmail' => 'false']; + $params = ['fileid' => $fileid, 'sendNotificationEmail' => 'false', 'supportsAllDrives' => 'true']; $response = $client->call('create_permission', $params, json_encode($updateeditor)); if (empty($response->id)) { $details = 'Cannot add user ' . $email . ' as a writer for document: ' . $fileid; @@ -878,7 +856,7 @@ class repository_googledocs extends repository { 'role' => 'writer', 'type' => 'user' ]; - $params = ['fileid' => $fileid, 'sendNotificationEmail' => 'false']; + $params = ['fileid' => $fileid, 'sendNotificationEmail' => 'false', 'supportsAllDrives' => 'true']; $response = $client->call('create_permission', $params, json_encode($updateeditor)); if (empty($response->id)) { $details = 'Cannot add user ' . $email . ' as a writer for document: ' . $fileid; @@ -944,7 +922,7 @@ class repository_googledocs extends repository { 'role' => 'reader', 'allowFileDiscovery' => 'false' ]; - $params = ['fileid' => $fileid]; + $params = ['fileid' => $fileid, 'supportsAllDrives' => 'true']; $response = $client->call('create_permission', $params, json_encode($updateread)); if (empty($response->id) || $response->id != 'anyoneWithLink') { $details = 'Cannot update link sharing for the document: ' . $fileid; @@ -1008,7 +986,6 @@ class repository_googledocs extends repository { $userservice = new repository_googledocs\rest($userauth); $systemservice = new repository_googledocs\rest($systemauth); - // Add Moodle as writer. $this->add_writer_to_file($userservice, $source->id, $systemuseremail); // Now move it to a sensible folder. @@ -1063,8 +1040,40 @@ class repository_googledocs extends repository { } } - // Copy the file so we get a snapshot file owned by Moodle. - $newsource = $this->copy_file($systemservice, $source->id, $source->name); + $originalfile = $this->get_file_summary($userservice, $source->id); + $downloadlink = ''; + if (isset($originalfile->webContentLink)) { + $downloadlink = $originalfile->webContentLink; + } else if (isset($originalfile->webViewLink)) { + $downloadlink = $originalfile->webViewLink; + } else { + // If we don't have a link, we cannot download the file. + throw new repository_exception( + 'errorwhilecommunicatingwith', + 'repository', + '', + 'Cannot download file: ' . $source->name + ); + } + + $downloadedfile = $this->download_file( + $userservice, + $source->id, + $downloadlink, + $originalfile->name + ); + // Upload the user file to the system drive. + $uploaded = $this->upload_file( + $systemservice, + $downloadedfile['path'], + $downloadedfile['newfilename'], + $source->exportformat, + $parentid + ); + // Add the original file owner as a writer to the file. + $this->add_writer_to_file($systemservice, $uploaded->id, $originalfile->owners[0]->emailAddress); + $newsource = $this->get_file_summary($systemservice, $uploaded->id); + // Move the copied file to the correct folder. $this->move_file_from_root_to_folder($systemservice, $newsource->id, $parentid); @@ -1084,6 +1093,84 @@ class repository_googledocs extends repository { return $reference; } + /** + * Uploads a file to Google Docs using the provided REST client. + * + * @param \repository_googledocs\rest $client The REST client for Google Docs API communication. + * @param string $filepath The local path to the file to be uploaded. + * @param string $filename The name to assign to the uploaded file. + * @param string $exportformat The export format for the file (e.g., 'pdf', 'docx'). + * @param string $parentid The ID of the parent folder in Google Drive where the file will be uploaded. + * @return stdClass|coding_exception Returns the response from the Google Docs API after uploading the file. + */ + public function upload_file( + \repository_googledocs\rest $client, + string $filepath, + string $filename, + string $exportformat, + string $parentid + ): stdClass { + $fileinfo = [ + 'name' => $filename, + 'mimeType' => $exportformat, + 'parents' => [$parentid], // We will move it later. + ]; + $params = [ + 'supportsAllDrives' => 'true', // Support shared drives. + 'uploadType' => 'resumable', // Use resumable upload. + ]; + + $headers = $client->call('upload', $params, json_encode($fileinfo)); + + $uploadurl = ''; + // Google returns a location header with the location for the upload. + foreach ($headers as $header) { + if (stripos($header, 'Location:') === 0) { + $uploadurl = trim(substr($header, strpos($header, ':') + 1)); + } + } + + $params = ['uploadurl' => $uploadurl]; + return $client->call('upload_content', $params, file_get_contents($filepath), mime_content_type($filepath)); + } + + /** + * Downloads a file from Google Docs using the provided user service. + * + * @param \repository_googledocs\rest $userservice The user service instance for Google Docs REST API. + * @param string $fileid The ID of the file to download. + * @param string $downloadlink The URL to the downloaded file. + * @param string $originalfilename The file original name + * @return array|repository_exception The downloaded file content or relevant response. + */ + protected function download_file( + \repository_googledocs\rest $userservice, + string $fileid, + string $downloadlink, + string $originalfilename + ): array|repository_exception { + global $CFG; + + // Ensure the file can be downloaded without credentials. + $this->set_file_sharing_anyone_with_link_can_read($userservice, $fileid); + + $tmp = make_request_directory(); + $temppath = $tmp . '/' . $fileid; + $c = new curl(); + $options = ['filepath' => $temppath, 'timeout' => $CFG->repositorygetfiletimeout]; + $result = $c->download_one($downloadlink, null, $options); + if ($result) { + @chmod($temppath, $CFG->filepermissions); + return [ + 'path' => $temppath, + 'url' => $downloadlink, + 'newfilename' => $originalfilename, + ]; + } + + throw new repository_exception('cannotdownload', 'repository'); + } + /** * Get human readable file info from a the reference. * @@ -1223,7 +1310,7 @@ class repository_googledocs extends repository { */ function repository_googledocs_oauth2_system_scopes(\core\oauth2\issuer $issuer) { if ($issuer->get('id') == get_config('googledocs', 'issuerid')) { - return 'https://www.googleapis.com/auth/drive'; + return Google_Service_Drive::DRIVE_FILE; } return ''; } diff --git a/repository/googledocs/repository_ajax.php b/repository/googledocs/repository_ajax.php new file mode 100644 index 00000000000..5feaafccd12 --- /dev/null +++ b/repository/googledocs/repository_ajax.php @@ -0,0 +1,115 @@ +. + +/** + * The Web service script that is called from the filepicker upload. + * + * @package repository_googledocs + * @copyright 2025 Raquel Ortega + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); + +require(__DIR__ . '/../../config.php'); +require_once($CFG->dirroot . '/lib/filelib.php'); +require_once($CFG->dirroot . '/repository/lib.php'); + + +$action = required_param('action', PARAM_ALPHA); +$repoid = optional_param('repo_id', 0, PARAM_INT); +$contextid = optional_param('contextid', 0, PARAM_INT); +$itemid = optional_param('itemid', 0, PARAM_INT); + +require_login(); +if (!confirm_sesskey()) { + die(json_encode(['error' => get_string('invalidsesskey', 'error')])); +} + +$context = context::instance_by_id($contextid, true); + +$repo = repository::get_repository_by_id($repoid, $contextid); +if (!$repo) { + die(json_encode(['error' => get_string('invalidrepositoryid', 'repository')])); +} +// Check permissions. +$repo->check_capability(); +$repo->check_login(); + +$fs = get_file_storage(); +$usercontext = context_user::instance($USER->id); + +switch ($action) { + case 'upload': + // Save the files in the draft area. + $draftid = !empty($itemid) ? $itemid : file_get_unused_draft_itemid(); + $file = $_FILES['repo_upload_file']; + + if (empty($file) || $file['error'] !== UPLOAD_ERR_OK) { + echo json_encode(['error' => get_string('erroruploadfailed', 'repository')]); + break; + } + + $filerecord = [ + 'contextid' => $usercontext->id, + 'component' => 'user', + 'filearea' => 'draft', + 'itemid' => $draftid, + 'filepath' => '/', + 'filename' => clean_param($file['name'], PARAM_FILE), + ]; + + $fs->create_file_from_pathname($filerecord, $file['tmp_name']); + + echo json_encode([ + 'draftid' => $draftid, + 'file' => $filerecord['filename'], + ]); + break; + + case 'commit': + // Upload the files to Google Drive. + if (empty($itemid)) { + die(json_encode(['erroruploadfailed' => get_string('error', 'repository')])); + } + + // Grab files from draft area with the same itemid. + $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $itemid, "id", false); + if (empty($draftfiles)) { + die(json_encode(['erroruploadfailed' => get_string('error', 'repository')])); + } + + // Upload the file to Google Drive repository. + $tmp = make_request_directory(); + $ha = new repository_googledocs($repoid, $context); + $userauth = $ha->get_user_oauth_client(); + $userservice = new repository_googledocs\rest($userauth); + foreach ($draftfiles as $draftfile) { + $tempfile = $tmp . '/' . rand(); + $filename = $draftfile->get_filename(); + $draftfile->copy_content_to($tempfile); + $ha->upload_file($userservice, $tempfile, $filename, 'download', "root"); + } + + // Clear drafts after upload them to Google drive. + $fs->delete_area_files($usercontext->id, 'user', 'draft', $itemid); + + echo json_encode(['success' => true]); + break; + + default: + echo json_encode(['error' => get_string('error', 'repository')]); +} From 8135ded9e32ef573eeb67e0ac2c207af2d0266c7 Mon Sep 17 00:00:00 2001 From: raortegar Date: Sun, 31 Aug 2025 21:52:43 +0200 Subject: [PATCH 3/5] MDL-80447 repository_googledocs: Update Google Drive OAuth2 scope tests --- .../tests/googledocs_search_content_test.php | 69 ++++------- .../browser/googledocs_drive_content_test.php | 107 ++++++++++++------ .../browser/googledocs_root_content_test.php | 61 +++++++--- 3 files changed, 143 insertions(+), 94 deletions(-) diff --git a/repository/googledocs/tests/googledocs_search_content_test.php b/repository/googledocs/tests/googledocs_search_content_test.php index 3c60a23be3c..39bf8fb2a1e 100644 --- a/repository/googledocs/tests/googledocs_search_content_test.php +++ b/repository/googledocs/tests/googledocs_search_content_test.php @@ -34,16 +34,21 @@ final class googledocs_search_content_test extends \googledocs_content_testcase /** * Test get_content_nodes(). * + * @covers \repository_googledocs\googledocs_content_search * @dataProvider get_content_nodes_provider * @param string $query The query string * @param bool $sortcontent Whether the contents should be sorted in alphabetical order * @param array $filterextensions The array containing file extensions that should be disallowed (filtered) - * @param array $shareddrives The array containing the existing shared drives * @param array $searccontents The array containing the fetched google drive contents that match the search criteria * @param array $expected The expected array which contains the generated repository content nodes */ - public function test_get_content_nodes(string $query, bool $sortcontent, array $filterextensions, - array $shareddrives, array $searccontents, array $expected): void { + public function test_get_content_nodes( + string $query, + bool $sortcontent, + array $filterextensions, + array $searccontents, + array $expected + ): void { // Mock the service object. $servicemock = $this->createMock(rest::class); @@ -52,45 +57,27 @@ final class googledocs_search_content_test extends \googledocs_content_testcase 'q' => "fullText contains '" . str_replace("'", "\'", $query) . "' AND trashed = false", 'fields' => 'files(id,name,mimeType,webContentLink,webViewLink,fileExtension,modifiedTime,size,iconLink)', 'spaces' => 'drive', + 'supportsAllDrives' => 'true', + 'includeItemsFromAllDrives' => 'true', + 'corpora' => 'allDrives', ]; - if (!empty($shareddrives)) { - $searchparams['supportsAllDrives'] = 'true'; - $searchparams['includeItemsFromAllDrives'] = 'true'; - $searchparams['corpora'] = 'allDrives'; - } - - // Assert that the call() method is being called twice with the given arguments consecutively. In the first - // instance it is being called to fetch the shared drives (shared_drives_list), while in the second instance - // to fetch the relevant drive contents (list) that match the search criteria. Also, define the returned - // data objects by these calls. - $callinvocations = $this->exactly(2); + // Assert that the call() method is being called to fetch the relevant drive contents (list), + // that match the search criteria. Also, define the returned data objects by these calls. + // This simulates the more restricted drive.file scope behavior. + $callinvocations = $this->exactly(1); $servicemock->expects($callinvocations) ->method('call') ->willReturnCallback(function(string $method, array $params) use ( - $callinvocations, - $shareddrives, $searccontents, $searchparams, ) { - switch (self::getInvocationCount($callinvocations)) { - case 1: - $this->assertEquals('shared_drives_list', $method); + $this->assertEquals('list', $method); + $this->assertEquals($searchparams, $params); - $this->assertEmpty($params); - return (object) [ - 'kind' => 'drive#driveList', - 'nextPageToken' => 'd838181f30b0f5', - 'drives' => $shareddrives, - ]; - case 2: - $this->assertEquals('list', $method); - $this->assertEquals($searchparams, $params); - - return (object) [ - 'files' => $searccontents, - ]; - } + return (object) [ + 'files' => $searccontents, + ]; }); // Construct the node path. @@ -118,14 +105,11 @@ final class googledocs_search_content_test extends \googledocs_content_testcase $searchforstring = get_string('searchfor', 'repository_googledocs'); return [ - 'Folders and files match the search criteria; shared drives exist; ordering applied.' => + 'Folders and files match the search criteria; ordering applied.' => [ 'test', true, [], - [ - self::create_google_drive_shared_drive_object('d85b21c0f86cb5', 'Shared Drive 1'), - ], [ self::create_google_drive_file_object('d85b21c0f86cb0', 'Test file 3.pdf', 'application/pdf', 'pdf', '1000', '', @@ -148,12 +132,11 @@ final class googledocs_search_content_test extends \googledocs_content_testcase 'https://drive.google.com/uc?id=d85b21c0f86cb0&export=download', 'download'), ], ], - 'Only folders match the search criteria; shared drives do not exist; ordering not applied.' => + 'Only folders match the search criteria; ordering not applied.' => [ 'testing', false, [], - [], [ self::create_google_drive_folder_object('0c4ad262c65333', 'Testing folder 3'), self::create_google_drive_folder_object('d85b21c0f86cb0', 'Testing folder 1'), @@ -168,14 +151,11 @@ final class googledocs_search_content_test extends \googledocs_content_testcase "{$rootid}|Google+Drive/{$searchnodeid}|" . urlencode("{$searchforstring} 'testing'")), ], ], - 'Only files match the search criteria; shared drives exist; ordering not applied; filter .doc and .txt.' => + 'Only files match the search criteria; ordering not applied; filter .doc and .txt.' => [ 'root', false, ['doc', 'txt'], - [ - self::create_google_drive_shared_drive_object('d85b21c0f86cb5', 'Shared Drive 1'), - ], [ self::create_google_drive_file_object('d85b21c0f86cb0', 'Testing file 3.pdf', 'application/pdf', 'pdf', '1000'), @@ -190,14 +170,13 @@ final class googledocs_search_content_test extends \googledocs_content_testcase 'https://googleusercontent.com/type/application/pdf', '', 'download'), ], ], - 'No content that matches the search criteria; shared drives do not exist.' => + 'No content that matches the search criteria.' => [ 'root', false, [], [], [], - [], ], ]; } diff --git a/repository/googledocs/tests/local/browser/googledocs_drive_content_test.php b/repository/googledocs/tests/local/browser/googledocs_drive_content_test.php index 8dadb1b37d9..9258a73b408 100644 --- a/repository/googledocs/tests/local/browser/googledocs_drive_content_test.php +++ b/repository/googledocs/tests/local/browser/googledocs_drive_content_test.php @@ -16,6 +16,8 @@ namespace repository_googledocs\local\browser; +use Google_Service_Drive; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -53,46 +55,67 @@ final class googledocs_drive_content_test extends \googledocs_content_testcase { 'q' => "'" . str_replace("'", "\'", $query) . "' in parents AND trashed = false", 'fields' => 'files(id,name,mimeType,webContentLink,webViewLink,fileExtension,modifiedTime,size,iconLink)', 'spaces' => 'drive', + 'supportsAllDrives' => 'true', + 'includeItemsFromAllDrives' => 'true', + 'corpora' => 'allDrives', ]; - if (!empty($shareddrives)) { - $listparams['supportsAllDrives'] = 'true'; - $listparams['includeItemsFromAllDrives'] = 'true'; + if (!$this->shared_drives_supported()) { + $callinvocations = $this->exactly(1); + $servicemock->expects($callinvocations) + ->method('call') + ->willReturnCallback(function ( + string $method, + array $params + ) use ( + $listparams, + $drivecontents, + ) { + $this->assertEquals('list', $method); + $this->assertEquals($listparams, $params); + + return (object) [ + 'files' => $drivecontents, + ]; + }); + } else { + // Assert that the call() method is being called twice with the given arguments consecutively. In the first + // instance it is being called to fetch the shared drives (shared_drives_list), while in the second instance + // to fetch the relevant drive contents (list). Also, define the returned data objects by these calls. + $callinvocations = $this->exactly(2); + $servicemock->expects($callinvocations) + ->method('call') + ->willReturnCallback(function ( + string $method, + array $params + ) use ( + $callinvocations, + $shareddrives, + $listparams, + $drivecontents, + ) { + switch (self::getInvocationCount($callinvocations)) { + case 1: + $this->assertEquals('shared_drives_list', $method); + $this->assertEquals([], $params); + + return (object) [ + 'kind' => 'drive#driveList', + 'nextPageToken' => 'd838181f30b0f5', + 'drives' => $shareddrives, + ]; + case 2: + $this->assertEquals('list', $method); + $this->assertEquals($listparams, $params); + return (object)[ + 'files' => $drivecontents, + ]; + default: + $this->fail('Unexpected call to the call() method.'); + } + }); } - // Assert that the call() method is being called twice with the given arguments consecutively. In the first - // instance it is being called to fetch the shared drives (shared_drives_list), while in the second instance - // to fetch the relevant drive contents (list). Also, define the returned data objects by these calls. - $callinvocations = $this->exactly(2); - $servicemock->expects($callinvocations) - ->method('call') - ->willReturnCallback(function(string $method, array $params) use ( - $callinvocations, - $shareddrives, - $listparams, - $drivecontents, - ) { - switch (self::getInvocationCount($callinvocations)) { - case 1: - $this->assertEquals('shared_drives_list', $method); - $this->assertEquals([], $params); - - return (object) [ - 'kind' => 'drive#driveList', - 'nextPageToken' => 'd838181f30b0f5', - 'drives' => $shareddrives, - ]; - case 2: - $this->assertEquals('list', $method); - $this->assertEquals($listparams, $params); - return (object)[ - 'files' => $drivecontents, - ]; - default: - $this->fail('Unexpected call to the call() method.'); - } - }); - // Set the disallowed file types (extensions). $this->disallowedextensions = $filterextensions; $drivebrowser = new googledocs_drive_content($servicemock, $path, $sortcontent); @@ -272,4 +295,16 @@ final class googledocs_drive_content_test extends \googledocs_content_testcase { ], ]; } + + /** + * Determines whether shared drives are supported under the current Google Drive scope. + * + * @return bool + */ + private function shared_drives_supported(): bool { + $scopes = Google_Service_Drive::DRIVE_FILE; + + // Full access is needed for shared drives (not just drive.file). + return str_contains($scopes, 'auth/drive') && !str_contains($scopes, 'auth/drive.file'); + } } diff --git a/repository/googledocs/tests/local/browser/googledocs_root_content_test.php b/repository/googledocs/tests/local/browser/googledocs_root_content_test.php index 462bcb60320..32b9e178c3e 100644 --- a/repository/googledocs/tests/local/browser/googledocs_root_content_test.php +++ b/repository/googledocs/tests/local/browser/googledocs_root_content_test.php @@ -16,6 +16,8 @@ namespace repository_googledocs\local\browser; +use Google_Service_Drive; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -36,24 +38,43 @@ final class googledocs_root_content_test extends \googledocs_content_testcase { * @dataProvider get_content_nodes_provider * @param array $shareddrives The array containing the existing shared drives * @param array $expected The expected array which contains the generated repository content nodes + * @param bool $expectshared Whether shared drives should be tested + * @covers \repository_googledocs */ - public function test_get_content_nodes(array $shareddrives, array $expected): void { + public function test_get_content_nodes(array $shareddrives, array $expected, bool $expectshared): void { + $scopessupportshared = $this->shared_drives_supported(); + + if ($expectshared && !$scopessupportshared) { + $this->markTestSkipped('Shared drives not supported in current OAuth scope.'); + } + // Mock the service object. $servicemock = $this->createMock(\repository_googledocs\rest::class); - // Assert that the call() method is being called only once with the given arguments to fetch the existing - // shared drives. Define the returned data object by this call. - $servicemock->expects($this->once()) - ->method('call') - ->with('shared_drives_list', []) - ->willReturn((object)[ - 'kind' => 'drive#driveList', - 'nextPageToken' => 'd838181f30b0f5', - 'drives' => $shareddrives, - ]); + if ($expectshared && $scopessupportshared) { + // Expect shared drives API to be called. + // Assert that the call() method is being called only once with the given arguments to fetch the existing + // shared drives. Define the returned data object by this call. + $servicemock->expects($this->once()) + ->method('call') + ->with('shared_drives_list', []) + ->willReturn((object)[ + 'kind' => 'drive#driveList', + 'nextPageToken' => 'd838181f30b0f5', + 'drives' => $shareddrives, + ]); + } else { + // If shared drives are not expected or not supported, call() should not be invoked. + $servicemock->expects($this->never()) + ->method('call'); + } - $rootbrowser = new googledocs_root_content($servicemock, - \repository_googledocs::REPOSITORY_ROOT_ID . '|Google+Drive', false); + $showshared = $expectshared && $scopessupportshared; + $rootbrowser = new googledocs_root_content( + $servicemock, + \repository_googledocs::REPOSITORY_ROOT_ID . '|Google+Drive', + $showshared + ); $contentnodes = $rootbrowser->get_content_nodes('', [$this, 'filter']); // Assert that the returned array of repository content nodes is equal to the expected one. @@ -84,6 +105,7 @@ final class googledocs_root_content_test extends \googledocs_content_testcase { get_string('shareddrives', 'repository_googledocs'), "{$rootid}|Google+Drive"), ], + true, // Expect shared drives. ], 'Shared drives do not exist.' => [ [], @@ -92,7 +114,20 @@ final class googledocs_root_content_test extends \googledocs_content_testcase { get_string('mydrive', 'repository_googledocs'), "{$rootid}|Google+Drive"), ], + false, // Do not expect shared drives. ], ]; } + + /** + * Determines whether shared drives are supported under the current Google Drive scope. + * + * @return bool + */ + private function shared_drives_supported(): bool { + $scopes = Google_Service_Drive::DRIVE_FILE; + + // Full access is needed for shared drives (not just drive.file). + return str_contains($scopes, 'auth/drive') && !str_contains($scopes, 'auth/drive.file'); + } } From f7f1a4562d766276a9ca0bbba64923e48d58a41f Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Mon, 22 Sep 2025 17:54:48 +0700 Subject: [PATCH 4/5] MDL-80447 repository_googledocs: Switch to use core/pubsub --- lang/en/repository.php | 2 - repository/filepicker.js | 229 ++---------------- repository/googledocs/amd/build/upload.min.js | 10 + .../googledocs/amd/build/upload.min.js.map | 1 + repository/googledocs/amd/src/upload.js | 150 ++++++++++++ repository/googledocs/lib.php | 51 ++-- repository/googledocs/repository_ajax.php | 66 ++--- .../templates/upload_dialogue.mustache | 39 +++ 8 files changed, 255 insertions(+), 293 deletions(-) create mode 100644 repository/googledocs/amd/build/upload.min.js create mode 100644 repository/googledocs/amd/build/upload.min.js.map create mode 100644 repository/googledocs/amd/src/upload.js create mode 100644 repository/googledocs/templates/upload_dialogue.mustache diff --git a/lang/en/repository.php b/lang/en/repository.php index 0d3309f64b4..1f0f9d73635 100644 --- a/lang/en/repository.php +++ b/lang/en/repository.php @@ -117,7 +117,6 @@ $string['errordoublereference'] = 'Unable to overwrite file with a link because $string['errornotyourfile'] = 'You can only pick files which you added.'; $string['erroruniquename'] = 'Repository instance name should be unique'; $string['errorpostmaxsize'] = 'The file you tried to upload is too large for the server to process.'; -$string['erroruploadfailed'] = 'File upload failed. Please check your files and try again.'; $string['errorwhilecommunicatingwith'] = 'Error while communicating with the repository \'{$a}\'.'; $string['errorwhiledownload'] = 'An error occurred while downloading the file: {$a}'; $string['existingrepository'] = 'This repository already exists'; @@ -246,7 +245,6 @@ $string['typenotvisible'] = 'Type not visible'; $string['unknownoriginal'] = 'Unknown'; $string['upload'] = 'Upload this file'; $string['uploaderror'] = 'File upload error'; -$string['uploadfilesdrop'] = 'Drag and drop a file to upload, or click to select'; $string['uploading'] = 'Uploading...'; $string['uploadsucc'] = 'The file has been uploaded successfully'; $string['unknownsource'] = 'Unknown source'; diff --git a/repository/filepicker.js b/repository/filepicker.js index 115302e09be..f4669e5746d 100644 --- a/repository/filepicker.js +++ b/repository/filepicker.js @@ -1625,7 +1625,7 @@ M.core_filepicker.init = function(Y, options) { this.active_repo.help = data.help?data.help:null; this.active_repo.manage = data.manage?data.manage:null; this.active_repo.uploadfile = data.uploadfile ? data.uploadfile : null; - this.active_repo.uploadurl = data.uploadurl ? data.uploadurl : null; + this.active_repo.uploadevent = data.uploadevent ? data.uploadevent : null; // Warning message related to the file reference option, if applicable to the given repository. this.active_repo.filereferencewarning = data.filereferencewarning ? data.filereferencewarning : null; this.print_header(); @@ -1989,217 +1989,26 @@ M.core_filepicker.init = function(Y, options) { managelnk.simulate('click') }); - // New Upload functionality. - toolbar.one('.fp-tb-uploadfile').one('a,button').on('click', function(e) { - e.preventDefault(); - var repoId = this.active_repo.id; - var contextId = this.options.context.id; - var draftidRef = {value: null}; // Store draftid between uploads. - var uploadurl = this.active_repo.uploadurl; - - require([ - 'core/modal_factory', - 'core/modal_events', - 'core/dropzone', - 'core/notification', - 'core/str', - 'core/templates' - ], - function(ModalFactory, ModalEvents, DropZone, Notification, Str, Templates) { - // Create a Modal box with a dropzone where files can be uploaded. - function openUploadModal() { - Templates.render('core/dropzone', {}).then(function(bodyHtml) { - ModalFactory.create({ - type: ModalFactory.types.SAVE_CANCEL, - title: Str.get_string('upload'), - body: bodyHtml, - large: true - }).then(function(modal) { - modal.getRoot().on(ModalEvents.shown, function() { - initDropzone(modal); - }); - modal.getRoot().on(ModalEvents.hidden, function() { - modal.destroy(); - }); - modal.getRoot().on(ModalEvents.save, function(ev) { - commitFiles(ev, modal, toolbar); - }); - modal.getRoot().on(ModalEvents.cancel, function() { - modal.hide(); - }); - modal.show(); - }); - }); - } - openUploadModal(); - - // Create dropzone inside the modal. - function initDropzone(modal) { - const $body = modal.getBody(); - - const dropzoneContainer = $body.find('.dropzone-container').get(0); - const dz = new DropZone(dropzoneContainer, '*', function(files) { - handleDroppedFiles(files, modal); - }); - - // Asynchronously load and set the label. - Str.get_string('dropfiles', 'repository').then(function(label) { - dz.setLabel(label); - }); - - dz.init(); - } - - // Handle file upload to save it as draft. - function handleDroppedFiles(files, modal) { - var currentIndex = 0; - - function uploadNextFile() { - if (currentIndex >= files.length) { - return; + // Repository upload. + // If repository supports upload, it needs to provide 'uploadevent' in its response to 'list' command. + // This event will be used to trigger the upload process. + // Repository will need to subscribe to this event and launch its own upload process. + toolbar.one('.fp-tb-uploadfile').one('a,button').on('click', (e) => { + if (this.active_repo.uploadevent) { + e.preventDefault(); + require(['core/pubsub'], (PubSub) => { + PubSub.publish(this.active_repo.uploadevent, { + repoId: this.active_repo.id, + contextId: this.options.context.id, + callback: () => { + // Refresh the file list after upload is done. + if (!this.active_repo.norefresh) { + this.list({path: this.currentpath}); + } } - - var file = files[currentIndex]; - currentIndex++; - - var formData = new FormData(); - formData.append('repo_upload_file', file); - formData.append('repo_id', repoId); - formData.append('contextid', contextId); - formData.append('sesskey', M.cfg.sesskey); - formData.append('action', 'upload'); - - if (draftidRef.value) { - formData.append('itemid', draftidRef.value); - } - - var xhr = new XMLHttpRequest(); - xhr.open('POST', M.cfg.wwwroot + uploadurl, true); - - xhr.onload = function() { - var response = JSON.parse(this.responseText); - if (response.error) { - Notification.alert( - Str.get_string('uploaderror', 'repository'), - response.error, - Str.get_string('close', 'repository') - ); - } else { - if (response.draftid && !draftidRef.value) { - draftidRef.value = response.draftid; - } - renderUploadedFile(modal, response); - - // Upload next file after this one finishes. - uploadNextFile(); - } - }; - - xhr.send(formData); - } - - // Start uploading files. - uploadNextFile(); - } - - // Render the uploaded file in a given modal. - function renderUploadedFile(modal, response) { - const $body = modal.getBody(); - let uploadFilesClass = 'uploaded-files'; - let $fileList = $body.find(`.${uploadFilesClass} ul`); - - if ($fileList.length === 0) { - // Load the string asynchronously. - Str.get_string('attachedfiles', 'repository').then(function(label) { - const uploadedFiles = document.createElement('div'); - uploadedFiles.className = uploadFilesClass + ' pt-4'; - const headingElement = document.createElement('h5'); - headingElement.textContent = label; - uploadedFiles.appendChild(headingElement); - uploadedFiles.appendChild(document.createElement('ul')); - $body.append(uploadedFiles); - - $fileList = $body.find(`.${uploadFilesClass} ul`); - $fileList.append(`
  • ${response.file}
  • `); - }); - } - - $fileList.append(`
  • ${response.file}
  • `); - } - - // Commit uploaded draft files when user clicks save. - function commitFiles(ev, modal, toolbar) { - ev.preventDefault(); - - if (!draftidRef.value) { - Notification.alert( - Str.get_string('uploaderror', 'repository'), - Str.get_string('nofilesattached', 'repository'), - Str.get_string('close', 'repository') - ); - return; - } - - // Show loading spinner using Moodle's core/loading template. - Templates.render('core/loading', {size: 'lg'}).then(function(html) { - // Create a full-page overlay with Moodle classes. - const loadingOverlay = document.createElement('div'); - loadingOverlay.className = 'loading-overlay'; - loadingOverlay.className = 'fixed-top w-100 h-100 d-flex justify-content-center align-items-center'; - loadingOverlay.style.zIndex = '9999'; - loadingOverlay.style.backgroundColor = 'rgba(255, 255, 255, 0.8)'; - - // Create container for the loading spinner. - const loadingContainer = document.createElement('div'); - loadingContainer.className = 'd-flex flex-column align-items-center'; - loadingContainer.innerHTML = html; - - // Add text below the spinner. - Str.get_string('uploading', 'repository').then(function(loadingText) { - const loadingTextElement = document.createElement('p'); - loadingTextElement.className = 'mt-2'; - loadingTextElement.textContent = loadingText; - loadingContainer.appendChild(loadingTextElement); - }); - - loadingOverlay.appendChild(loadingContainer); - document.body.appendChild(loadingOverlay); - - var formData = new FormData(); - formData.append('action', 'commit'); - formData.append('repo_id', repoId); - formData.append('contextid', contextId); - formData.append('sesskey', M.cfg.sesskey); - formData.append('itemid', draftidRef.value); - - var xhr = new XMLHttpRequest(); - xhr.open('POST', M.cfg.wwwroot + uploadurl, true); - - xhr.onload = function() { - // Remove loading overlay. - if (loadingOverlay && loadingOverlay.parentNode) { - loadingOverlay.parentNode.removeChild(loadingOverlay); - } - - let response = JSON.parse(xhr.responseText); - if (response.error) { - Notification.alert( - Str.get_string('uploaderror', 'repository'), - response.error, - Str.get_string('close', 'repository') - ); - } else { - modal.hide(); - var refreshButton = toolbar.one('.fp-tb-refresh').one('a,button'); - if (refreshButton) { - refreshButton.simulate('click'); - } - } - }; - xhr.send(formData); }); - } - }); + }); + } }, this); // same with .fp-tb-help diff --git a/repository/googledocs/amd/build/upload.min.js b/repository/googledocs/amd/build/upload.min.js new file mode 100644 index 00000000000..4165865db19 --- /dev/null +++ b/repository/googledocs/amd/build/upload.min.js @@ -0,0 +1,10 @@ +define("repository_googledocs/upload",["exports","core/pubsub","core/modal_save_cancel","core/str","core/templates","core/modal_events","core/dropzone","core/config","core/notification"],(function(_exports,_pubsub,_modal_save_cancel,_str,_templates,_modal_events,_dropzone,config,_notification){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +/** + * Upload module for Google Docs repository. + * + * @module repository_googledocs/upload + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_modal_save_cancel=_interopRequireDefault(_modal_save_cancel),_templates=_interopRequireDefault(_templates),_modal_events=_interopRequireDefault(_modal_events),_dropzone=_interopRequireDefault(_dropzone),config=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(config),_notification=_interopRequireDefault(_notification);let listenersRegistered=!1,droppedFiles=[];const initDropzone=modal=>{const $body=modal.getBody(),dropzoneContainer=$body.find(".repository_googledocs_dropzone_container").get(0),fileListContainer=$body.find("ul.repository_googledocs_files_list").get(0),dz=new _dropzone.default(dropzoneContainer,"*",(files=>{droppedFiles.push(...files);let fileListHTML="";for(let i=0;i"+droppedFiles[i].name+"";fileListContainer.innerHTML=fileListHTML}));(0,_str.getString)("dropfiles","repository").then((label=>{dz.setLabel(label)})),dz.init()},commitFiles=(repoId,contextId,callback,files,modal)=>{const saveButton=modal.getFooter().find('[data-action="save"]'),formData=new FormData;formData.append("action","upload"),formData.append("repo_id",repoId),formData.append("contextid",contextId),formData.append("sesskey",config.sesskey);for(let i=0;i{listenersRegistered||((0,_pubsub.subscribe)("repository_googledocs_upload",(data=>{(data=>{_templates.default.render("repository_googledocs/upload_dialogue",{}).then((function(bodyHtml){return _modal_save_cancel.default.create({title:(0,_str.getString)("upload"),body:bodyHtml,large:!0}).then((function(modal){return modal.getRoot().on(_modal_events.default.shown,(()=>{droppedFiles=[],initDropzone(modal)})),modal.getRoot().on(_modal_events.default.hidden,(()=>{modal.destroy()})),modal.getRoot().on(_modal_events.default.save,(e=>{e.preventDefault(),commitFiles(data.repoId,data.contextId,data.callback,droppedFiles,modal)})),modal.getRoot().on(_modal_events.default.cancel,(()=>{modal.hide()})),modal.show(),modal}))}))})(data)})),listenersRegistered=!0)};_exports.init=()=>{registerEventListeners()}})); + +//# sourceMappingURL=upload.min.js.map \ No newline at end of file diff --git a/repository/googledocs/amd/build/upload.min.js.map b/repository/googledocs/amd/build/upload.min.js.map new file mode 100644 index 00000000000..2aa8f9e5daf --- /dev/null +++ b/repository/googledocs/amd/build/upload.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"upload.min.js","sources":["../src/upload.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Upload module for Google Docs repository.\n *\n * @module repository_googledocs/upload\n * @copyright 2025 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {subscribe} from 'core/pubsub';\nimport SaveCancelModal from 'core/modal_save_cancel';\nimport {getString} from 'core/str';\nimport Templates from 'core/templates';\nimport ModalEvents from 'core/modal_events';\nimport Dropzone from 'core/dropzone';\nimport * as config from 'core/config';\nimport Notification from 'core/notification';\n\nlet listenersRegistered = false;\nlet droppedFiles = [];\n\n/**\n * Open the upload modal.\n *\n * @param {object} data Data passed from the event\n */\nconst openUploadModal = (data) => {\n Templates.render('repository_googledocs/upload_dialogue', {}).then(function(bodyHtml) {\n return SaveCancelModal.create({\n title: getString('upload'),\n body: bodyHtml,\n large: true,\n }).then(function(modal) {\n modal.getRoot().on(ModalEvents.shown, () => {\n droppedFiles = [];\n initDropzone(modal);\n });\n modal.getRoot().on(ModalEvents.hidden, () => {\n modal.destroy();\n });\n modal.getRoot().on(ModalEvents.save, (e) => {\n e.preventDefault();\n commitFiles(data.repoId, data.contextId, data.callback, droppedFiles, modal);\n });\n modal.getRoot().on(ModalEvents.cancel, () => {\n modal.hide();\n });\n modal.show();\n\n return modal;\n });\n });\n};\n\n/**\n * Initialize the dropzone inside the modal.\n *\n * @param {Modal} modal Modal instance\n */\nconst initDropzone = (modal) => {\n const $body = modal.getBody();\n const dropzoneContainer = $body.find('.repository_googledocs_dropzone_container').get(0);\n const fileListContainer = $body.find('ul.repository_googledocs_files_list').get(0);\n const dz = new Dropzone(dropzoneContainer, '*', (files) => {\n droppedFiles.push(...files);\n let fileListHTML = '';\n for (let i = 0; i < droppedFiles.length; i++) {\n fileListHTML += '
  • ' + droppedFiles[i].name + '
  • ';\n }\n fileListContainer.innerHTML = fileListHTML;\n });\n\n getString('dropfiles', 'repository').then((label) => {\n dz.setLabel(label);\n });\n\n dz.init();\n};\n\n/**\n * Upload files to server.\n *\n * @param {Integer} repoId Repository ID\n * @param {Integer} contextId Context ID\n * @param {function} callback Callback function\n * @param {array} files Files to be uploaded\n * @param {Modal} modal Modal instance\n */\nconst commitFiles = (repoId, contextId, callback, files, modal) => {\n const saveButton = modal.getFooter().find('[data-action=\"save\"]');\n const formData = new FormData();\n formData.append('action', 'upload');\n formData.append('repo_id', repoId);\n formData.append('contextid', contextId);\n formData.append('sesskey', config.sesskey);\n for (let i = 0; i < files.length; i++) {\n formData.append(\"files[]\", files[i]);\n }\n const xhr = new XMLHttpRequest();\n xhr.open('POST', config.wwwroot + '/repository/googledocs/repository_ajax.php', false);\n xhr.onload = function() {\n const response = JSON.parse(xhr.responseText);\n if (response.error) {\n saveButton.removeAttr('disabled');\n Notification.alert(\n getString('uploaderror', 'repository'),\n response.error,\n getString('close', 'repository'),\n );\n } else {\n saveButton.removeAttr('disabled');\n modal.hide();\n callback();\n }\n };\n xhr.send(formData);\n};\n\n/**\n * Register events.\n */\nconst registerEventListeners = () => {\n if (!listenersRegistered) {\n subscribe('repository_googledocs_upload', (data) => {\n openUploadModal(data);\n });\n listenersRegistered = true;\n }\n};\n\n/**\n * Initializes the upload module.\n */\nexport const init = () => {\n registerEventListeners();\n};\n"],"names":["listenersRegistered","droppedFiles","initDropzone","modal","$body","getBody","dropzoneContainer","find","get","fileListContainer","dz","Dropzone","files","push","fileListHTML","i","length","name","innerHTML","then","label","setLabel","init","commitFiles","repoId","contextId","callback","saveButton","getFooter","formData","FormData","append","config","sesskey","xhr","XMLHttpRequest","open","wwwroot","onload","response","JSON","parse","responseText","error","removeAttr","alert","hide","send","registerEventListeners","data","render","bodyHtml","SaveCancelModal","create","title","body","large","getRoot","on","ModalEvents","shown","hidden","destroy","save","e","preventDefault","cancel","show","openUploadModal"],"mappings":";;;;;;;k/BAgCIA,qBAAsB,EACtBC,aAAe,SAwCbC,aAAgBC,cACZC,MAAQD,MAAME,UACdC,kBAAoBF,MAAMG,KAAK,6CAA6CC,IAAI,GAChFC,kBAAoBL,MAAMG,KAAK,uCAAuCC,IAAI,GAC1EE,GAAK,IAAIC,kBAASL,kBAAmB,KAAMM,QAC7CX,aAAaY,QAAQD,WACjBE,aAAe,OACd,IAAIC,EAAI,EAAGA,EAAId,aAAae,OAAQD,IACrCD,cAAgB,OAASb,aAAac,GAAGE,KAAO,QAEpDR,kBAAkBS,UAAYJ,mCAGxB,YAAa,cAAcK,MAAMC,QACvCV,GAAGW,SAASD,UAGhBV,GAAGY,QAYDC,YAAc,CAACC,OAAQC,UAAWC,SAAUd,MAAOT,eAC/CwB,WAAaxB,MAAMyB,YAAYrB,KAAK,wBACpCsB,SAAW,IAAIC,SACrBD,SAASE,OAAO,SAAU,UAC1BF,SAASE,OAAO,UAAWP,QAC3BK,SAASE,OAAO,YAAaN,WAC7BI,SAASE,OAAO,UAAWC,OAAOC,aAC7B,IAAIlB,EAAI,EAAGA,EAAIH,MAAMI,OAAQD,IAC9Bc,SAASE,OAAO,UAAWnB,MAAMG,UAE/BmB,IAAM,IAAIC,eAChBD,IAAIE,KAAK,OAAQJ,OAAOK,QAAU,8CAA8C,GAChFH,IAAII,OAAS,iBACHC,SAAWC,KAAKC,MAAMP,IAAIQ,cAC5BH,SAASI,OACThB,WAAWiB,WAAW,kCACTC,OACT,kBAAU,cAAe,cACzBN,SAASI,OACT,kBAAU,QAAS,iBAGvBhB,WAAWiB,WAAW,YACtBzC,MAAM2C,OACNpB,aAGRQ,IAAIa,KAAKlB,WAMPmB,uBAAyB,KACtBhD,4CACS,gCAAiCiD,OAjG1BA,CAAAA,0BACXC,OAAO,wCAAyC,IAAI/B,MAAK,SAASgC,iBACjEC,2BAAgBC,OAAO,CAC1BC,OAAO,kBAAU,UACjBC,KAAMJ,SACNK,OAAO,IACRrC,MAAK,SAAShB,cACbA,MAAMsD,UAAUC,GAAGC,sBAAYC,OAAO,KAClC3D,aAAe,GACfC,aAAaC,UAEjBA,MAAMsD,UAAUC,GAAGC,sBAAYE,QAAQ,KACnC1D,MAAM2D,aAEV3D,MAAMsD,UAAUC,GAAGC,sBAAYI,MAAOC,IAClCA,EAAEC,iBACF1C,YAAY0B,KAAKzB,OAAQyB,KAAKxB,UAAWwB,KAAKvB,SAAUzB,aAAcE,UAE1EA,MAAMsD,UAAUC,GAAGC,sBAAYO,QAAQ,KACnC/D,MAAM2C,UAEV3C,MAAMgE,OAEChE,aA2EPiE,CAAgBnB,SAEpBjD,qBAAsB,kBAOV,KAChBgD"} \ No newline at end of file diff --git a/repository/googledocs/amd/src/upload.js b/repository/googledocs/amd/src/upload.js new file mode 100644 index 00000000000..0fcabde01f1 --- /dev/null +++ b/repository/googledocs/amd/src/upload.js @@ -0,0 +1,150 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Upload module for Google Docs repository. + * + * @module repository_googledocs/upload + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {subscribe} from 'core/pubsub'; +import SaveCancelModal from 'core/modal_save_cancel'; +import {getString} from 'core/str'; +import Templates from 'core/templates'; +import ModalEvents from 'core/modal_events'; +import Dropzone from 'core/dropzone'; +import * as config from 'core/config'; +import Notification from 'core/notification'; + +let listenersRegistered = false; +let droppedFiles = []; + +/** + * Open the upload modal. + * + * @param {object} data Data passed from the event + */ +const openUploadModal = (data) => { + Templates.render('repository_googledocs/upload_dialogue', {}).then(function(bodyHtml) { + return SaveCancelModal.create({ + title: getString('upload'), + body: bodyHtml, + large: true, + }).then(function(modal) { + modal.getRoot().on(ModalEvents.shown, () => { + droppedFiles = []; + initDropzone(modal); + }); + modal.getRoot().on(ModalEvents.hidden, () => { + modal.destroy(); + }); + modal.getRoot().on(ModalEvents.save, (e) => { + e.preventDefault(); + commitFiles(data.repoId, data.contextId, data.callback, droppedFiles, modal); + }); + modal.getRoot().on(ModalEvents.cancel, () => { + modal.hide(); + }); + modal.show(); + + return modal; + }); + }); +}; + +/** + * Initialize the dropzone inside the modal. + * + * @param {Modal} modal Modal instance + */ +const initDropzone = (modal) => { + const $body = modal.getBody(); + const dropzoneContainer = $body.find('.repository_googledocs_dropzone_container').get(0); + const fileListContainer = $body.find('ul.repository_googledocs_files_list').get(0); + const dz = new Dropzone(dropzoneContainer, '*', (files) => { + droppedFiles.push(...files); + let fileListHTML = ''; + for (let i = 0; i < droppedFiles.length; i++) { + fileListHTML += '
  • ' + droppedFiles[i].name + '
  • '; + } + fileListContainer.innerHTML = fileListHTML; + }); + + getString('dropfiles', 'repository').then((label) => { + dz.setLabel(label); + }); + + dz.init(); +}; + +/** + * Upload files to server. + * + * @param {Integer} repoId Repository ID + * @param {Integer} contextId Context ID + * @param {function} callback Callback function + * @param {array} files Files to be uploaded + * @param {Modal} modal Modal instance + */ +const commitFiles = (repoId, contextId, callback, files, modal) => { + const saveButton = modal.getFooter().find('[data-action="save"]'); + const formData = new FormData(); + formData.append('action', 'upload'); + formData.append('repo_id', repoId); + formData.append('contextid', contextId); + formData.append('sesskey', config.sesskey); + for (let i = 0; i < files.length; i++) { + formData.append("files[]", files[i]); + } + const xhr = new XMLHttpRequest(); + xhr.open('POST', config.wwwroot + '/repository/googledocs/repository_ajax.php', false); + xhr.onload = function() { + const response = JSON.parse(xhr.responseText); + if (response.error) { + saveButton.removeAttr('disabled'); + Notification.alert( + getString('uploaderror', 'repository'), + response.error, + getString('close', 'repository'), + ); + } else { + saveButton.removeAttr('disabled'); + modal.hide(); + callback(); + } + }; + xhr.send(formData); +}; + +/** + * Register events. + */ +const registerEventListeners = () => { + if (!listenersRegistered) { + subscribe('repository_googledocs_upload', (data) => { + openUploadModal(data); + }); + listenersRegistered = true; + } +}; + +/** + * Initializes the upload module. + */ +export const init = () => { + registerEventListeners(); +}; diff --git a/repository/googledocs/lib.php b/repository/googledocs/lib.php index 8cc54c60eef..1b76567dedd 100644 --- a/repository/googledocs/lib.php +++ b/repository/googledocs/lib.php @@ -76,6 +76,7 @@ class repository_googledocs extends repository { * @return void */ public function __construct($repositoryid, $context = SYSCONTEXTID, $options = array(), $readonly = 0) { + global $PAGE; parent::__construct($repositoryid, $context, $options, $readonly = 0); try { @@ -87,6 +88,8 @@ class repository_googledocs extends repository { if ($this->issuer && !$this->issuer->get('enabled')) { $this->disabled = true; } + + $PAGE->requires->js_call_amd('repository_googledocs/upload', 'init'); } /** @@ -310,7 +313,7 @@ class repository_googledocs extends repository { 'path' => $contentobj->get_navigation(), 'list' => $contentobj->get_content_nodes($query, [$this, 'filter']), 'uploadfile' => true, - 'uploadurl' => '/repository/googledocs/repository_ajax.php', + 'uploadevent' => 'repository_googledocs_upload', 'repo_id' => $this->id, 'contextid' => $this->context->id, 'sesskey' => sesskey(), @@ -805,7 +808,7 @@ class repository_googledocs extends repository { * @return stdClass */ protected function get_file_summary(\repository_googledocs\rest $client, $fileid) { - $fields = "id,name,owners,parents,mimeType,webContentLink,webViewLink,size,thumbnailLink,iconLink"; + $fields = "id,name,owners,parents"; $params = [ 'fileid' => $fileid, 'fields' => $fields @@ -1041,26 +1044,11 @@ class repository_googledocs extends repository { } $originalfile = $this->get_file_summary($userservice, $source->id); - $downloadlink = ''; - if (isset($originalfile->webContentLink)) { - $downloadlink = $originalfile->webContentLink; - } else if (isset($originalfile->webViewLink)) { - $downloadlink = $originalfile->webViewLink; - } else { - // If we don't have a link, we cannot download the file. - throw new repository_exception( - 'errorwhilecommunicatingwith', - 'repository', - '', - 'Cannot download file: ' . $source->name - ); - } - + // Use the user service to download the file. $downloadedfile = $this->download_file( $userservice, $source->id, - $downloadlink, - $originalfile->name + $originalfile->name, ); // Upload the user file to the system drive. $uploaded = $this->upload_file( @@ -1068,7 +1056,7 @@ class repository_googledocs extends repository { $downloadedfile['path'], $downloadedfile['newfilename'], $source->exportformat, - $parentid + $parentid, ); // Add the original file owner as a writer to the file. $this->add_writer_to_file($systemservice, $uploaded->id, $originalfile->owners[0]->emailAddress); @@ -1139,31 +1127,28 @@ class repository_googledocs extends repository { * * @param \repository_googledocs\rest $userservice The user service instance for Google Docs REST API. * @param string $fileid The ID of the file to download. - * @param string $downloadlink The URL to the downloaded file. * @param string $originalfilename The file original name * @return array|repository_exception The downloaded file content or relevant response. */ protected function download_file( \repository_googledocs\rest $userservice, string $fileid, - string $downloadlink, string $originalfilename ): array|repository_exception { global $CFG; - // Ensure the file can be downloaded without credentials. - $this->set_file_sharing_anyone_with_link_can_read($userservice, $fileid); + $client = $this->get_user_oauth_client(); + $base = 'https://www.googleapis.com/drive/v3'; + $params = ['alt' => 'media']; + $sourceurl = new moodle_url($base . '/files/' . $fileid, $params); - $tmp = make_request_directory(); - $temppath = $tmp . '/' . $fileid; - $c = new curl(); - $options = ['filepath' => $temppath, 'timeout' => $CFG->repositorygetfiletimeout]; - $result = $c->download_one($downloadlink, null, $options); - if ($result) { - @chmod($temppath, $CFG->filepermissions); + $path = $this->prepare_file($originalfilename); + $options = ['filepath' => $path, 'timeout' => $CFG->repositorygetfiletimeout, 'followlocation' => true, 'maxredirs' => 5]; + $success = $client->download_one($sourceurl->out(false), null, $options); + if ($success) { + @chmod($path, $CFG->filepermissions); return [ - 'path' => $temppath, - 'url' => $downloadlink, + 'path' => $path, 'newfilename' => $originalfilename, ]; } diff --git a/repository/googledocs/repository_ajax.php b/repository/googledocs/repository_ajax.php index 5feaafccd12..55e53c4e4e0 100644 --- a/repository/googledocs/repository_ajax.php +++ b/repository/googledocs/repository_ajax.php @@ -49,64 +49,34 @@ if (!$repo) { $repo->check_capability(); $repo->check_login(); -$fs = get_file_storage(); -$usercontext = context_user::instance($USER->id); - switch ($action) { case 'upload': - // Save the files in the draft area. - $draftid = !empty($itemid) ? $itemid : file_get_unused_draft_itemid(); - $file = $_FILES['repo_upload_file']; - - if (empty($file) || $file['error'] !== UPLOAD_ERR_OK) { - echo json_encode(['error' => get_string('erroruploadfailed', 'repository')]); - break; - } - - $filerecord = [ - 'contextid' => $usercontext->id, - 'component' => 'user', - 'filearea' => 'draft', - 'itemid' => $draftid, - 'filepath' => '/', - 'filename' => clean_param($file['name'], PARAM_FILE), - ]; - - $fs->create_file_from_pathname($filerecord, $file['tmp_name']); - - echo json_encode([ - 'draftid' => $draftid, - 'file' => $filerecord['filename'], - ]); - break; - - case 'commit': - // Upload the files to Google Drive. - if (empty($itemid)) { - die(json_encode(['erroruploadfailed' => get_string('error', 'repository')])); - } - - // Grab files from draft area with the same itemid. - $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $itemid, "id", false); - if (empty($draftfiles)) { - die(json_encode(['erroruploadfailed' => get_string('error', 'repository')])); + $files = $_FILES['files']; + $tmp = make_request_directory(); + $savedfiles = []; + if (is_array($files['name'])) { + // Multiple files. + foreach ($files['name'] as $idx => $name) { + $dest = $tmp . '/' . basename($name); + move_uploaded_file($files['tmp_name'][$idx], $dest); + $savedfiles[] = $dest; + } + } else { + // Single file. + $dest = $tmp . '/' . basename($files['name']); + move_uploaded_file($files['tmp_name'], $dest); + $savedfiles[] = $dest; } // Upload the file to Google Drive repository. - $tmp = make_request_directory(); $ha = new repository_googledocs($repoid, $context); $userauth = $ha->get_user_oauth_client(); $userservice = new repository_googledocs\rest($userauth); - foreach ($draftfiles as $draftfile) { - $tempfile = $tmp . '/' . rand(); - $filename = $draftfile->get_filename(); - $draftfile->copy_content_to($tempfile); - $ha->upload_file($userservice, $tempfile, $filename, 'download', "root"); + foreach ($savedfiles as $file) { + $filename = basename($file); + $ha->upload_file($userservice, $file, $filename, 'download', "root"); } - // Clear drafts after upload them to Google drive. - $fs->delete_area_files($usercontext->id, 'user', 'draft', $itemid); - echo json_encode(['success' => true]); break; diff --git a/repository/googledocs/templates/upload_dialogue.mustache b/repository/googledocs/templates/upload_dialogue.mustache new file mode 100644 index 00000000000..17cac5ada52 --- /dev/null +++ b/repository/googledocs/templates/upload_dialogue.mustache @@ -0,0 +1,39 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template repository_googledocs/upload_dialogue + + Google repository upload dialogue. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Example context (json): + { + + } +}} +
    +
    +
    + {{#str}} attachedfiles, repository {{/str}} +
    +
      +
      From 8648dc6046c51c9c685a019979526253678e51fa Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Tue, 23 Sep 2025 16:49:53 +0700 Subject: [PATCH 5/5] MDL-80447 repository_googledocs: Add missing fields --- repository/googledocs/lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repository/googledocs/lib.php b/repository/googledocs/lib.php index 1b76567dedd..a1a078e4c62 100644 --- a/repository/googledocs/lib.php +++ b/repository/googledocs/lib.php @@ -808,7 +808,7 @@ class repository_googledocs extends repository { * @return stdClass */ protected function get_file_summary(\repository_googledocs\rest $client, $fileid) { - $fields = "id,name,owners,parents"; + $fields = "id,name,owners,parents,webContentLink,webViewLink"; $params = [ 'fileid' => $fileid, 'fields' => $fields