MOBILE-2272 quiz: Support add new files in essay in offline
parent
9d0ae3c34b
commit
d2f4c85af2
|
@ -78,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
||||||
* @param quiz Quiz.
|
* @param quiz Quiz.
|
||||||
* @param courseId Course ID.
|
* @param courseId Course ID.
|
||||||
* @param warnings List of warnings generated by the sync.
|
* @param warnings List of warnings generated by the sync.
|
||||||
* @param attemptId Last attempt ID.
|
* @param options Other options.
|
||||||
* @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.
|
|
||||||
* @return Promise resolved on success.
|
* @return Promise resolved on success.
|
||||||
*/
|
*/
|
||||||
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any,
|
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions)
|
||||||
onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise<AddonModQuizSyncResult> {
|
: Promise<AddonModQuizSyncResult> {
|
||||||
|
options = options || {};
|
||||||
|
|
||||||
// Invalidate the data for the quiz and attempt.
|
// 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.
|
// Ignore errors.
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
if (removeAttempt && attemptId) {
|
if (options.removeAttempt && options.attemptId) {
|
||||||
return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId);
|
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(() => {
|
}).then(() => {
|
||||||
if (updated) {
|
if (options.updated) {
|
||||||
// Data has been sent. Update prefetched data.
|
// Data has been sent. Update prefetched data.
|
||||||
return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => {
|
return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => {
|
||||||
return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId);
|
return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId);
|
||||||
|
@ -110,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
||||||
});
|
});
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
// Check if online attempt was finished because of the sync.
|
// 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.
|
// Attempt wasn't finished at start. Check if it's finished now.
|
||||||
return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => {
|
return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => {
|
||||||
// Search the attempt.
|
// Search the attempt.
|
||||||
for (const i in attempts) {
|
for (const i in attempts) {
|
||||||
const attempt = attempts[i];
|
const attempt = attempts[i];
|
||||||
|
|
||||||
if (attempt.id == onlineAttempt.id) {
|
if (attempt.id == options.onlineAttempt.id) {
|
||||||
return this.quizProvider.isAttemptFinished(attempt.state);
|
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.
|
// Attempt not found or it's finished in online. Discard it.
|
||||||
warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished'));
|
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.
|
// Get the data stored in offline.
|
||||||
|
@ -363,7 +376,12 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
||||||
|
|
||||||
if (!answersList.length) {
|
if (!answersList.length) {
|
||||||
// No answers stored, finish.
|
// 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);
|
const offlineAnswers = this.questionProvider.convertAnswersArrayToObject(answersList);
|
||||||
|
@ -376,10 +394,8 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
||||||
'core.settings.synchronization', siteId);
|
'core.settings.synchronization', siteId);
|
||||||
|
|
||||||
// Now get the online questions data.
|
// Now get the online questions data.
|
||||||
const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions);
|
|
||||||
|
|
||||||
const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
|
const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
|
||||||
pages,
|
pages: this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions),
|
||||||
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
siteId,
|
siteId,
|
||||||
});
|
});
|
||||||
|
@ -387,6 +403,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
||||||
// Validate questions, discarding the offline answers that can't be synchronized.
|
// Validate questions, discarding the offline answers that can't be synchronized.
|
||||||
const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId);
|
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.
|
// Get the answers to send.
|
||||||
const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions);
|
const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions);
|
||||||
const finish = offlineAttempt.finished && !discardedData;
|
const finish = offlineAttempt.finished && !discardedData;
|
||||||
|
@ -410,7 +434,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data sent. Finish the sync.
|
// 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.
|
||||||
|
};
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
|
|
||||||
<!-- Attachments. -->
|
<!-- Attachments. -->
|
||||||
<ng-container *ngIf="question.allowsAttachments">
|
<ng-container *ngIf="question.allowsAttachments">
|
||||||
<core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offline"></core-attachments>
|
<core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offlineEnabled"></core-attachments>
|
||||||
|
|
||||||
<input item-content *ngIf="uploadFilesSupported" type="hidden" [name]="question.attachmentsDraftIdInput.name" [value]="question.attachmentsDraftIdInput.value" >
|
<input item-content *ngIf="uploadFilesSupported" type="hidden" [name]="question.attachmentsDraftIdInput.name" [value]="question.attachmentsDraftIdInput.value" >
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
import { Component, OnInit, Injector } from '@angular/core';
|
import { Component, OnInit, Injector } from '@angular/core';
|
||||||
import { CoreLoggerProvider } from '@providers/logger';
|
import { CoreLoggerProvider } from '@providers/logger';
|
||||||
import { CoreWSExternalFile } from '@providers/ws';
|
import { CoreWSExternalFile } from '@providers/ws';
|
||||||
|
import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader';
|
||||||
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
|
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
|
||||||
import { CoreQuestion } from '@core/question/providers/question';
|
import { CoreQuestion } from '@core/question/providers/question';
|
||||||
import { FormControl, FormBuilder } from '@angular/forms';
|
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);
|
this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text);
|
||||||
|
|
||||||
if (this.question.allowsAttachments && this.uploadFilesSupported) {
|
if (this.question.allowsAttachments && this.uploadFilesSupported) {
|
||||||
this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments'));
|
this.loadAttachments();
|
||||||
CoreFileSession.instance.setFiles(this.component,
|
|
||||||
CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load attachments.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async loadAttachments(): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,19 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
|
||||||
CoreFileUploader.instance.clearTmpFiles(files);
|
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<void> {
|
||||||
|
return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the name of the behaviour to use for the question.
|
* 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.
|
* 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 attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
|
||||||
const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments');
|
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 question Question.
|
||||||
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
|
* @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 = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
|
const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
|
||||||
|
|
||||||
if (textarea && typeof answers[textarea.name] != 'undefined') {
|
if (textarea && typeof answers[textarea.name] != 'undefined') {
|
||||||
if (this.questionHelper.hasDraftFileUrls(question.html) && question.responsefileareas) {
|
await this.prepareTextAnswer(question, answers, textarea, siteId);
|
||||||
// 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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentsInput) {
|
if (attachmentsInput) {
|
||||||
// Treat attachments if any.
|
await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
|
||||||
|
const element = this.domUtils.convertToElement(question.html);
|
||||||
|
const attachmentsInput = <HTMLInputElement> 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<void> {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -552,16 +552,26 @@ export class CoreFileUploaderProvider {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(files.map(async (file) => {
|
// Index the online files by name.
|
||||||
if ((<CoreWSExternalFile> file).filename && !(<FileEntry> file).name) {
|
const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {};
|
||||||
// File already uploaded, ignore it.
|
const filesToUpload: FileEntry[] = [];
|
||||||
return;
|
files.forEach((file) => {
|
||||||
}
|
const isOnlineFile = (<CoreWSExternalFile> file).filename && !(<FileEntry> file).name;
|
||||||
|
|
||||||
file = <FileEntry> file;
|
if (isOnlineFile) {
|
||||||
|
usedNames[(<CoreWSExternalFile> file).filename.toLowerCase()] = file;
|
||||||
|
} else {
|
||||||
|
filesToUpload.push(<FileEntry> 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.
|
// 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);
|
await this.uploadFile(file.toURL(), options, undefined, siteId);
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -127,8 +127,33 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
|
||||||
* @param question Question.
|
* @param question Question.
|
||||||
* @param component The component the question is related to.
|
* @param component The component the question is related to.
|
||||||
* @param componentId Component ID.
|
* @param componentId Component ID.
|
||||||
|
* @return If async, promise resolved when done.
|
||||||
*/
|
*/
|
||||||
clearTmpData?(question: any, component: string, componentId: string | number): void | Promise<void>;
|
clearTmpData?(question: any, component: string, componentId: string | number): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -309,10 +334,43 @@ export class CoreQuestionDelegate extends CoreDelegate {
|
||||||
* @param question Question.
|
* @param question Question.
|
||||||
* @param component The component the question is related to.
|
* @param component The component the question is related to.
|
||||||
* @param componentId Component ID.
|
* @param componentId Component ID.
|
||||||
|
* @return If async, promise resolved when done.
|
||||||
*/
|
*/
|
||||||
clearTmpData(question: any, component: string, componentId: string | number): void | Promise<void> {
|
clearTmpData(question: any, component: string, componentId: string | number): void | Promise<void> {
|
||||||
const type = this.getTypeName(question);
|
const type = this.getTypeName(question);
|
||||||
|
|
||||||
return this.executeFunctionOnEnabled(type, 'clearTmpData', [question, component, componentId]);
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const type = this.getTypeName(question);
|
||||||
|
|
||||||
|
return this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,9 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable, EventEmitter } from '@angular/core';
|
import { Injectable, EventEmitter } from '@angular/core';
|
||||||
|
import { FileEntry } from '@ionic-native/file';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { CoreFile } from '@providers/file';
|
||||||
import { CoreFilepoolProvider } from '@providers/filepool';
|
import { CoreFilepoolProvider } from '@providers/filepool';
|
||||||
import { CoreSitesProvider } from '@providers/sites';
|
import { CoreSitesProvider } from '@providers/sites';
|
||||||
import { CoreWSExternalFile } from '@providers/ws';
|
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<void> {
|
||||||
|
|
||||||
|
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.
|
* 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.
|
* 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 || [];
|
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<FileEntry[]> {
|
||||||
|
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.
|
* Get the validation error message from a question HTML if it's there.
|
||||||
*
|
*
|
||||||
|
|
|
@ -1114,10 +1114,8 @@ export class CoreFileProvider {
|
||||||
// Get existing files in the folder.
|
// Get existing files in the folder.
|
||||||
return this.getDirectoryContents(dirPath).then((entries) => {
|
return this.getDirectoryContents(dirPath).then((entries) => {
|
||||||
const files = {};
|
const files = {};
|
||||||
let num = 1,
|
let fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName);
|
||||||
fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName),
|
let extension = this.mimeUtils.getFileExtension(fileName) || defaultExt;
|
||||||
extension = this.mimeUtils.getFileExtension(fileName) || defaultExt,
|
|
||||||
newName;
|
|
||||||
|
|
||||||
// Clean the file name.
|
// Clean the file name.
|
||||||
fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles(
|
fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles(
|
||||||
|
@ -1135,26 +1133,40 @@ export class CoreFileProvider {
|
||||||
extension = '';
|
extension = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
newName = fileNameWithoutExtension + extension;
|
return this.calculateUniqueName(files, 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;
|
|
||||||
}
|
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Folder doesn't exist, name is unique. Clean it and return it.
|
// Folder doesn't exist, name is unique. Clean it and return it.
|
||||||
return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName));
|
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.
|
* Remove app temporary folder.
|
||||||
*
|
*
|
||||||
|
|
Loading…
Reference in New Issue