From 02f25f50ac58f1dee8d7006cb54aac77e854fe28 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 27 Mar 2018 10:02:54 +0200 Subject: [PATCH] MOBILE-2348 quiz: Implement sync provider and handler --- src/addon/messages/providers/sync.ts | 12 +- src/addon/mod/quiz/providers/quiz-sync.ts | 404 ++++++++++++++++++ .../mod/quiz/providers/sync-cron-handler.ts | 47 ++ src/addon/mod/survey/providers/sync.ts | 12 +- src/addon/notes/providers/notes-sync.ts | 11 +- src/classes/base-sync.ts | 39 +- 6 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 src/addon/mod/quiz/providers/quiz-sync.ts create mode 100644 src/addon/mod/quiz/providers/sync-cron-handler.ts diff --git a/src/addon/messages/providers/sync.ts b/src/addon/messages/providers/sync.ts index 6e2dfcbfe..0cc5a6c5b 100644 --- a/src/addon/messages/providers/sync.ts +++ b/src/addon/messages/providers/sync.ts @@ -34,12 +34,12 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_messages_autom_synced'; - constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider, - protected appProvider: CoreAppProvider, private messagesOffline: AddonMessagesOfflineProvider, - private eventsProvider: CoreEventsProvider, private messagesProvider: AddonMessagesProvider, - private userProvider: CoreUserProvider, private translate: TranslateService, private utils: CoreUtilsProvider, - syncProvider: CoreSyncProvider, protected textUtils: CoreTextUtilsProvider) { - super('AddonMessagesSync', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils); + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + translate: TranslateService, syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, + private messagesOffline: AddonMessagesOfflineProvider, private eventsProvider: CoreEventsProvider, + private messagesProvider: AddonMessagesProvider, private userProvider: CoreUserProvider, + private utils: CoreUtilsProvider) { + super('AddonMessagesSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); } /** diff --git a/src/addon/mod/quiz/providers/quiz-sync.ts b/src/addon/mod/quiz/providers/quiz-sync.ts new file mode 100644 index 000000000..901850d1c --- /dev/null +++ b/src/addon/mod/quiz/providers/quiz-sync.ts @@ -0,0 +1,404 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreQuestionProvider } from '@core/question/providers/question'; +import { CoreQuestionDelegate } from '@core/question/providers/delegate'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { AddonModQuizProvider } from './quiz'; +import { AddonModQuizOfflineProvider } from './quiz-offline'; +import { AddonModQuizPrefetchHandler } from './prefetch-handler'; + +/** + * Data returned by a quiz sync. + */ +export interface AddonModQuizSyncResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether an attempt was finished in the site due to the sync, + * @type {boolean} + */ + attemptFinished: boolean; +} + +/** + * Service to sync quizzes. + */ +@Injectable() +export class AddonModQuizSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_quiz_autom_synced'; + static SYNC_TIME = 300000; + + protected componentTranslate: string; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private quizProvider: AddonModQuizProvider, private quizOfflineProvider: AddonModQuizOfflineProvider, + private prefetchHandler: AddonModQuizPrefetchHandler, private questionProvider: CoreQuestionProvider, + private questionDelegate: CoreQuestionDelegate) { + + super('AddonModQuizSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('quiz'); + } + + /** + * Finish a sync process: remove offline data if needed, prefetch quiz data, set sync time and return the result. + * + * @param {string} siteId Site ID. + * @param {any} quiz Quiz. + * @param {number} courseId Course ID. + * @param {string[]} warnings List of warnings generated by the sync. + * @param {number} [attemptId] Last attempt ID. + * @param {any} [offlineAttempt] Offline attempt synchronized, if any. + * @param {any} [onlineAttempt] Online data for the offline attempt. + * @param {boolean} [removeAttempt] Whether the offline data should be removed. + * @param {boolean} [updated] Whether some data was sent to the site. + * @return {Promise} 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 { + + // Invalidate the data for the quiz and attempt. + return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => { + // Ignore errors. + }).then(() => { + if (removeAttempt && attemptId) { + return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId); + } + }).then(() => { + if (updated) { + // Data has been sent. Update prefetched data. + return this.prefetchHandler.prefetchQuizAndLastAttempt(quiz, false, siteId); + } + }).then(() => { + return this.setSyncTime(quiz.id, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // Check if online attempt was finished because of the sync. + if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) { + // Attempt wasn't finished at start. Check if it's finished now. + return this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, false, siteId).then((attempts) => { + // Search the attempt. + for (const i in attempts) { + const attempt = attempts[i]; + + if (attempt.id == onlineAttempt.id) { + return this.quizProvider.isAttemptFinished(attempt.state); + } + } + + return false; + }); + } + + return false; + }).then((attemptFinished) => { + return { + warnings: warnings, + attemptFinished: attemptFinished + }; + }); + } + + /** + * Check if a quiz has data to synchronize. + * + * @param {number} quizId Quiz ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether it has data to sync. + */ + hasDataToSync(quizId: number, siteId?: string): Promise { + return this.quizOfflineProvider.getQuizAttempts(quizId, siteId).then((attempts) => { + return !!attempts.length; + }).catch(() => { + return false; + }); + } + + /** + * Try to synchronize all the quizzes in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllQuizzes(siteId?: string): Promise { + return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this), [], siteId); + } + + /** + * Sync all quizzes on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllQuizzesFunc(siteId?: string): Promise { + // Get all offline attempts. + return this.quizOfflineProvider.getAllAttempts(siteId).then((attempts) => { + const quizzes = [], + ids = [], // To prevent duplicates. + promises = []; + + // Get the IDs of all the quizzes that have something to be synced. + attempts.forEach((attempt) => { + if (ids.indexOf(attempt.quizid) == -1) { + ids.push(attempt.quizid); + + quizzes.push({ + id: attempt.quizid, + courseid: attempt.courseid + }); + } + }); + + // Sync all quizzes that haven't been synced for a while and that aren't attempted right now. + quizzes.forEach((quiz) => { + if (!this.syncProvider.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) { + + // Quiz not blocked, try to synchronize it. + promises.push(this.quizProvider.getQuizById(quiz.courseid, quiz.id, false, siteId).then((quiz) => { + return this.syncQuizIfNeeded(quiz, false, siteId).then((data) => { + if (data && data.warnings && data.warnings.length) { + // Store the warnings to show them when the user opens the quiz. + return this.setSyncWarnings(quiz.id, data.warnings, siteId).then(() => { + return data; + }); + } + + return data; + }).then((data) => { + if (typeof data != 'undefined') { + // Sync successful. Send event. + this.eventsProvider.trigger(AddonModQuizSyncProvider.AUTO_SYNCED, { + quizId: quiz.id, + attemptFinished: data.attemptFinished, + warnings: data.warnings + }, siteId); + } + }); + })); + } + }); + + return Promise.all(promises); + }); + } + + /** + * Sync a quiz only if a certain time has passed since the last time. + * + * @param {any} quiz Quiz. + * @param {boolean} [askPreflight] Whether we should ask for preflight data if needed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the quiz is synced or if it doesn't need to be synced. + */ + syncQuizIfNeeded(quiz: any, askPreflight?: boolean, siteId?: string): Promise { + return this.isSyncNeeded(quiz.id, siteId).then((needed) => { + if (needed) { + return this.syncQuiz(quiz, askPreflight, siteId); + } + }); + } + + /** + * Try to synchronize a quiz. + * The promise returned will be resolved with an array with warnings if the synchronization is successful. + * + * @param {any} quiz Quiz. + * @param {boolean} [askPreflight] Whether we should ask for preflight data if needed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success. + */ + syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const warnings = [], + courseId = quiz.course; + let syncPromise, + preflightData; + + if (this.isSyncing(quiz.id, siteId)) { + // There's already a sync ongoing for this quiz, return the promise. + return this.getOngoingSync(quiz.id, siteId); + } + + // Verify that quiz isn't blocked. + if (this.syncProvider.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) { + this.logger.debug('Cannot sync quiz ' + quiz.id + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId); + + // Get all the offline attempts for the quiz. + syncPromise = this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId).then((attempts) => { + // Should return 0 or 1 attempt. + if (!attempts.length) { + return this.finishSync(siteId, quiz, courseId, warnings); + } + + const offlineAttempt = attempts.pop(); + + // Now get the list of online attempts to make sure this attempt exists and isn't finished. + return this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((attempts) => { + const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined; + let onlineAttempt; + + // Search the attempt we retrieved from offline. + for (const i in attempts) { + const attempt = attempts[i]; + + if (attempt.id == offlineAttempt.id) { + onlineAttempt = attempt; + break; + } + } + + if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { + // 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, lastAttemptId, offlineAttempt, onlineAttempt, true); + } + + // Get the data stored in offline. + return this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId).then((answersList) => { + + if (!answersList.length) { + // No answers stored, finish. + return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, + true); + } + + const answers = this.questionProvider.convertAnswersArrayToObject(answersList), + offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(answers); + let finish; + + // We're going to need preflightData, get it. + return this.quizProvider.getQuizAccessInformation(quiz.id, false, true, siteId).then((info) => { + + return this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight, + 'core.settings.synchronization', siteId); + }).then((data) => { + preflightData = data; + + // Now get the online questions data. + const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions); + + return this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, pages, false, true, + siteId); + }).then((onlineQuestions) => { + + // Validate questions, discarding the offline answers that can't be synchronized. + return this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); + }).then((discardedData) => { + + // Get the answers to send. + const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); + finish = offlineAttempt.finished && !discardedData; + + if (discardedData) { + if (offlineAttempt.finished) { + warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); + } else { + warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded')); + } + } + + return this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, + siteId); + }).then(() => { + + // Answers sent, now set the current page if the attempt isn't finished. + if (!finish) { + return this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, preflightData, + false).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + + // Data sent. Finish the sync. + return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, + true, true); + }); + }); + }); + }); + + return this.addOngoingSync(quiz.id, syncPromise, siteId); + } + + /** + * Validate questions, discarding the offline answers that can't be synchronized. + * + * @param {number} attemptId Attempt ID. + * @param {any} onlineQuestions Online questions + * @param {any} offlineQuestions Offline questions. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if some offline data was discarded, false otherwise. + */ + validateQuestions(attemptId: number, onlineQuestions: any, offlineQuestions: any, siteId?: string): Promise { + const promises = []; + let discardedData = false; + + for (const slot in offlineQuestions) { + const offlineQuestion = offlineQuestions[slot], + onlineQuestion = onlineQuestions[slot], + offlineSequenceCheck = offlineQuestion.answers[':sequencecheck']; + + if (onlineQuestion) { + + // We found the online data for the question, validate that the sequence check is ok. + if (!this.questionDelegate.validateSequenceCheck(onlineQuestion, offlineSequenceCheck)) { + // Sequence check is not valid, remove the offline data. + discardedData = true; + promises.push(this.quizOfflineProvider.removeQuestionAndAnswers(attemptId, Number(slot), siteId)); + delete offlineQuestions[slot]; + } else { + // Sequence check is valid. Use the online one to prevent synchronization errors. + offlineQuestion.answers[':sequencecheck'] = onlineQuestion.sequencecheck; + } + } else { + // Online question not found, it can happen for 2 reasons: + // 1- It's a sequential quiz and the question is in a page already passed. + // 2- Quiz layout has changed (shouldn't happen since it's blocked if there are attempts). + discardedData = true; + promises.push(this.quizOfflineProvider.removeQuestionAndAnswers(attemptId, Number(slot), siteId)); + delete offlineQuestions[slot]; + } + } + + return Promise.all(promises).then(() => { + return discardedData; + }); + } +} diff --git a/src/addon/mod/quiz/providers/sync-cron-handler.ts b/src/addon/mod/quiz/providers/sync-cron-handler.ts new file mode 100644 index 000000000..d25c2d30b --- /dev/null +++ b/src/addon/mod/quiz/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { AddonModQuizSyncProvider } from './quiz-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModQuizSyncCronHandler implements CoreCronHandler { + name = 'AddonModQuizSyncCronHandler'; + + constructor(private quizSync: AddonModQuizSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.quizSync.syncAllQuizzes(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModQuizSyncProvider.SYNC_TIME; + } +} diff --git a/src/addon/mod/survey/providers/sync.ts b/src/addon/mod/survey/providers/sync.ts index 7c3aaa35e..d0717acf8 100644 --- a/src/addon/mod/survey/providers/sync.ts +++ b/src/addon/mod/survey/providers/sync.ts @@ -35,12 +35,14 @@ export class AddonModSurveySyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_mod_survey_autom_synced'; protected componentTranslate: string; - constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider, - protected appProvider: CoreAppProvider, private surveyOffline: AddonModSurveyOfflineProvider, + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + courseProvider: CoreCourseProvider, private surveyOffline: AddonModSurveyOfflineProvider, private eventsProvider: CoreEventsProvider, private surveyProvider: AddonModSurveyProvider, - private translate: TranslateService, private utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider, - courseProvider: CoreCourseProvider, syncProvider: CoreSyncProvider) { - super('AddonModSurveySyncProvider', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils); + private utils: CoreUtilsProvider) { + + super('AddonModSurveySyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + this.componentTranslate = courseProvider.translateModuleName('survey'); } diff --git a/src/addon/notes/providers/notes-sync.ts b/src/addon/notes/providers/notes-sync.ts index d443385db..059d61530 100644 --- a/src/addon/notes/providers/notes-sync.ts +++ b/src/addon/notes/providers/notes-sync.ts @@ -34,12 +34,13 @@ export class AddonNotesSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_notes_autom_synced'; - constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider, - protected appProvider: CoreAppProvider, private notesOffline: AddonNotesOfflineProvider, + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + private notesOffline: AddonNotesOfflineProvider, private utils: CoreUtilsProvider, private eventsProvider: CoreEventsProvider, private notesProvider: AddonNotesProvider, - private coursesProvider: CoreCoursesProvider, private translate: TranslateService, private utils: CoreUtilsProvider, - syncProvider: CoreSyncProvider, protected textUtils: CoreTextUtilsProvider) { - super('AddonNotesSync', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils); + private coursesProvider: CoreCoursesProvider) { + + super('AddonNotesSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); } /** diff --git a/src/classes/base-sync.ts b/src/classes/base-sync.ts index aa413dbcc..d9fc0d477 100644 --- a/src/classes/base-sync.ts +++ b/src/classes/base-sync.ts @@ -12,11 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSyncProvider } from '@providers/sync'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreAppProvider } from '@providers/app'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import * as moment from 'moment'; /** * Base class to create sync providers. It provides some common functions. @@ -44,10 +46,14 @@ export class CoreSyncBaseProvider { // Store sync promises. protected syncPromises: { [siteId: string]: { [uniqueId: string]: Promise } } = {}; - constructor(component: string, protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider, + // List of services that will be injected using injector. + // It's done like this so subclasses don't have to send all the services to the parent in the constructor. + + constructor(component: string, loggerProvider: CoreLoggerProvider, protected sitesProvider: CoreSitesProvider, protected appProvider: CoreAppProvider, protected syncProvider: CoreSyncProvider, - protected textUtils: CoreTextUtilsProvider) { - this.logger = this.loggerProvider.getInstance(component); + protected textUtils: CoreTextUtilsProvider, protected translate: TranslateService) { + + this.logger = loggerProvider.getInstance(component); this.component = component; } @@ -93,6 +99,33 @@ export class CoreSyncBaseProvider { } } + /** + * Get the synchronization time in a human readable format. + * + * @param {string | number} id Unique sync identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the readable time. + */ + getReadableSyncTime(id: string | number, siteId?: string): Promise { + return this.getSyncTime(id, siteId).then((time) => { + return this.getReadableTimeFromTimestamp(time); + }); + } + + /** + * Given a timestamp return it in a human readable format. + * + * @param {number} timestamp Timestamp + * @return {string} Human readable time. + */ + getReadableTimeFromTimestamp(timestamp: number): string { + if (!timestamp) { + return this.translate.instant('core.never'); + } else { + return moment(timestamp).format('LLL'); + } + } + /** * Get the synchronization time. Returns 0 if no time stored. *