MOBILE-2272 quiz: Support add new files in essay in offline

main
Dani Palou 2020-09-30 12:01:04 +02:00
parent 9d0ae3c34b
commit d2f4c85af2
8 changed files with 342 additions and 70 deletions

View File

@ -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<AddonModQuizSyncResult> {
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions)
: Promise<AddonModQuizSyncResult> {
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.
};

View File

@ -27,7 +27,7 @@
<!-- Attachments. -->
<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" >

View File

@ -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.loadAttachments();
}
}
/**
* 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);
}
}
}

View File

@ -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<void> {
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,6 +223,94 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
if (textarea && typeof answers[textarea.name] != 'undefined') {
await this.prepareTextAnswer(question, answers, textarea, siteId);
}
if (attachmentsInput) {
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<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);
@ -221,18 +322,4 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
// Add some HTML to the text if needed.
answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]);
}
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);
}
}
}
}

View File

@ -552,16 +552,26 @@ export class CoreFileUploaderProvider {
return;
}
await Promise.all(files.map(async (file) => {
if ((<CoreWSExternalFile> file).filename && !(<FileEntry> 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 = (<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.
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);
}));

View File

@ -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<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 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<void> {
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<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]);
}
}

View File

@ -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<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.
* 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<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.
*

View File

@ -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.
*