diff --git a/gulpfile.js b/gulpfile.js index 717d913d651..c6d3ce48124 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -125,6 +125,10 @@ function treatMergedData(data) { addProperties(merged, data[filepath], 'mm.core.country-'); + } else if (filepath.indexOf('core/assets/mimetypes') === 0) { + + addProperties(merged, data[filepath], 'mm.core.mimetype-'); + } } @@ -352,6 +356,7 @@ var paths = { './www/core/components/**/lang/', './www/addons/**/lang/', './www/core/assets/countries/', + './www/core/assets/mimetypes/', '!./www/**/' + remoteAddonPackageFolder + '/*.json', '!./www/**/' + remoteAddonPackageFolder + '/**/*.json', ], diff --git a/upgrade.txt b/upgrade.txt index 60e7bd17501..ec3731e84f7 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,6 +1,10 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 3.3.2 === + + * Handlers registered in $mmFileUploaderDelegate now need to implement a function getSupportedMimeTypes. This function will receive a list of mimetypes and needs to return the ones supported by the handler. Also, the handler's action and afterRender functions now receive a new parameter: mimetypes. + === 3.3 === * The project now supports Ionic CLI v2 and Node 6.9. We recommend updating node, npm, Ionic CLI and project dependencies: diff --git a/www/addons/mod/assign/submission/file/directive.js b/www/addons/mod/assign/submission/file/directive.js index 96ffcf8ade2..5b64e392949 100644 --- a/www/addons/mod/assign/submission/file/directive.js +++ b/www/addons/mod/assign/submission/file/directive.js @@ -22,7 +22,81 @@ angular.module('mm.addons.mod_assign') * @name mmaModAssignSubmissionFile */ .directive('mmaModAssignSubmissionFile', function($mmaModAssign, $mmFileSession, mmaModAssignComponent, $mmaModAssignHelper, - $mmaModAssignOffline, mmaModAssignSubmissionFileName, $mmFileUploaderHelper, $q) { + $mmaModAssignOffline, mmaModAssignSubmissionFileName, $mmFileUploaderHelper, $q, $mmFS) { + + /** + * Add a dot to the beginning of an extension. + * + * @param {String} extension Extension. + * @return {String} Treated extension. + */ + function addDot(extension) { + return '.' + extension; + } + + /** + * Parse filetypeslist to get the list of allowed mimetypes and the data to render information. + * + * @param {Object} scope Directive's scope. + * @return {Void} + */ + function treatFileTypes(scope) { + var mimetypes = {}, // Use an object to prevent duplicates. + filetypes = scope.configs.filetypeslist.replace(/,/g, ';').split(';'); + + scope.typesInfo = []; + + angular.forEach(filetypes, function(filetype) { + filetype = filetype.trim(); + + if (filetype) { + if (filetype.indexOf('/') != -1) { + // It's a mimetype. + mimetypes[filetype] = true; + + scope.typesInfo.push({ + type: 'mimetype', + value: { + name: $mmFS.getMimetypeDescription(filetype), + extlist: $mmFS.getExtensions(filetype).map(addDot).join(' ') + } + }); + } else if (filetype.indexOf('.') === 0) { + // It's an extension. + var mimetype = $mmFS.getMimeType(filetype); + if (mimetype) { + mimetypes[mimetype] = true; + } + + scope.typesInfo.push({ + type: 'extension', + value: filetype + }); + } else { + // It's a group. + var groupMimetypes = $mmFS.getGroupMimeInfo(filetype, 'mimetypes'), + groupExtensions = $mmFS.getGroupMimeInfo(filetype, 'extensions'); + + angular.forEach(groupMimetypes, function(mimetype) { + if (mimetype) { + mimetypes[mimetype] = true; + } + }); + + scope.typesInfo.push({ + type: 'mimetype', + value: { + name: $mmFS.getTranslatedGroupName(filetype), + extlist: groupExtensions ? groupExtensions.map(addDot).join(' ') : '' + } + }); + } + } + }); + + scope.mimetypes = Object.keys(mimetypes); + } + return { restrict: 'A', priority: 100, @@ -32,6 +106,10 @@ angular.module('mm.addons.mod_assign') return; } + if (scope.edit && scope.configs && scope.configs.filetypeslist && scope.configs.filetypeslist.trim()) { + treatFileTypes(scope); + } + // Get the offline data. $mmaModAssignOffline.getSubmission(scope.assign.id).catch(function() { // Error getting data, assume there's no offline submission. diff --git a/www/addons/mod/assign/submission/file/lang/en.json b/www/addons/mod/assign/submission/file/lang/en.json index 7262ba21715..f0239b984d4 100644 --- a/www/addons/mod/assign/submission/file/lang/en.json +++ b/www/addons/mod/assign/submission/file/lang/en.json @@ -1,3 +1,5 @@ { + "filesofthesetypes": "Files of these types may be added to the submission:", + "filetypewithexts": "{{$a.name}} — {{$a.extlist}}", "pluginname": "File submissions" } \ No newline at end of file diff --git a/www/addons/mod/assign/submission/file/template.html b/www/addons/mod/assign/submission/file/template.html index 08de6c3caa6..6af89e936ee 100644 --- a/www/addons/mod/assign/submission/file/template.html +++ b/www/addons/mod/assign/submission/file/template.html @@ -1,7 +1,16 @@

{{plugin.name}}

- + +
+

{{ 'mma.mod_assign_submission_file.filesofthesetypes' | translate }}

+ +
diff --git a/www/core/assets/mimetypes/en.json b/www/core/assets/mimetypes/en.json new file mode 100644 index 00000000000..f94347be44f --- /dev/null +++ b/www/core/assets/mimetypes/en.json @@ -0,0 +1,54 @@ +{ + "application/epub_zip": "EPUB ebook", + "application/msword": "Word document", + "application/pdf": "PDF document", + "application/vnd.moodle.backup": "Moodle backup", + "application/vnd.ms-excel": "Excel spreadsheet", + "application/vnd.ms-excel.sheet.macroEnabled.12": "Excel 2007 macro-enabled workbook", + "application/vnd.ms-powerpoint": "Powerpoint presentation", + "application/vnd.oasis.opendocument.spreadsheet": "OpenDocument Spreadsheet", + "application/vnd.oasis.opendocument.spreadsheet-template": "OpenDocument Spreadsheet template", + "application/vnd.oasis.opendocument.text": "OpenDocument Text document", + "application/vnd.oasis.opendocument.text-template": "OpenDocument Text template", + "application/vnd.oasis.opendocument.text-web": "OpenDocument Web page template", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": "Powerpoint 2007 presentation", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow": "Powerpoint 2007 slideshow", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel 2007 spreadsheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template": "Excel 2007 template", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Word 2007 document", + "application/x-iwork-keynote-sffkey": "iWork Keynote presentation", + "application/x-iwork-numbers-sffnumbers": "iWork Numbers spreadsheet", + "application/x-iwork-pages-sffpages": "iWork Pages document", + "application/x-javascript": "JavaScript source", + "application/x-mspublisher": "Publisher document", + "application/x-shockwave-flash": "Flash animation", + "application/xhtml_xml": "XHTML document", + "archive": "Archive ({{$a.EXT}})", + "audio": "Audio file ({{$a.EXT}})", + "default": "{{$a.mimetype}}", + "document/unknown": "File", + "group:archive": "Archive files", + "group:audio": "Audio files", + "group:document": "Document files", + "group:html_audio": "Audio files natively supported by browsers", + "group:html_track": "HTML track files", + "group:html_video": "Video files natively supported by browsers", + "group:image": "Image files", + "group:presentation": "Presentation files", + "group:sourcecode": "Source code", + "group:spreadsheet": "Spreadsheet files", + "group:video": "Video files", + "group:web_audio": "Audio files used on the web", + "group:web_file": "Web files", + "group:web_image": "Image files used on the web", + "group:web_video": "Video files used on the web", + "image": "Image ({{$a.MIMETYPE2}})", + "image/vnd.microsoft.icon": "Windows icon", + "text/css": "Cascading Style-Sheet", + "text/csv": "Comma-separated values", + "text/html": "HTML document", + "text/plain": "Text file", + "text/rtf": "RTF document", + "text/vtt": "Web Video Text Track", + "video": "Video file ({{$a.EXT}})" +} \ No newline at end of file diff --git a/www/core/components/emulator/services/mediacapture.js b/www/core/components/emulator/services/mediacapture.js index 98d5e34557d..014b68a0f01 100644 --- a/www/core/components/emulator/services/mediacapture.js +++ b/www/core/components/emulator/services/mediacapture.js @@ -59,7 +59,8 @@ angular.module('mm.core.emulator') extension, quality = 0.92, // Image only. returnData = false, // Image only. - isCaptureImage = false; // To identify if it's capturing an image using media capture plugin (instead of camera). + isCaptureImage = false, // To identify if it's capturing an image using media capture plugin (instead of camera). + mimeAndExt; loadingModal = $mmUtil.showModalLoading(); @@ -72,13 +73,17 @@ angular.module('mm.core.emulator') if (type == 'video') { scope.isVideo = true; title = 'mm.core.capturevideo'; - mimetype = videoMimeType; - extension = possibleVideoMimeTypes[mimetype]; + + mimeAndExt = getMimeTypeAndExtension(type, options.mimetypes); + mimetype = mimeAndExt.mimetype; + extension = mimeAndExt.extension; } else if (type == 'audio') { scope.isAudio = true; title = 'mm.core.captureaudio'; - mimetype = audioMimeType; - extension = possibleAudioMimeTypes[mimetype]; + + mimeAndExt = getMimeTypeAndExtension(type, options.mimetypes); + mimetype = mimeAndExt.mimetype; + extension = mimeAndExt.extension; } else if (type == 'image') { scope.isImage = true; title = 'mm.core.captureimage'; @@ -338,6 +343,45 @@ angular.module('mm.core.emulator') } } + /** + * Get the mimetype and extension to capture media. + * + * @param {String} type Type of media: image, audio, video. + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Object} An object with mimetype and extension to use. + */ + function getMimeTypeAndExtension(type, mimetypes) { + var result = {}; + + if (mimetypes && mimetypes.length) { + // Search for a supported mimetype. + for (var i = 0; i < mimetypes.length; i++) { + var mimetype = mimetypes[i], + matches = mimetype.match(new RegExp('^' + type + '/')); + + if (matches && matches.length && MediaRecorder.isTypeSupported(mimetype)) { + result.mimetype = mimetype; + break; + } + } + } + + if (result.mimetype) { + // Found a supported mimetype in the mimetypes array, get the extension. + result.extension = $mmFS.getExtension(result.mimetype); + } else if (type == 'video') { + // No mimetype found, use default extension. + result.mimetype = videoMimeType; + result.extension = possibleVideoMimeTypes[result.mimetype]; + } else if (type == 'audio') { + // No mimetype found, use default extension. + result.mimetype = audioMimeType; + result.extension = possibleAudioMimeTypes[result.mimetype]; + } + + return result; + } + /** * Initialize the audio drawer. This code has been extracted from MDN's example on MediaStream Recording: * https://github.com/mdn/web-dictaphone diff --git a/www/core/components/fileuploader/lang/en.json b/www/core/components/fileuploader/lang/en.json index d42abc6a3c8..041d6469017 100644 --- a/www/core/components/fileuploader/lang/en.json +++ b/www/core/components/fileuploader/lang/en.json @@ -14,6 +14,7 @@ "errorwhileuploading": "An error occurred during the file upload.", "file": "File", "fileuploaded": "The file was successfully uploaded.", + "invalidfiletype": "{{$a}} filetype cannot be accepted.", "maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.", "photoalbums": "Photo albums", "readingfile": "Reading file", diff --git a/www/core/components/fileuploader/services/delegate.js b/www/core/components/fileuploader/services/delegate.js index 790210ef9aa..cbc488c14df 100644 --- a/www/core/components/fileuploader/services/delegate.js +++ b/www/core/components/fileuploader/services/delegate.js @@ -36,16 +36,18 @@ angular.module('mm.core.fileuploader') * returning an object defining these functions. See {@link $mmUtil#resolveObject}. * - isEnabled (Boolean|Promise) Whether or not the handler is enabled on a site level. * When using a promise, it should return a boolean. + * - getSupportedMimeTypes(mimetypes) (String[]) Given a list of mimetypes, return the ones + * that are supported by the handler. * - getData (Object) Returns an object with the data to display the handler. Accepted properties: * * name Required. A name to identify the handler. Allows filtering it. * * class Optional. Class to add to the handler's row. * * title Required. Title to show in the handler's row. * * icon Optional. Icon to show in the handler's row. - * * afterRender(maxSize, upload, allowOffline) Optional. Called when the handler is - * rendered. - * * action(maxSize, upload, allowOffline) Required. A function called when the handler - * is clicked. It must return an object - or a promise resolved with an object - - * containing these properties: + * * afterRender(maxSize, upload, allowOffline, mimetypes) Optional. Called when the + * handler is rendered. + * * action(maxSize, upload, allowOffline, mimetypes) Required. A function called when + * the handler is clicked. It must return an object - or a promise resolved with an + * object - containing these properties: * - uploaded Boolean. Whether the handler uploaded or treated the file. * - path String. Ignored if uploaded=true. The path of the file to upload. * - fileEntry Object. Ignored if uploaded=true. The fileEntry to upload. @@ -93,14 +95,32 @@ angular.module('mm.core.fileuploader') * @module mm.core.fileuploader * @ngdoc method * @name $mmFileUploaderDelegate#getHandlers + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. * @return {Promise} Resolved with an array of objects containing 'priority' and the handler data. */ - self.getHandlers = function() { + self.getHandlers = function(mimetypes) { var handlers = []; angular.forEach(enabledHandlers, function(handler) { + var supportedMimetypes; + + if (mimetypes) { + if (!handler.instance.getSupportedMimeTypes) { + // Handler doesn't implement a required function, don't add it. + return; + } + + supportedMimetypes = handler.instance.getSupportedMimeTypes(mimetypes); + + if (!supportedMimetypes.length) { + // Handler doesn't support any mimetype, don't add it. + return; + } + } + var data = handler.instance.getData(); data.priority = handler.priority; + data.mimetypes = supportedMimetypes; handlers.push(data); }); diff --git a/www/core/components/fileuploader/services/handlers.js b/www/core/components/fileuploader/services/handlers.js index 1cc6ea3e1d6..707310044d3 100644 --- a/www/core/components/fileuploader/services/handlers.js +++ b/www/core/components/fileuploader/services/handlers.js @@ -21,7 +21,7 @@ angular.module('mm.core.fileuploader') * @ngdoc service * @name $mmFileUploaderHandlers */ -.factory('$mmFileUploaderHandlers', function($mmFileUploaderHelper, $rootScope, $compile, $mmUtil, $mmApp) { +.factory('$mmFileUploaderHandlers', function($mmFileUploaderHelper, $rootScope, $compile, $mmUtil, $mmApp, $translate, $mmFS) { var self = {}; @@ -56,8 +56,8 @@ angular.module('mm.core.fileuploader') title: 'mm.fileuploader.photoalbums', class: 'mm-fileuploader-album-handler', icon: 'ion-images', - action: function(maxSize, upload, allowOffline) { - return $mmFileUploaderHelper.uploadImage(true, maxSize, upload).then(function(result) { + action: function(maxSize, upload, allowOffline, mimetypes) { + return $mmFileUploaderHelper.uploadImage(true, maxSize, upload, mimetypes).then(function(result) { return { uploaded: true, result: result @@ -67,6 +67,17 @@ angular.module('mm.core.fileuploader') }; }; + /** + * Given a list of mimetypes, return the ones supported by this handler. + * + * @param {String[]} mimetypes List of mimetypes. + * @return {String[]} Supported mimetypes. + */ + self.getSupportedMimeTypes = function(mimetypes) { + // Album allows picking images and videos. + return $mmUtil.filterByRegexp(mimetypes, /^(image|video)\//); + }; + return self; }; @@ -101,8 +112,8 @@ angular.module('mm.core.fileuploader') title: 'mm.fileuploader.camera', class: 'mm-fileuploader-camera-handler', icon: 'ion-camera', - action: function(maxSize, upload, allowOffline) { - return $mmFileUploaderHelper.uploadImage(false, maxSize, upload).then(function(result) { + action: function(maxSize, upload, allowOffline, mimetypes) { + return $mmFileUploaderHelper.uploadImage(false, maxSize, upload, mimetypes).then(function(result) { return { uploaded: true, result: result @@ -112,6 +123,17 @@ angular.module('mm.core.fileuploader') }; }; + /** + * Given a list of mimetypes, return the ones supported by this handler. + * + * @param {String[]} mimetypes List of mimetypes. + * @return {String[]} Supported mimetypes. + */ + self.getSupportedMimeTypes = function(mimetypes) { + // Camera only supports JPEG and PNG. + return $mmUtil.filterByRegexp(mimetypes, /^image\/(jpeg|png)$/); + }; + return self; }; @@ -146,8 +168,8 @@ angular.module('mm.core.fileuploader') title: 'mm.fileuploader.audio', class: 'mm-fileuploader-audio-handler', icon: 'ion-mic-a', - action: function(maxSize, upload, allowOffline) { - return $mmFileUploaderHelper.uploadAudioOrVideo(true, maxSize, upload).then(function(result) { + action: function(maxSize, upload, allowOffline, mimetypes) { + return $mmFileUploaderHelper.uploadAudioOrVideo(true, maxSize, upload, mimetypes).then(function(result) { return { uploaded: true, result: result @@ -157,6 +179,32 @@ angular.module('mm.core.fileuploader') }; }; + /** + * Given a list of mimetypes, return the ones supported by this handler. + * + * @param {String[]} mimetypes List of mimetypes. + * @return {String[]} Supported mimetypes. + */ + self.getSupportedMimeTypes = function(mimetypes) { + if (ionic.Platform.isIOS()) { + // iOS records as WAV. + return $mmUtil.filterByRegexp(mimetypes, /^audio\/wav$/); + } else if (ionic.Platform.isAndroid()) { + // In Android we don't know the format the audio will be recorded, so accept any audio mimetype. + return $mmUtil.filterByRegexp(mimetypes, /^audio\//); + } else { + // In desktop, support audio formats that are supported by MediaRecorder. + if (MediaRecorder) { + return mimetypes.filter(function(type) { + var matches = type.match(/^audio\//); + return matches && matches.length && MediaRecorder.isTypeSupported(type); + }); + } + } + + return []; + }; + return self; }; @@ -191,8 +239,8 @@ angular.module('mm.core.fileuploader') title: 'mm.fileuploader.video', class: 'mm-fileuploader-video-handler', icon: 'ion-ios-videocam', - action: function(maxSize, upload, allowOffline) { - return $mmFileUploaderHelper.uploadAudioOrVideo(false, maxSize, upload).then(function(result) { + action: function(maxSize, upload, allowOffline, mimetypes) { + return $mmFileUploaderHelper.uploadAudioOrVideo(false, maxSize, upload, mimetypes).then(function(result) { return { uploaded: true, result: result @@ -202,6 +250,32 @@ angular.module('mm.core.fileuploader') }; }; + /** + * Given a list of mimetypes, return the ones supported by this handler. + * + * @param {String[]} mimetypes List of mimetypes. + * @return {String[]} Supported mimetypes. + */ + self.getSupportedMimeTypes = function(mimetypes) { + if (ionic.Platform.isIOS()) { + // iOS records as MOV. + return $mmUtil.filterByRegexp(mimetypes, /^video\/quicktime$/); + } else if (ionic.Platform.isAndroid()) { + // In Android we don't know the format the video will be recorded, so accept any video mimetype. + return $mmUtil.filterByRegexp(mimetypes, /^video\//); + } else { + // In desktop, support video formats that are supported by MediaRecorder. + if (MediaRecorder) { + return mimetypes.filter(function(type) { + var matches = type.match(/^video\//); + return matches && matches.length && MediaRecorder.isTypeSupported(type); + }); + } + } + + return []; + }; + return self; }; @@ -237,12 +311,16 @@ angular.module('mm.core.fileuploader') title: 'mm.fileuploader.file', class: 'mm-fileuploader-file-handler', icon: 'ion-folder', - afterRender: function(maxSize, upload, allowOffline) { + afterRender: function(maxSize, upload, allowOffline, mimetypes) { // Add an invisible file input in the file handler. // It needs to be done like this because button text doesn't accept inputs. var element = document.querySelector('.mm-fileuploader-file-handler'); if (element) { var input = angular.element(''); + if (mimetypes && mimetypes.length && (!ionic.Platform.isAndroid() || mimetypes.length === 1)) { + // Don't use accept attribute in Android with several mimetypes, it's not supported. + input.attr('accept', mimetypes.join(', ')); + } if (!uploadFileScope) { // Create a scope for the on change directive. @@ -257,6 +335,13 @@ angular.module('mm.core.fileuploader') return; } + // Verify that the mimetype of the file is supported, in case the accept attribute isn't supported. + var error = $mmFileUploaderHelper.isInvalidMimetype(mimetypes, file.name, file.type); + if (error) { + $mmUtil.showErrorModal(error); + return; + } + // Upload the picked file. $mmFileUploaderHelper.uploadFileObject(file, maxSize, upload, allowOffline).then(function(result) { $mmFileUploaderHelper.fileUploaded(result); @@ -275,6 +360,16 @@ angular.module('mm.core.fileuploader') }; }; + /** + * Given a list of mimetypes, return the ones supported by this handler. + * + * @param {String[]} mimetypes List of mimetypes. + * @return {String[]} Supported mimetypes. + */ + self.getSupportedMimeTypes = function(mimetypes) { + return mimetypes; + }; + return self; }; diff --git a/www/core/components/fileuploader/services/helper.js b/www/core/components/fileuploader/services/helper.js index 8e3bdc60cd6..e799e7ba7e8 100644 --- a/www/core/components/fileuploader/services/helper.js +++ b/www/core/components/fileuploader/services/helper.js @@ -228,6 +228,38 @@ angular.module('mm.core.fileuploader') }); }; + /** + * Check if a file's mimetype is invalid based on the list of accepted mimetypes. This function needs either the file's + * mimetype or the file's path/name. + * + * @module mm.core.fileuploader + * @ngdoc method + * @name $mmFileUploaderHelper#isInvalidMimetype + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @param {String} [path] File's path or name. + * @param {String} [mimetype] File's mimetype. + * @return {Mixed} False if file is valid, error message if file is invalid. + */ + self.isInvalidMimetype = function(mimetypes, path, mimetype) { + var extension; + + if (mimetypes) { + // Verify that the mimetype of the file is supported. + if (mimetype) { + extension = $mmFS.getExtension(mimetype); + } else { + extension = $mmFS.getFileExtension(path); + mimetype = $mmFS.getMimeType(extension); + } + + if (mimetype && mimetypes.indexOf(mimetype) == -1) { + return $translate.instant('mm.fileuploader.invalidfiletype', {$a: extension}); + } + } + + return false; + }; + /** * Mark files as offline. * @@ -252,14 +284,15 @@ angular.module('mm.core.fileuploader') * @module mm.core.fileuploader * @ngdoc method * @name $mmFileUploaderHelper#selectAndUploadFile - * @param {Number} [maxSize] Max size of the file to upload. If not defined or -1, no max size. - * @param {String} [title] File picker page title - * @param {Array} [filterMethods] File picker available methods + * @param {Number} [maxSize] Max size of the file to upload. If not defined or -1, no max size. + * @param {String} [title] File picker page title + * @param {Array} [filterMethods] File picker available methods + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. * @return {Promise} Promise resolved when a file is uploaded, rejected if file picker is closed without a file uploaded. * The resolve value should be the response of the upload request. */ - self.selectAndUploadFile = function(maxSize, title, filterMethods) { - return selectFile(maxSize, false, title, filterMethods, true); + self.selectAndUploadFile = function(maxSize, title, filterMethods, mimetypes) { + return selectFile(maxSize, false, title, filterMethods, true, mimetypes); }; /** @@ -268,36 +301,38 @@ angular.module('mm.core.fileuploader') * @module mm.core.fileuploader * @ngdoc method * @name $mmFileUploaderHelper#selectFile - * @param {Number} [maxSize] Max size of the file. If not defined or -1, no max size. - * @param {Boolean} allowOffline True to allow selecting in offline, false to require connection. - * @param {String} [title] File picker page title - * @param {Array} [filterMethods] File picker available methods + * @param {Number} [maxSize] Max size of the file. If not defined or -1, no max size. + * @param {Boolean} [allowOffline] True to allow selecting in offline, false to require connection. + * @param {String} [title] File picker page title + * @param {Array} [filterMethods] File picker available methodss + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. * @return {Promise} Promise resolved when a file is selected, rejected if file picker is closed without selecting a file. * The resolve value should be the FileEntry of a copy of the picked file, so it can be deleted afterwards. */ - self.selectFile = function(maxSize, allowOffline, title, filterMethods) { - return selectFile(maxSize, allowOffline, title, filterMethods, false); + self.selectFile = function(maxSize, allowOffline, title, filterMethods, mimetypes) { + return selectFile(maxSize, allowOffline, title, filterMethods, false, mimetypes); }; /** * Open the view to select a file and maybe uploading it. * * @param {Number} [maxSize] Max size of the file. If not defined or -1, no max size. - * @param {Boolean} allowOffline True to allow selecting in offline, false to require connection. + * @param {Boolean} [allowOffline] True to allow selecting in offline, false to require connection. * @param {String} [title] File picker title. * @param {Array} [filterMethods] File picker available methods. - * @param {Boolean} upload True if the file should be uploaded, false if only picked. + * @param {Boolean} [upload] True if the file should be uploaded, false if only picked. + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. * @return {Promise} Promise resolved when a file is selected, rejected if file picker is closed without selecting a file. * The resolve value should be the FileEntry of a copy of the picked file, so it can be deleted afterwards. */ - function selectFile(maxSize, allowOffline, title, filterMethods, upload) { + function selectFile(maxSize, allowOffline, title, filterMethods, upload, mimetypes) { var buttons = [], handlers; filePickerDeferred = $q.defer(); // Add buttons for handlers. - handlers = $mmFileUploaderDelegate.getHandlers(); + handlers = $mmFileUploaderDelegate.getHandlers(mimetypes); handlers.sort(function(a, b) { return a.priority <= b.priority ? 1 : -1; }); @@ -312,7 +347,8 @@ angular.module('mm.core.fileuploader') text: (handler.icon ? '' : '') + $translate.instant(handler.title), action: handler.action, className: handler.class, - afterRender: handler.afterRender + afterRender: handler.afterRender, + mimetypes: handler.mimetypes }); }); @@ -329,7 +365,7 @@ angular.module('mm.core.fileuploader') } // Execute the action and close the action sheet. - buttons[index].action(maxSize, upload, allowOffline).then(function(data) { + buttons[index].action(maxSize, upload, allowOffline, buttons[index].mimetypes).then(function(data) { if (data.uploaded) { // The handler already uploaded the file. Return the result. return data.result; @@ -377,7 +413,7 @@ angular.module('mm.core.fileuploader') $timeout(function() { angular.forEach(buttons, function(button) { if (angular.isFunction(button.afterRender)) { - button.afterRender(maxSize, upload, allowOffline); + button.afterRender(maxSize, upload, allowOffline, button.mimetypes); } }); }, 500); @@ -420,19 +456,27 @@ angular.module('mm.core.fileuploader') * @module mm.core.fileuploader * @ngdoc method * @name $mmFileUploaderHelper#uploadAudioOrVideo - * @param {Boolean} isAudio True if uploading an audio, false if it's a video. - * @param {Number} maxSize Max size of the upload. -1 for no max size. - * @param {Boolean} upload True if the file should be uploaded, false to return the picked file. - * @return {Promise} The reject contains the error message, if there is no error message - * then we can consider that this is a silent fail. + * @param {Boolean} isAudio True if uploading an audio, false if it's a video. + * @param {Number} maxSize Max size of the upload. -1 for no max size. + * @param {Boolean} upload True if the file should be uploaded, false to return the picked file. + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} The reject contains the error message, if there is no error message + * then we can consider that this is a silent fail. */ - self.uploadAudioOrVideo = function(isAudio, maxSize, upload) { + self.uploadAudioOrVideo = function(isAudio, maxSize, upload, mimetypes) { $log.debug('Trying to record a video file'); var fn = isAudio ? $cordovaCapture.captureAudio : $cordovaCapture.captureVideo; - return fn({limit: 1}).then(function(medias) { + + // The mimetypes param is only for desktop apps, the Cordova plugin doesn't support it. + return fn({limit: 1, mimetypes: mimetypes}).then(function(medias) { // We used limit 1, we only want 1 media. var media = medias[0], - path = media.localURL || media.toURL(); + path = media.localURL || media.toURL(), + error = self.isInvalidMimetype(mimetypes, path); // Verify that the mimetype of the file is supported. + + if (error) { + return $q.reject(error); + } if (upload) { return uploadFile(true, path, maxSize, true, $mmFileUploader.uploadMedia, media); @@ -472,13 +516,14 @@ angular.module('mm.core.fileuploader') * @module mm.core.fileuploader * @ngdoc method * @name $mmFileUploaderHelper#uploadImage - * @param {Boolean} fromAlbum True if the image should be selected from album, false if it should be taken with camera. - * @param {Number} maxSize Max size of the upload. -1 for no max size. - * @param {Boolean} upload True if the image should be uploaded, false to return the picked file. - * @return {Promise} The reject contains the error message, if there is no error message - * then we can consider that this is a silent fail. + * @param {Boolean} fromAlbum True if the image should be selected from album, false if it should be taken with camera. + * @param {Number} maxSize Max size of the upload. -1 for no max size. + * @param {Boolean} upload True if the image should be uploaded, false to return the picked file. + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} The reject contains the error message, if there is no error message + * then we can consider that this is a silent fail. */ - self.uploadImage = function(fromAlbum, maxSize, upload) { + self.uploadImage = function(fromAlbum, maxSize, upload, mimetypes) { $log.debug('Trying to capture an image with camera'); var options = { quality: 50, @@ -487,16 +532,36 @@ angular.module('mm.core.fileuploader') }; if (fromAlbum) { + var imageSupported = !mimetypes || $mmUtil.indexOfRegexp(mimetypes, /^image\//) > -1, + videoSupported = !mimetypes || $mmUtil.indexOfRegexp(mimetypes, /^video\//) > -1; + options.sourceType = navigator.camera.PictureSourceType.PHOTOLIBRARY; options.popoverOptions = new CameraPopoverOptions(10, 10, $window.innerWidth - 200, $window.innerHeight - 200, Camera.PopoverArrowDirection.ARROW_ANY); - if (ionic.Platform.isIOS()) { + + // Determine the mediaType based on the mimetypes. + if (imageSupported && !videoSupported) { + options.mediaType = Camera.MediaType.PICTURE; + } else if (!imageSupported && videoSupported) { + options.mediaType = Camera.MediaType.VIDEO; + } else if (ionic.Platform.isIOS()) { // Only get all media in iOS because in Android using this option allows uploading any kind of file. options.mediaType = Camera.MediaType.ALLMEDIA; } + } else if (mimetypes) { + if (mimetypes.indexOf('image/jpeg') > -1) { + options.encodingType = Camera.EncodingType.JPEG; + } else if (mimetypes.indexOf('image/png') > -1) { + options.encodingType = Camera.EncodingType.PNG; + } } return $cordovaCamera.getPicture(options).then(function(path) { + var error = self.isInvalidMimetype(mimetypes, path); // Verify that the mimetype of the file is supported. + if (error) { + return $q.reject(error); + } + if (upload) { return uploadFile(!fromAlbum, path, maxSize, true, $mmFileUploader.uploadImage, path, fromAlbum); } else { diff --git a/www/core/components/sharedfiles/services/handlers.js b/www/core/components/sharedfiles/services/handlers.js index db9c700bb26..d183974ca05 100644 --- a/www/core/components/sharedfiles/services/handlers.js +++ b/www/core/components/sharedfiles/services/handlers.js @@ -57,14 +57,24 @@ angular.module('mm.core.sharedfiles') title: 'mm.sharedfiles.sharedfiles', class: 'mm-sharedfiles-filepicker-handler', icon: 'ion-folder', - action: function(maxSize, upload, allowOffline) { + action: function(maxSize, upload, allowOffline, mimetypes) { // We don't use the params because we aren't uploading the file ourselves, we return // the file to upload to the fileuploader. - return $mmSharedFilesHelper.pickSharedFile(); + return $mmSharedFilesHelper.pickSharedFile(mimetypes); } }; }; + /** + * Given a list of mimetypes, return the ones supported by this handler. + * + * @param {String[]} mimetypes List of mimetypes. + * @return {String[]} Supported mimetypes. + */ + self.getSupportedMimeTypes = function(mimetypes) { + return mimetypes; + }; + return self; }; diff --git a/www/core/components/sharedfiles/services/helper.js b/www/core/components/sharedfiles/services/helper.js index 926a85f40ac..78bb7e47fa9 100644 --- a/www/core/components/sharedfiles/services/helper.js +++ b/www/core/components/sharedfiles/services/helper.js @@ -15,7 +15,7 @@ angular.module('mm.core.sharedfiles') .factory('$mmSharedFilesHelper', function($mmSharedFiles, $mmUtil, $log, $mmApp, $mmSitesManager, $mmFS, $rootScope, $q, - $ionicModal, $state, $translate, $mmSite) { + $ionicModal, $state, $translate, $mmSite, $mmFileUploaderHelper) { $log = $log.getInstance('$mmSharedFilesHelper'); @@ -76,11 +76,12 @@ angular.module('mm.core.sharedfiles') * @module mm.core.sharedfiles * @ngdoc method * @name $mmSharedFilesHelper#filePickerClosed + * @param {String} [error] The error message if any. * @return {Void} */ - self.filePickerClosed = function() { + self.filePickerClosed = function(error) { if (filePickerDeferred) { - filePickerDeferred.reject(); + filePickerDeferred.reject(error); filePickerDeferred = undefined; } }; @@ -150,9 +151,10 @@ angular.module('mm.core.sharedfiles') * @module mm.core.sharedfiles * @ngdoc method * @name $mmSharedFilesHelper#pickSharedFile + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. * @return {Promise} Promise resolved when a file is picked, rejected if file picker is closed without selecting a file. */ - self.pickSharedFile = function() { + self.pickSharedFile = function(mimetypes) { var path = '', siteId = $mmSite.getId(); @@ -160,6 +162,7 @@ angular.module('mm.core.sharedfiles') self.initFileListModal().then(function() { fileListScope.filesLoaded = false; + // fileListScope.mimetypes = mimetypes; if (path) { fileListScope.title = $mmFS.getFileAndDirectoryFromPath(path).name; } else { @@ -199,8 +202,15 @@ angular.module('mm.core.sharedfiles') // File picked. fileListScope.filePicked = function(file) { - self.filePicked(file.fullPath); fileListScope.modal.hide(); + + var error = $mmFileUploaderHelper.isInvalidMimetype(mimetypes, file.fullPath); + if (error) { + self.filePickerClosed(error); + return; + } + + self.filePicked(file.fullPath); }; }); @@ -212,7 +222,7 @@ angular.module('mm.core.sharedfiles') return filePickerDeferred.promise; function loadFiles() { - return $mmSharedFiles.getSiteSharedFiles(siteId, path).then(function(files) { + return $mmSharedFiles.getSiteSharedFiles(siteId, path, mimetypes).then(function(files) { fileListScope.files = files; fileListScope.filesLoaded = true; }); diff --git a/www/core/components/sharedfiles/services/sharedfiles.js b/www/core/components/sharedfiles/services/sharedfiles.js index 1792ac28f24..09c4ecfaa91 100644 --- a/www/core/components/sharedfiles/services/sharedfiles.js +++ b/www/core/components/sharedfiles/services/sharedfiles.js @@ -133,11 +133,12 @@ angular.module('mm.core.sharedfiles') * @module mm.core.sharedfiles * @ngdoc method * @name $mmSharedFiles#getSiteSharedFiles - * @param {String} [siteId] Site ID. If not defined, current site. - * @param {String} [path] Path to search inside the site shared folder. - * @return {Promise} Promise resolved with the files. + * @param {String} [siteId] Site ID. If not defined, current site. + * @param {String} [path] Path to search inside the site shared folder. + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {Promise} Promise resolved with the files. */ - self.getSiteSharedFiles = function(siteId, path) { + self.getSiteSharedFiles = function(siteId, path, mimetypes) { siteId = siteId || $mmSite.getId(); var pathToGet = self.getSiteSharedFilesDirPath(siteId); @@ -145,7 +146,19 @@ angular.module('mm.core.sharedfiles') pathToGet = $mmFS.concatenatePaths(pathToGet, path); } - return $mmFS.getDirectoryContents(pathToGet).catch(function() { + return $mmFS.getDirectoryContents(pathToGet).then(function(files) { + if (mimetypes) { + // Only show files with the right mimetype and the ones we cannot determine the mimetype. + files = files.filter(function(file) { + var extension = $mmFS.getFileExtension(file.name), + mimetype = $mmFS.getMimeType(extension); + + return !mimetype || mimetypes.indexOf(mimetype) > -1; + }); + } + + return files; + }).catch(function() { // Directory not found, return empty list. return []; }); diff --git a/www/core/directives/attachments.js b/www/core/directives/attachments.js index 075c1f3af1a..3fd2b755a7c 100644 --- a/www/core/directives/attachments.js +++ b/www/core/directives/attachments.js @@ -33,7 +33,7 @@ angular.module('mm.core') * Example usage: * * + * component="{{component}}" component-id="{{assign.id}}" mimetypes="mimetypes"> * * Parameters accepted: * @@ -43,6 +43,7 @@ angular.module('mm.core') * @param {String} [component] Component the downloaded files will be linked to. * @param {Mixed} [componentId] Component ID the downloaded files will be linked to. * @param {Boolean} [allowOffline] True to allow selecting files in offline. + * @param {String[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. */ .directive('mmAttachments', function($mmText, $translate, $ionicScrollDelegate, $mmUtil, $mmApp, $mmFileUploaderHelper, $q) { return { @@ -55,7 +56,8 @@ angular.module('mm.core') maxSubmissions: '@?', component: '@?', componentId: '@?', - allowOffline: '@?' + allowOffline: '@?', + mimetypes: '=?' }, link: function(scope) { var allowOffline = scope.allowOffline && scope.allowOffline !== 'false'; @@ -77,7 +79,8 @@ angular.module('mm.core') if (!allowOffline && !$mmApp.isOnline()) { $mmUtil.showErrorModal('mm.fileuploader.errormustbeonlinetoupload', true); } else { - return $mmFileUploaderHelper.selectFile(maxSize, allowOffline).then(function(result) { + return $mmFileUploaderHelper.selectFile(maxSize, allowOffline, undefined, undefined, scope.mimetypes) + .then(function(result) { scope.files.push(result); }); } diff --git a/www/core/lib/fs.js b/www/core/lib/fs.js index 30f4435d8ba..1af868ab556 100644 --- a/www/core/lib/fs.js +++ b/www/core/lib/fs.js @@ -25,7 +25,7 @@ angular.module('mm.core') * This service handles the interaction with the FileSystem. */ .factory('$mmFS', function($ionicPlatform, $cordovaFile, $log, $q, $http, $cordovaZip, $mmText, mmFsSitesFolder, mmFsTmpFolder, - $mmApp) { + $mmApp, $translate) { $log = $log.getInstance('$mmFS'); @@ -35,6 +35,7 @@ angular.module('mm.core') isHTMLAPI = false, extToMime = {}, mimeToExt = {}, + groupsMimeInfo = {}, extensionRegex = new RegExp('^[a-z0-9]+$'); // Loading extensions to mimetypes file. @@ -916,8 +917,7 @@ angular.module('mm.core') if (dot > -1) { ext = filename.substr(dot + 1).toLowerCase(); - // Remove hash in extension if there's any. @see $mmFilepool#_getFileIdByUrl - ext = ext.replace(/_.{32}$/, ''); + ext = self.cleanExtension(ext); // Check extension corresponds to a mimetype to know if it's valid. if (typeof self.getMimeType(ext) == 'undefined') { @@ -939,13 +939,15 @@ angular.module('mm.core') * @return {String} Mimetype. */ self.getMimeType = function(extension) { + extension = self.cleanExtension(extension); + if (extToMime[extension] && extToMime[extension].type) { return extToMime[extension].type; } }; /** - * Get the "type" of an extension, something like "image", "video" or "audio". + * Get the "type" (string) of an extension, something like "image", "video" or "audio". * * @module mm.core * @ngdoc method @@ -955,11 +957,39 @@ angular.module('mm.core') * @since 3.3 */ self.getExtensionType = function(extension) { + extension = self.cleanExtension(extension); + if (extToMime[extension] && extToMime[extension].string) { return extToMime[extension].string; } }; + /** + * Get the "type" (string) of a mimetype, something like "image", "video" or "audio". + * + * @module mm.core + * @ngdoc method + * @name $mmFS#getMimetypeType + * @param {String} mimetype Mimetype. + * @return {Mixed} Type of the mimetype. + * @since 3.3.2 + */ + self.getMimetypeType = function(mimetype) { + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + + var extensions = mimeToExt[mimetype]; + if (!extensions) { + return; + } + + for (var i = 0; i < extensions.length; i++) { + var extension = extensions[i]; + if (extToMime[extension] && extToMime[extension].string) { + return extToMime[extension].string; + } + } + }; + /** * Guess the extension of a file from its URL. * @@ -1010,6 +1040,9 @@ angular.module('mm.core') * @return {String} Extension. */ self.getExtension = function(mimetype, url) { + mimetype = mimetype || ''; + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + if (mimetype == 'application/x-forcedownload' || mimetype == 'application/forcedownload') { // Couldn't get the right mimetype (old Moodle), try to guess it. return self.guessExtensionFromUrl(url); @@ -1029,6 +1062,22 @@ angular.module('mm.core') return undefined; }; + /** + * Get all the possible extensions of a mimetype. Returns empty array if not found. + * + * @module mm.core + * @ngdoc method + * @name $mmFS#getExtensions + * @param {String} mimetype Mimetype. + * @return {String[]} Extensions. + * @since 3.3.2 + */ + self.getExtensions = function(mimetype) { + mimetype = mimetype || ''; + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + return mimeToExt[mimetype] || []; + }; + /** * Remove the extension from a path (if any). * @@ -1363,6 +1412,8 @@ angular.module('mm.core') * @since 3.3 */ self.isExtensionInGroup = function(extension, groups) { + extension = self.cleanExtension(extension); + if (groups && groups.length && extToMime[extension] && extToMime[extension].groups) { for (var i = 0; i < extToMime[extension].groups.length; i++) { var group = extToMime[extension].groups[i]; @@ -1388,5 +1439,182 @@ angular.module('mm.core') return self.isExtensionInGroup(extension, ['web_image', 'web_video', 'web_audio']); }; + /** + * Get the mimetype/extension info belonging to a certain group. + * + * @module mm.core + * @ngdoc method + * @name $mmFS#getGroupMimetypes + * @param {String} group Group name. + * @param {String} [field] The field to get. If not supplied, all the info will be returned. + * @return {Mixed} List of mimetypes. + * @since 3.3.2 + */ + self.getGroupMimeInfo = function(group, field) { + if (typeof groupsMimeInfo[group] == 'undefined') { + fillGroupMimeInfo(group); + } + + if (field) { + return groupsMimeInfo[group][field]; + } + return groupsMimeInfo[group]; + }; + + /** + * Fill the mimetypes and extensions info for a certain group. + * + * @param {String} group Group name. + * @return {Void} + * @since 3.3.2 + */ + function fillGroupMimeInfo(group) { + var mimetypes = {}, // Use an object to prevent duplicates. + extensions = []; // Extensions are unique. + + angular.forEach(extToMime, function(data, extension) { + if (data.type && data.groups && data.groups.indexOf(group) != -1) { + // This extension has the group, add it to the list. + mimetypes[data.type] = true; + extensions.push(extension); + } + }); + + groupsMimeInfo[group] = { + mimetypes: Object.keys(mimetypes), + extensions: extensions + }; + } + + /** + * Obtains descriptions for file types (e.g. 'Microsoft Word document') from the language file. + * Based on Moodle's get_mimetype_description. + * + * @module mm.core + * @ngdoc method + * @name $mmFS#getMimetypeDescription + * @param {Mixed} obj Instance of FileEntry OR object with 'filename' and 'mimetype' OR string with mimetype. + * @param {Boolean} capitalise If true, capitalises first character of result. + * @return {String} Type description. + * @since 3.3.2 + */ + self.getMimetypeDescription = function(obj, capitalise) { + var filename = '', + mimetype = '', + extension = '', + langPrefix = 'mm.core.mimetype-'; + + if (typeof obj == 'object' && angular.isFunction(obj.file)) { + // It's a FileEntry. Don't use the file function because it's asynchronous and the type isn't reliable. + filename = obj.name; + } else if (typeof obj == 'object') { + filename = obj.filename || ''; + mimetype = obj.mimetype || ''; + } else { + mimetype = obj; + } + + if (filename) { + extension = self.getFileExtension(filename); + + if (!mimetype) { + // Try to calculate the mimetype using the extension. + mimetype = self.getMimeType(extension); + } + } + + if (!mimetype) { + // Don't have the mimetype, stop. + return ''; + } + + if (!extension) { + extension = self.getExtension(mimetype); + } + + var mimetypeStr = self.getMimetypeType(mimetype) || '', + chunks = mimetype.split('/'), + attr = { + mimetype: mimetype, + ext: extension || '', + mimetype1: chunks[0], + mimetype2: chunks[1] || '', + }, + a = {}; + + for (var key in attr) { + var value = attr[key]; + a[key] = value; + a[key.toUpperCase()] = value.toUpperCase(); + a[$mmText.ucFirst(key)] = $mmText.ucFirst(value); + } + + // MIME types may include + symbol but this is not permitted in string ids. + var safeMimetype = mimetype.replace(/\+/g, '_'), + safeMimetypeStr = mimetypeStr.replace(/\+/g, '_'), + safeMimetypeTrns = $translate.instant(langPrefix + safeMimetype, {$a: a}), + safeMimetypeStrTrns = $translate.instant(langPrefix + safeMimetypeStr, {$a: a}), + defaultTrns = $translate.instant(langPrefix + 'default', {$a: a}), + result = mimetype; + + if (safeMimetypeTrns != langPrefix + safeMimetype) { + result = safeMimetypeTrns; + } else if (safeMimetypeStrTrns != langPrefix + safeMimetypeStr) { + result = safeMimetypeStrTrns; + } else if (defaultTrns != langPrefix + 'default') { + result = defaultTrns; + } + + if (capitalise) { + result = $mmText.ucFirst(result); + } + + return result; + }; + + /** + * Given a group name, return the translated name. + * + * @module mm.core + * @ngdoc method + * @name $mmFS#getTranslatedGroupName + * @param {String} name Group name. + * @return {String} Translated name. + * @since 3.3.2 + */ + self.getTranslatedGroupName = function(name) { + var key = 'mm.core.mimetype-group:' + name, + translated = $translate.instant(key); + return translated != key ? translated : name; + }; + + /** + * Clean a extension, removing the dot, hash, extra params... + * + * @module mm.core + * @ngdoc method + * @name $mmFS#cleanExtension + * @param {String} extension Extension to clean. + * @return {String} Clean extension. + * @since 3.3.2 + */ + self.cleanExtension = function(extension) { + // If the extension has parameters, remove them. + var position = extension.indexOf('?'); + if (position > -1) { + extension = extension.substr(0, position); + } + + // Remove hash in extension if there's any. @see $mmFilepool#_getFileIdByUrl + extension = extension.replace(/_.{32}$/, ''); + + // Remove dot from the extension if found. + if (extension && extension[0] == '.') { + extension = extension.substr(1); + } + + return extension; + }; + return self; }); diff --git a/www/core/lib/text.js b/www/core/lib/text.js index 27df20f5dbd..2466ec53cdf 100644 --- a/www/core/lib/text.js +++ b/www/core/lib/text.js @@ -725,5 +725,18 @@ angular.module('mm.core') return self.escapeHTML(text).replace(/&#(\d+|x[0-9a-f]+);/i, '&#$1;'); }; + /** + * Make a string's first character uppercase + * + * @module mm.core + * @ngdoc method + * @name $mmText#ucFirst + * @param {String} text Text to treat. + * @return {String} Treated text. + */ + self.ucFirst = function(text) { + return text.charAt(0).toUpperCase() + text.slice(1); + }; + return self; }); diff --git a/www/core/lib/util.js b/www/core/lib/util.js index f2d3df3e97e..89cf25d2d68 100644 --- a/www/core/lib/util.js +++ b/www/core/lib/util.js @@ -2573,6 +2573,54 @@ angular.module('mm.core') return measure; }; + /** + * Gets the index of the first string that matches a regular expression. + * + * @module mm.core + * @ngdoc method + * @name $mmUtil#indexOfRegexp + * @param {String[]} array Array to search. + * @param {RegExp} regex RegExp to apply to each string. + * @return {Number} Index of the first string that matches the RegExp. -1 if not found. + */ + self.indexOfRegexp = function(array, regex) { + if (!array || !array.length) { + return -1; + } + + for (var i = 0; i < array.length; i++) { + var entry = array[i], + matches = entry.match(regex); + + if (matches && matches.length) { + return i; + } + } + + return -1; + }; + + /** + * Given an array of strings, return only the ones that match a regular expression. + * + * @module mm.core + * @ngdoc method + * @name $mmUtil#filterByRegexp + * @param {String[]} array Array to filter. + * @param {RegExp} regex RegExp to apply to each string. + * @return {String[]} Filtered array. + */ + self.filterByRegexp = function(array, regex) { + if (!array || !array.length) { + return []; + } + + return array.filter(function(entry) { + var matches = entry.match(regex); + return matches && matches.length; + }); + }; + return self; }; }); diff --git a/www/core/scss/styles.scss b/www/core/scss/styles.scss index 05b2f94e72f..b9ceba30e8e 100644 --- a/www/core/scss/styles.scss +++ b/www/core/scss/styles.scss @@ -1691,3 +1691,8 @@ h2.invert { ::-webkit-media-controls { max-width: 100%; } + +ol.list-with-style, ul.list-with-style { + list-style: initial; + padding-left: 40px; +}