From d2f4c85af2a12da07c769d9a0c41319e8db3b896 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 30 Sep 2020 12:01:04 +0200 Subject: [PATCH] MOBILE-2272 quiz: Support add new files in essay in offline --- src/addon/mod/quiz/providers/quiz-sync.ts | 81 ++++++++--- .../essay/component/addon-qtype-essay.html | 2 +- src/addon/qtype/essay/component/essay.ts | 31 ++++- src/addon/qtype/essay/providers/handler.ts | 131 +++++++++++++++--- .../fileuploader/providers/fileuploader.ts | 24 +++- src/core/question/providers/delegate.ts | 58 ++++++++ src/core/question/providers/helper.ts | 37 +++++ src/providers/file.ts | 48 ++++--- 8 files changed, 342 insertions(+), 70 deletions(-) diff --git a/src/addon/mod/quiz/providers/quiz-sync.ts b/src/addon/mod/quiz/providers/quiz-sync.ts index 2812cb077..c4521591e 100644 --- a/src/addon/mod/quiz/providers/quiz-sync.ts +++ b/src/addon/mod/quiz/providers/quiz-sync.ts @@ -78,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider * @param quiz Quiz. * @param courseId Course ID. * @param warnings List of warnings generated by the sync. - * @param attemptId Last attempt ID. - * @param offlineAttempt Offline attempt synchronized, if any. - * @param onlineAttempt Online data for the offline attempt. - * @param removeAttempt Whether the offline data should be removed. - * @param updated Whether some data was sent to the site. + * @param options Other options. * @return Promise resolved on success. */ - protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any, - onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise { + protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions) + : Promise { + options = options || {}; // Invalidate the data for the quiz and attempt. - return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => { + return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId).catch(() => { // Ignore errors. }).then(() => { - if (removeAttempt && attemptId) { - return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId); + if (options.removeAttempt && options.attemptId) { + const promises = []; + + promises.push(this.quizOfflineProvider.removeAttemptAndAnswers(options.attemptId, siteId)); + + if (options.onlineQuestions) { + for (const slot in options.onlineQuestions) { + promises.push(this.questionDelegate.deleteOfflineData(options.onlineQuestions[slot], + AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId)); + } + } + + return Promise.all(promises); } }).then(() => { - if (updated) { + if (options.updated) { // Data has been sent. Update prefetched data. return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => { return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); @@ -110,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider }); }).then(() => { // Check if online attempt was finished because of the sync. - if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) { + if (options.onlineAttempt && !this.quizProvider.isAttemptFinished(options.onlineAttempt.state)) { // Attempt wasn't finished at start. Check if it's finished now. return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => { // Search the attempt. for (const i in attempts) { const attempt = attempts[i]; - if (attempt.id == onlineAttempt.id) { + if (attempt.id == options.onlineAttempt.id) { return this.quizProvider.isAttemptFinished(attempt.state); } } @@ -355,7 +363,12 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider // Attempt not found or it's finished in online. Discard it. warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); - return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt, true); + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: offlineAttempt.id, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); } // Get the data stored in offline. @@ -363,7 +376,12 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider if (!answersList.length) { // No answers stored, finish. - return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, true); + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); } const offlineAnswers = this.questionProvider.convertAnswersArrayToObject(answersList); @@ -376,10 +394,8 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider 'core.settings.synchronization', siteId); // Now get the online questions data. - const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions); - const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, { - pages, + pages: this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions), readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, siteId, }); @@ -387,6 +403,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider // Validate questions, discarding the offline answers that can't be synchronized. const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); + // Let questions prepare the data to send. + await Promise.all(Object.keys(offlineQuestions).map(async (slot) => { + const onlineQuestion = onlineQuestions[slot]; + + await this.questionDelegate.prepareSyncData(onlineQuestion, offlineQuestions[slot].answers, + AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId); + })); + // Get the answers to send. const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); const finish = offlineAttempt.finished && !discardedData; @@ -410,7 +434,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider } // Data sent. Finish the sync. - return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, true, true); + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + updated: true, + onlineQuestions, + }); } /** @@ -458,3 +489,15 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider }); } } + +/** + * Options to pass to finish sync. + */ +type FinishSyncOptions = { + attemptId?: number; // Last attempt ID. + offlineAttempt?: any; // Offline attempt synchronized, if any. + onlineAttempt?: any; // Online data for the offline attempt. + removeAttempt?: boolean; // Whether the offline data should be removed. + updated?: boolean; // Whether the offline data should be removed. + onlineQuestions?: any; // Online questions indexed by slot. +}; diff --git a/src/addon/qtype/essay/component/addon-qtype-essay.html b/src/addon/qtype/essay/component/addon-qtype-essay.html index bb496cb6a..bc7ff9a2b 100644 --- a/src/addon/qtype/essay/component/addon-qtype-essay.html +++ b/src/addon/qtype/essay/component/addon-qtype-essay.html @@ -27,7 +27,7 @@ - + diff --git a/src/addon/qtype/essay/component/essay.ts b/src/addon/qtype/essay/component/essay.ts index c2f245661..6fc1dd19c 100644 --- a/src/addon/qtype/essay/component/essay.ts +++ b/src/addon/qtype/essay/component/essay.ts @@ -15,6 +15,7 @@ import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreWSExternalFile } from '@providers/ws'; +import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; import { CoreQuestion } from '@core/question/providers/question'; import { FormControl, FormBuilder } from '@angular/forms'; @@ -48,9 +49,33 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text); if (this.question.allowsAttachments && this.uploadFilesSupported) { - this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments')); - CoreFileSession.instance.setFiles(this.component, - CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments); + this.loadAttachments(); } } + + /** + * Load attachments. + * + * @return Promise resolved when done. + */ + async loadAttachments(): Promise { + if (this.offlineEnabled && this.question.localAnswers['attachments_offline']) { + + const attachmentsData = this.textUtils.parseJSON(this.question.localAnswers['attachments_offline'], {}); + let offlineFiles = []; + + if (attachmentsData.offline) { + offlineFiles = await this.questionHelper.getStoredQuestionFiles(this.question, this.component, this.componentId); + + offlineFiles = CoreFileUploader.instance.markOfflineFiles(offlineFiles); + } + + this.attachments = (attachmentsData.online || []).concat(offlineFiles); + } else { + this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments')); + } + + CoreFileSession.instance.setFiles(this.component, + CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments); + } } diff --git a/src/addon/qtype/essay/providers/handler.ts b/src/addon/qtype/essay/providers/handler.ts index 0f911e828..79a972a30 100644 --- a/src/addon/qtype/essay/providers/handler.ts +++ b/src/addon/qtype/essay/providers/handler.ts @@ -54,6 +54,19 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { CoreFileUploader.instance.clearTmpFiles(files); } + /** + * Delete any stored data for the question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): Promise { + return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId); + } + /** * Return the name of the behaviour to use for the question. * If the question should use the default behaviour you shouldn't implement this function. @@ -186,11 +199,11 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments'); - return CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments); + return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments); } /** - * Prepare and add to answers the data to send to server based in the input. Return promise if async. + * Prepare and add to answers the data to send to server based in the input. * * @param question Question. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. @@ -210,29 +223,103 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { const textarea = element.querySelector('textarea[name*=_answer]'); if (textarea && typeof answers[textarea.name] != 'undefined') { - if (this.questionHelper.hasDraftFileUrls(question.html) && question.responsefileareas) { - // Restore draftfile URLs. - const site = await CoreSites.instance.getSite(siteId); - - answers[textarea.name] = this.textUtils.restoreDraftfileUrls(site.getURL(), answers[textarea.name], - question.html, this.questionHelper.getResponseFileAreaFiles(question, 'answer')); - } - - // Add some HTML to the text if needed. - answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); + await this.prepareTextAnswer(question, answers, textarea, siteId); } if (attachmentsInput) { - // Treat attachments if any. - const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); - const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); - const draftId = Number(attachmentsInput.value); - - if (offline) { - // @TODO Support offline. - } else { - await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId); - } + await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId); } } + + /** + * Prepare attachments. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param attachmentsInput The HTML input containing the draft ID for attachments. + * @param siteId Site ID. If not defined, current site. + * @return Return a promise resolved when done if async, void if sync. + */ + async prepareAttachments(question: any, answers: any, offline: boolean, component: string, componentId: string | number, + attachmentsInput: HTMLInputElement, siteId?: string): Promise { + + // Treat attachments if any. + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + const draftId = Number(attachmentsInput.value); + + if (offline) { + // Get the folder where to store the files. + const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId); + + const result = await CoreFileUploader.instance.storeFilesToUpload(folderPath, attachments); + + // Store the files in the answers. + answers[attachmentsInput.name + '_offline'] = JSON.stringify(result); + } else { + await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId); + } + } + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prepareSyncData(question: any, answers: {[name: string]: any}, component: string, componentId: string | number, + siteId?: string): Promise { + + const element = this.domUtils.convertToElement(question.html); + const attachmentsInput = element.querySelector('.attachments input[name*=_attachments]'); + + if (attachmentsInput) { + // Update the draft ID, the stored one could no longer be valid. + answers.attachments = attachmentsInput.value; + } + + if (answers && answers.attachments_offline) { + // Check if it has new attachments to upload. + const attachmentsData = this.textUtils.parseJSON(answers.attachments_offline, {}); + + if (attachmentsData.offline) { + // Upload the offline files. + const offlineFiles = await this.questionHelper.getStoredQuestionFiles(question, component, componentId, siteId); + + await CoreFileUploader.instance.uploadFiles(answers.attachments, attachmentsData.online.concat(offlineFiles), + siteId); + } + + delete answers.attachments_offline; + } + } + + /** + * Prepare the text answer. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param textarea The textarea HTML element of the question. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prepareTextAnswer(question: any, answers: any, textarea: HTMLTextAreaElement, siteId?: string): Promise { + if (this.questionHelper.hasDraftFileUrls(question.html) && question.responsefileareas) { + // Restore draftfile URLs. + const site = await CoreSites.instance.getSite(siteId); + + answers[textarea.name] = this.textUtils.restoreDraftfileUrls(site.getURL(), answers[textarea.name], + question.html, this.questionHelper.getResponseFileAreaFiles(question, 'answer')); + } + + // Add some HTML to the text if needed. + answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); + } } diff --git a/src/core/fileuploader/providers/fileuploader.ts b/src/core/fileuploader/providers/fileuploader.ts index a12d44987..d27d41864 100644 --- a/src/core/fileuploader/providers/fileuploader.ts +++ b/src/core/fileuploader/providers/fileuploader.ts @@ -552,16 +552,26 @@ export class CoreFileUploaderProvider { return; } - await Promise.all(files.map(async (file) => { - if (( file).filename && !( file).name) { - // File already uploaded, ignore it. - return; - } + // Index the online files by name. + const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {}; + const filesToUpload: FileEntry[] = []; + files.forEach((file) => { + const isOnlineFile = ( file).filename && !( file).name; - file = file; + if (isOnlineFile) { + usedNames[( file).filename.toLowerCase()] = file; + } else { + filesToUpload.push( file); + } + }); + + await Promise.all(filesToUpload.map(async (file) => { + // Make sure the file name is unique in the area. + const name = this.fileProvider.calculateUniqueName(usedNames, file.name); + usedNames[name] = file; // Now upload the file. - const options = this.getFileUploadOptions(file.toURL(), file.name, undefined, false, 'draft', itemId); + const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId); await this.uploadFile(file.toURL(), options, undefined, siteId); })); diff --git a/src/core/question/providers/delegate.ts b/src/core/question/providers/delegate.ts index da8529180..b3c2c6818 100644 --- a/src/core/question/providers/delegate.ts +++ b/src/core/question/providers/delegate.ts @@ -127,8 +127,33 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { * @param question Question. * @param component The component the question is related to. * @param componentId Component ID. + * @return If async, promise resolved when done. */ clearTmpData?(question: any, component: string, componentId: string | number): void | Promise; + + /** + * Delete any stored data for the question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + deleteOfflineData?(question: any, component: string, componentId: string | number, siteId?: string): void | Promise; + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number, + siteId?: string): void | Promise; } /** @@ -309,10 +334,43 @@ export class CoreQuestionDelegate extends CoreDelegate { * @param question Question. * @param component The component the question is related to. * @param componentId Component ID. + * @return If async, promise resolved when done. */ clearTmpData(question: any, component: string, componentId: string | number): void | Promise { const type = this.getTypeName(question); return this.executeFunctionOnEnabled(type, 'clearTmpData', [question, component, componentId]); } + + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): void | Promise { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'deleteOfflineData', [question, component, componentId, siteId]); + } + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number, + siteId?: string): void | Promise { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]); + } } diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 21e4c5a0d..9c6570858 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -13,7 +13,9 @@ // limitations under the License. import { Injectable, EventEmitter } from '@angular/core'; +import { FileEntry } from '@ionic-native/file'; import { TranslateService } from '@ngx-translate/core'; +import { CoreFile } from '@providers/file'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreSitesProvider } from '@providers/sites'; import { CoreWSExternalFile } from '@providers/ws'; @@ -76,6 +78,25 @@ export class CoreQuestionHelperProvider { })); } + /** + * Delete files stored for a question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string) + : Promise { + + const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId); + const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId); + + // Ignore errors, maybe the folder doesn't exist. + await this.utils.ignoreErrors(CoreFile.instance.removeDir(folderPath)); + } + /** * Extract question behaviour submit buttons from the question's HTML and add them to "behaviourButtons" property. * The buttons aren't deleted from the content because all the im-controls block will be removed afterwards. @@ -457,6 +478,22 @@ export class CoreQuestionHelperProvider { return area && area.files || []; } + /** + * Get files stored for a question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + getStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string): Promise { + const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId); + const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId); + + return CoreFile.instance.getDirectoryContents(folderPath); + } + /** * Get the validation error message from a question HTML if it's there. * diff --git a/src/providers/file.ts b/src/providers/file.ts index 98df61923..7c555e71e 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -1114,10 +1114,8 @@ export class CoreFileProvider { // Get existing files in the folder. return this.getDirectoryContents(dirPath).then((entries) => { const files = {}; - let num = 1, - fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName), - extension = this.mimeUtils.getFileExtension(fileName) || defaultExt, - newName; + let fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName); + let extension = this.mimeUtils.getFileExtension(fileName) || defaultExt; // Clean the file name. fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles( @@ -1135,26 +1133,40 @@ export class CoreFileProvider { extension = ''; } - newName = fileNameWithoutExtension + extension; - if (typeof files[newName.toLowerCase()] == 'undefined') { - // No file with the same name. - return newName; - } else { - // Repeated name. Add a number until we find a free name. - do { - newName = fileNameWithoutExtension + '(' + num + ')' + extension; - num++; - } while (typeof files[newName.toLowerCase()] != 'undefined'); - - // Ask the user what he wants to do. - return newName; - } + return this.calculateUniqueName(files, fileNameWithoutExtension + extension); }).catch(() => { // Folder doesn't exist, name is unique. Clean it and return it. return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName)); }); } + /** + * Given a file name and a set of already used names, calculate a unique name. + * + * @param usedNames Object with names already used as keys. + * @param name Name to check. + * @return Unique name. + */ + calculateUniqueName(usedNames: {[name: string]: any}, name: string): string { + if (typeof usedNames[name.toLowerCase()] == 'undefined') { + // No file with the same name. + return name; + } else { + // Repeated name. Add a number until we find a free name. + const nameWithoutExtension = this.mimeUtils.removeExtension(name); + let extension = this.mimeUtils.getFileExtension(name); + let num = 1; + extension = extension ? '.' + extension : ''; + + do { + name = nameWithoutExtension + '(' + num + ')' + extension; + num++; + } while (typeof usedNames[name.toLowerCase()] != 'undefined'); + + return name; + } + } + /** * Remove app temporary folder. *