diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 42b0102c1..4ada12bc3 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1269,7 +1269,9 @@ "core.comments.comments": "Comments", "core.comments.commentscount": "Comments ({{$a}})", "core.comments.commentsnotworking": "Comments cannot be retrieved", + "core.comments.eventcommentcreated": "Comment created", "core.comments.nocomments": "No comments", + "core.comments.savecomment": "Save comment", "core.completion-alt-auto-fail": "Completed: {{$a}} (did not achieve pass grade)", "core.completion-alt-auto-n": "Not completed: {{$a}}", "core.completion-alt-auto-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}})", diff --git a/src/core/comments/comments.module.ts b/src/core/comments/comments.module.ts index 3dc800d05..d19d2ccf3 100644 --- a/src/core/comments/comments.module.ts +++ b/src/core/comments/comments.module.ts @@ -13,8 +13,12 @@ // limitations under the License. import { NgModule } from '@angular/core'; -import { CoreCommentsProvider } from './providers/comments'; import { CoreEventsProvider } from '@providers/events'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreCommentsProvider } from './providers/comments'; +import { CoreCommentsOfflineProvider } from './providers/offline'; +import { CoreCommentsSyncCronHandler } from './providers/sync-cron-handler'; +import { CoreCommentsSyncProvider } from './providers/sync'; @NgModule({ declarations: [ @@ -22,15 +26,20 @@ import { CoreEventsProvider } from '@providers/events'; imports: [ ], providers: [ - CoreCommentsProvider + CoreCommentsProvider, + CoreCommentsOfflineProvider, + CoreCommentsSyncProvider, + CoreCommentsSyncCronHandler ] }) export class CoreCommentsModule { - constructor(eventsProvider: CoreEventsProvider) { + constructor(eventsProvider: CoreEventsProvider, cronDelegate: CoreCronDelegate, syncHandler: CoreCommentsSyncCronHandler) { // Reset comments page size. eventsProvider.on(CoreEventsProvider.LOGIN, () => { CoreCommentsProvider.pageSize = null; CoreCommentsProvider.pageSizeOK = false; }); + + cronDelegate.register(syncHandler); } -} \ No newline at end of file +} diff --git a/src/core/comments/components/comments/comments.ts b/src/core/comments/components/comments/comments.ts index b380d8f32..6f7a22444 100644 --- a/src/core/comments/components/comments/comments.ts +++ b/src/core/comments/components/comments/comments.ts @@ -103,7 +103,7 @@ export class CoreCommentsCommentsComponent implements OnChanges, OnDestroy { this.navCtrl.push('CoreCommentsViewerPage', { contextLevel: this.contextLevel, instanceId: this.instanceId, - component: this.component, + componentName: this.component, itemId: this.itemId, area: this.area, title: this.title, diff --git a/src/core/comments/lang/en.json b/src/core/comments/lang/en.json index 0eb280877..b6c99e4c3 100644 --- a/src/core/comments/lang/en.json +++ b/src/core/comments/lang/en.json @@ -3,5 +3,7 @@ "comments": "Comments", "commentscount": "Comments ({{$a}})", "commentsnotworking": "Comments cannot be retrieved", - "nocomments": "No comments" + "eventcommentcreated": "Comment created", + "nocomments": "No comments", + "savecomment": "Save comment" } \ No newline at end of file diff --git a/src/core/comments/pages/add/add.html b/src/core/comments/pages/add/add.html new file mode 100644 index 000000000..b5ca49440 --- /dev/null +++ b/src/core/comments/pages/add/add.html @@ -0,0 +1,22 @@ + + + {{ 'core.comments.addcomment' | translate }} + + + + + + +
+ + + +
+ +
+
+
diff --git a/src/core/comments/pages/add/add.module.ts b/src/core/comments/pages/add/add.module.ts new file mode 100644 index 000000000..a6b6661a0 --- /dev/null +++ b/src/core/comments/pages/add/add.module.ts @@ -0,0 +1,31 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { CoreCommentsAddPage } from './add'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreCommentsAddPage + ], + imports: [ + CoreDirectivesModule, + IonicPageModule.forChild(CoreCommentsAddPage), + TranslateModule.forChild() + ] +}) +export class CoreCommentsAddPageModule {} diff --git a/src/core/comments/pages/add/add.ts b/src/core/comments/pages/add/add.ts new file mode 100644 index 000000000..1d58db46f --- /dev/null +++ b/src/core/comments/pages/add/add.ts @@ -0,0 +1,82 @@ +// (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 { Component } from '@angular/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; +import { CoreAppProvider } from '@providers/app'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreCommentsProvider } from '../../providers/comments'; + +/** + * Component that displays a text area for composing a comment. + */ +@IonicPage({ segment: 'core-comments-add' }) +@Component({ + selector: 'page-core-comments-add', + templateUrl: 'add.html', +}) +export class CoreCommentsAddPage { + protected contextLevel: string; + protected instanceId: number; + protected componentName: string; + protected itemId: number; + protected area = ''; + + content = ''; + processing = false; + + constructor(params: NavParams, private viewCtrl: ViewController, private appProvider: CoreAppProvider, + private domUtils: CoreDomUtilsProvider, private commentsProvider: CoreCommentsProvider) { + this.contextLevel = params.get('contextLevel'); + this.instanceId = params.get('instanceId'); + this.componentName = params.get('componentName'); + this.itemId = params.get('itemId'); + this.area = params.get('area') || ''; + this.content = params.get('content') || ''; + } + + /** + * Send the comment or store it offline. + * + * @param {Event} e Event. + */ + addComment(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + this.appProvider.closeKeyboard(); + const loadingModal = this.domUtils.showModalLoading('core.sending', true); + // Freeze the add note button. + this.processing = true; + this.commentsProvider.addComment(this.content, this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area).then((commentsResponse) => { + this.viewCtrl.dismiss({comments: commentsResponse}).finally(() => { + this.domUtils.showToast(commentsResponse ? 'core.comments.eventcommentcreated' : 'core.datastoredoffline', true, + 3000); + }); + }).catch((error) => { + this.domUtils.showErrorModal(error); + this.processing = false; + }).finally(() => { + loadingModal.dismiss(); + }); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } +} diff --git a/src/core/comments/pages/viewer/viewer.html b/src/core/comments/pages/viewer/viewer.html index cb825c4dd..812838695 100644 --- a/src/core/comments/pages/viewer/viewer.html +++ b/src/core/comments/pages/viewer/viewer.html @@ -1,20 +1,44 @@ + + + + + + - + +
+ + {{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }} +
+ + + + +

{{ offlineComment.fullname }}

+

+ {{ 'core.notsent' | translate }} +

+
+ + + +
+

{{ comment.fullname }}

-

{{ comment.time }}

+

{{ comment.timecreated * 1000 | coreFormatDate: 'strftimerecentfull' }}

@@ -25,7 +49,7 @@
- diff --git a/src/core/comments/pages/viewer/viewer.module.ts b/src/core/comments/pages/viewer/viewer.module.ts index ca5267970..3326cfe19 100644 --- a/src/core/comments/pages/viewer/viewer.module.ts +++ b/src/core/comments/pages/viewer/viewer.module.ts @@ -18,6 +18,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreCommentsViewerPage } from './viewer'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCommentsComponentsModule } from '../../components/components.module'; @NgModule({ @@ -27,6 +28,7 @@ import { CoreCommentsComponentsModule } from '../../components/components.module imports: [ CoreComponentsModule, CoreDirectivesModule, + CorePipesModule, CoreCommentsComponentsModule, IonicPageModule.forChild(CoreCommentsViewerPage), TranslateModule.forChild() diff --git a/src/core/comments/pages/viewer/viewer.ts b/src/core/comments/pages/viewer/viewer.ts index d253fbcb8..50bbf004f 100644 --- a/src/core/comments/pages/viewer/viewer.ts +++ b/src/core/comments/pages/viewer/viewer.ts @@ -12,13 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild } from '@angular/core'; -import { IonicPage, Content, NavParams } from 'ionic-angular'; +import { Component, ViewChild, OnDestroy } from '@angular/core'; +import { IonicPage, Content, NavParams, ModalController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreEventsProvider } from '@providers/events'; import { CoreUserProvider } from '@core/user/providers/user'; import { CoreCommentsProvider } from '../../providers/comments'; +import { CoreCommentsOfflineProvider } from '../../providers/offline'; +import { CoreCommentsSyncProvider } from '../../providers/sync'; /** * Page that displays comments. @@ -28,14 +32,14 @@ import { CoreCommentsProvider } from '../../providers/comments'; selector: 'page-core-comments-viewer', templateUrl: 'viewer.html', }) -export class CoreCommentsViewerPage { +export class CoreCommentsViewerPage implements OnDestroy { @ViewChild(Content) content: Content; comments = []; commentsLoaded = false; contextLevel: string; instanceId: number; - component: string; + componentName: string; itemId: number; area: string; page: number; @@ -43,20 +47,48 @@ export class CoreCommentsViewerPage { canLoadMore = false; loadMoreError = false; canAddComments = false; + hasOffline = false; + refreshIcon = 'spinner'; + syncIcon = 'spinner'; + offlineComment: any; protected addCommentsAvailable = false; + protected syncObserver: any; + protected currentUser: any; - constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, private userProvider: CoreUserProvider, - private domUtils: CoreDomUtilsProvider, private translate: TranslateService, - private commentsProvider: CoreCommentsProvider) { + constructor(navParams: NavParams, private sitesProvider: CoreSitesProvider, private userProvider: CoreUserProvider, + private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private modalCtrl: ModalController, + private commentsProvider: CoreCommentsProvider, private offlineComments: CoreCommentsOfflineProvider, + eventsProvider: CoreEventsProvider, private commentsSync: CoreCommentsSyncProvider, + private textUtils: CoreTextUtilsProvider) { this.contextLevel = navParams.get('contextLevel'); this.instanceId = navParams.get('instanceId'); - this.component = navParams.get('component'); + this.componentName = navParams.get('componentName'); this.itemId = navParams.get('itemId'); this.area = navParams.get('area') || ''; this.title = navParams.get('title') || this.translate.instant('core.comments.comments'); this.page = 0; + + // Refresh data if comments are synchronized automatically. + this.syncObserver = eventsProvider.on(CoreCommentsSyncProvider.AUTO_SYNCED, (data) => { + if (data.contextLevel == this.contextLevel && data.instanceId == this.instanceId && + data.componentName == this.componentName && data.itemId == this.itemId && data.area == this.area) { + // Show the sync warnings. + this.showSyncWarnings(data.warnings); + + // Refresh the data. + this.commentsLoaded = false; + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + this.domUtils.scrollToTop(this.content); + + this.page = 0; + this.comments = []; + this.fetchComments(false); + } + }, sitesProvider.getCurrentSiteId()); } /** @@ -67,46 +99,78 @@ export class CoreCommentsViewerPage { this.addCommentsAvailable = enabled; }); - this.fetchComments().finally(() => { - this.commentsLoaded = true; - }); + this.fetchComments(true); } /** * Fetches the comments. * + * @param {boolean} sync When to resync notes. + * @param {boolean} [showErrors] When to display errors or not. * @return {Promise} Resolved when done. */ - protected fetchComments(): Promise { + protected fetchComments(sync: boolean, showErrors?: boolean): Promise { this.loadMoreError = false; - // Get comments data. - return this.commentsProvider.getComments(this.contextLevel, this.instanceId, this.component, this.itemId, - this.area, this.page).then((response) => { - this.canAddComments = this.addCommentsAvailable && response.canpost; + const promise = sync ? this.syncComment(showErrors) : Promise.resolve(); - const comments = response.comments.sort((a, b) => b.timecreated - a.timecreated); - this.canLoadMore = comments.length >= CoreCommentsProvider.pageSize; + return promise.catch(() => { + // Ignore errors. + }).then(() => { + return this.offlineComments.getComment(this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area).then((offlineComment) => { + this.hasOffline = !!offlineComment; + this.offlineComment = offlineComment; - this.comments.forEach((comment) => { - // Get the user profile image. - this.userProvider.getProfile(comment.userid, undefined, true).then((user) => { - comment.profileimageurl = user.profileimageurl; - }).catch(() => { - // Ignore errors. - }); + if (this.hasOffline && !this.currentUser) { + return this.userProvider.getProfile(this.sitesProvider.getCurrentSiteUserId(), undefined, true).then((user) => { + this.currentUser = user; + this.offlineComment.profileimageurl = user.profileimageurl; + this.offlineComment.fullname = user.fullname; + this.offlineComment.userid = user.id; + }).catch(() => { + // Ignore errors. + }); + } else if (this.hasOffline) { + this.offlineComment.profileimageurl = this.currentUser.profileimageurl; + this.offlineComment.fullname = this.currentUser.fullname; + this.offlineComment.userid = this.currentUser.id; + } }); + }).then(() => { - this.comments = this.comments.concat(comments); + // Get comments data. + return this.commentsProvider.getComments(this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area, this.page).then((response) => { + this.canAddComments = this.addCommentsAvailable && response.canpost; + const comments = response.comments.sort((a, b) => b.timecreated - a.timecreated); + this.canLoadMore = comments.length >= CoreCommentsProvider.pageSize; + + this.comments.forEach((comment) => { + // Get the user profile image. + this.userProvider.getProfile(comment.userid, undefined, true).then((user) => { + comment.profileimageurl = user.profileimageurl; + }).catch(() => { + // Ignore errors. + }); + }); + + this.comments = this.comments.concat(comments); + }); }).catch((error) => { this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. - if (error && this.component == 'assignsubmission_comments') { + if (error && this.componentName == 'assignsubmission_comments') { this.domUtils.showAlertTranslated('core.notice', 'core.comments.commentsnotworking'); } else { this.domUtils.showErrorModalDefault(error, this.translate.instant('core.error') + ': get_comments'); } + }).finally(() => { + this.commentsLoaded = true; + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; }); + } /** @@ -119,7 +183,7 @@ export class CoreCommentsViewerPage { this.page++; this.canLoadMore = false; - return this.fetchComments().finally(() => { + return this.fetchComments(true).finally(() => { infiniteComplete && infiniteComplete(); }); } @@ -127,17 +191,89 @@ export class CoreCommentsViewerPage { /** * Refresh the comments. * - * @param {any} refresher Refresher. + * @param {boolean} showErrors Whether to display errors or not. + * @param {any} [refresher] Refresher. + * @return {Promise} Resolved when done. */ - refreshComments(refresher: any): void { - this.commentsProvider.invalidateCommentsData(this.contextLevel, this.instanceId, this.component, + refreshComments(showErrors: boolean, refresher?: any): Promise { + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + return this.commentsProvider.invalidateCommentsData(this.contextLevel, this.instanceId, this.componentName, this.itemId, this.area).finally(() => { this.page = 0; this.comments = []; - return this.fetchComments().finally(() => { - refresher.complete(); + return this.fetchComments(true, showErrors).finally(() => { + refresher && refresher.complete(); }); }); } + + /** + * Show sync warnings if any. + * + * @param {string[]} warnings the warnings + */ + private showSyncWarnings(warnings: string[]): void { + const message = this.textUtils.buildMessage(warnings); + if (message) { + this.domUtils.showErrorModal(message); + } + } + + /** + * Tries to synchronize comments. + * + * @param {boolean} showErrors Whether to display errors or not. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + private syncComment(showErrors: boolean): Promise { + return this.commentsSync.syncComment(this.contextLevel, this.instanceId, this.componentName, this.itemId, + this.area).then((warnings) => { + this.showSyncWarnings(warnings); + }).catch((error) => { + if (showErrors) { + this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + + return Promise.reject(null); + }); + } + + /** + * Add a new comment to the list. + * + * @param {Event} e Event. + */ + addComment(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + const params = { + contextLevel: this.contextLevel, + instanceId: this.instanceId, + componentName: this.componentName, + itemId: this.itemId, + area: this.area, + content: this.hasOffline ? this.offlineComment.content : '' + }; + + const modal = this.modalCtrl.create('CoreCommentsAddPage', params); + modal.onDidDismiss((data) => { + if (data && data.comments) { + this.comments = data.comments.concat(this.comments); + } else if (data && !data.comments) { + this.fetchComments(false); + } + }); + modal.present(); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.syncObserver && this.syncObserver.off(); + } } diff --git a/src/core/comments/providers/comments.ts b/src/core/comments/providers/comments.ts index e5634bf26..a17748b08 100644 --- a/src/core/comments/providers/comments.ts +++ b/src/core/comments/providers/comments.ts @@ -13,8 +13,11 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSite } from '@classes/site'; +import { CoreCommentsOfflineProvider } from './offline'; /** * Service that provides some features regarding comments. @@ -26,7 +29,107 @@ export class CoreCommentsProvider { static pageSize = null; static pageSizeOK = false; // If true, the pageSize is definitive. If not, it's a temporal value to reduce WS calls. - constructor(private sitesProvider: CoreSitesProvider) {} + constructor(private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, + private commentsOffline: CoreCommentsOfflineProvider) {} + + /** + * Add a comment. + * + * @param {string} content Comment text. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if comment was sent to server, false if stored in device. + */ + addComment(content: string, contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a note to be synchronized later. + const storeOffline = (): Promise => { + return this.commentsOffline.saveComment(content, contextLevel, instanceId, component, itemId, area, siteId).then(() => { + return Promise.resolve(false); + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the note. + return storeOffline(); + } + + // Send note to server. + return this.addCommentOnline(content, contextLevel, instanceId, component, itemId, area, siteId).then((comments) => { + return comments; + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + return Promise.reject(error); + } + + // Error sending note, store it to retry later. + return storeOffline(); + }); + } + + /** + * Add a comment. It will fail if offline or cannot connect. + * + * @param {string} content Comment text. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when added, rejected otherwise. + */ + addCommentOnline(content: string, contextLevel: string, instanceId: number, component: string, itemId: number, + area: string = '', siteId?: string): Promise { + const comments = [ + { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + content: content + } + ]; + + return this.addCommentsOnline(comments, siteId).then((commentsResponse) => { + // A cooment was added, invalidate them. + return this.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return commentsResponse; + }); + }); + } + + /** + * Add several comments. It will fail if offline or cannot connect. + * + * @param {any[]} comments Comments to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when added, rejected otherwise. Promise resolved doesn't mean that comments + * have been added, the resolve param can contain errors for notes not sent. + */ + addCommentsOnline(comments: any[], siteId?: string): Promise { + if (!comments || !comments.length) { + return Promise.resolve(); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + comments: comments + }; + + return site.write('core_comment_add_comments', data); + }); + } /** * Check if Calendar is disabled in a certain site. diff --git a/src/core/comments/providers/offline.ts b/src/core/comments/providers/offline.ts new file mode 100644 index 000000000..82dbd6894 --- /dev/null +++ b/src/core/comments/providers/offline.ts @@ -0,0 +1,187 @@ +// (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 { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; + +/** + * Service to handle offline comments. + */ +@Injectable() +export class CoreCommentsOfflineProvider { + + // Variables for database. + static COMMENTS_TABLE = 'core_comments_offline_comments'; + protected siteSchema: CoreSiteSchema = { + name: 'CoreCommentsOfflineProvider', + version: 1, + tables: [ + { + name: CoreCommentsOfflineProvider.COMMENTS_TABLE, + columns: [ + { + name: 'contextlevel', + type: 'TEXT' + }, + { + name: 'instanceid', + type: 'INTEGER' + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'itemid', + type: 'INTEGER' + }, + { + name: 'area', + type: 'TEXT' + }, + { + name: 'content', + type: 'TEXT' + }, + { + name: 'action', + type: 'TEXT' + }, + { + name: 'lastmodified', + type: 'INTEGER' + } + ], + primaryKeys: ['contextlevel', 'instanceid', 'component', 'itemid', 'area'] + } + ] + }; + + constructor( private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider) { + this.sitesProvider.registerSiteSchema(this.siteSchema); + } + + /** + * Delete a comment. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + removeComment(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(CoreCommentsOfflineProvider.COMMENTS_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area + }); + }); + } + + /** + * Get all offline comments. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with comments. + */ + getAllComments(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(CoreCommentsOfflineProvider.COMMENTS_TABLE); + }); + } + + /** + * Get an offline comment. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the comments. + */ + getComment(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(CoreCommentsOfflineProvider.COMMENTS_TABLE, { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area + }); + }).catch(() => { + return false; + }); + } + + /** + * Check if there are offline comments. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @return {Promise} Promise resolved with boolean: true if has offline comments, false otherwise. + */ + hasComments(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + return this.getComment(contextLevel, instanceId, component, itemId, area, siteId).then((comments) => { + return !!comments.length; + }); + } + + /** + * Save a comment to be sent later. + * + * @param {string} content Comment text. + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + saveComment(content: string, contextLevel: string, instanceId: number, component: string, itemId: number, + area: string = '', siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const now = this.timeUtils.timestamp(); + const data = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + itemid: itemId, + area: area, + content: content, + action: 'add', + lastmodified: now + }; + + return site.getDb().insertRecord(CoreCommentsOfflineProvider.COMMENTS_TABLE, data).then(() => { + return data; + }); + }); + } +} diff --git a/src/core/comments/providers/sync-cron-handler.ts b/src/core/comments/providers/sync-cron-handler.ts new file mode 100644 index 000000000..5803f936e --- /dev/null +++ b/src/core/comments/providers/sync-cron-handler.ts @@ -0,0 +1,48 @@ +// (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 { CoreCommentsSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class CoreCommentsSyncCronHandler implements CoreCronHandler { + name = 'CoreCommentsSyncCronHandler'; + + constructor(private commentsSync: CoreCommentsSyncProvider) {} + + /** + * 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. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return this.commentsSync.syncAllComments(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } +} diff --git a/src/core/comments/providers/sync.ts b/src/core/comments/providers/sync.ts new file mode 100644 index 000000000..ab43a6d4b --- /dev/null +++ b/src/core/comments/providers/sync.ts @@ -0,0 +1,221 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreAppProvider } from '@providers/app'; +import { CoreCommentsOfflineProvider } from './offline'; +import { CoreCommentsProvider } from './comments'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncProvider } from '@providers/sync'; + +/** + * Service to sync omments. + */ +@Injectable() +export class CoreCommentsSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'core_comments_autom_synced'; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + private commentsOffline: CoreCommentsOfflineProvider, private utils: CoreUtilsProvider, + private eventsProvider: CoreEventsProvider, private commentsProvider: CoreCommentsProvider, + private coursesProvider: CoreCoursesProvider, timeUtils: CoreTimeUtilsProvider) { + + super('CoreCommentsSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); + } + + /** + * Try to synchronize all the comments in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllComments(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all comments', this.syncAllCommentsFunc.bind(this), [force], siteId); + } + + /** + * Synchronize all the comments in a certain site + * + * @param {string} siteId Site ID to sync. + * @param {boolean} force Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + private syncAllCommentsFunc(siteId: string, force: boolean): Promise { + return this.commentsOffline.getAllComments(siteId).then((comments) => { + // Sync all courses. + const promises = comments.map((comment) => { + const promise = force ? this.syncComment(comment.contextlevel, comment.instanceid, comment.component, + comment.itemid, comment.area, siteId) : this.syncCommentIfNeeded(comment.contextlevel, comment.instanceid, + comment.component, comment.itemid, comment.area, siteId); + + return promise.then((warnings) => { + if (typeof warnings != 'undefined') { + // Sync successful, send event. + this.eventsProvider.trigger(CoreCommentsSyncProvider.AUTO_SYNCED, { + contextLevel: comment.contextlevel, + instanceId: comment.instanceid, + componentName: comment.component, + itemId: comment.itemid, + area: comment.area, + warnings: warnings + }, siteId); + } + }); + }); + + return Promise.all(promises); + }); + } + + /** + * Sync course notes only if a certain time has passed since the last time. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the notes are synced or if they don't need to be synced. + */ + private syncCommentIfNeeded(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area); + + return this.isSyncNeeded(syncId, siteId).then((needed) => { + if (needed) { + return this.syncComment(contextLevel, instanceId, component, itemId, area, siteId); + } + }); + } + + /** + * Synchronize notes of a course. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncComment(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = '', + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getSyncId(contextLevel, instanceId, component, itemId, area); + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for notes, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + this.logger.debug('Try to sync comments ' + syncId); + + const warnings = []; + + // Get offline comments to be sent. + const syncPromise = this.commentsOffline.getComment(contextLevel, instanceId, component, itemId, area, siteId) + .then((comment) => { + if (!comment) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + const errors = []; + let commentsResponse = []; + let promise; + + if (comment.action == 'add') { + promise = this.commentsProvider.addCommentOnline(comment.content, contextLevel, instanceId, component, itemId, area, + siteId); + } + + // Send the comments. + return promise.then((response) => { + commentsResponse = response; + + // Fetch the comments from server to be sure they're up to date. + return this.commentsProvider.invalidateCommentsData(contextLevel, instanceId, component, itemId, area, siteId) + .then(() => { + return this.commentsProvider.getComments(contextLevel, instanceId, component, itemId, area, 0, siteId); + }).catch(() => { + // Ignore errors. + }); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // It's a WebService error, this means the user cannot send comments. + errors.push(error); + } else { + // Not a WebService error, reject the synchronization to try again. + return Promise.reject(error); + } + }).then(() => { + // Notes were sent, delete them from local DB. + const promises = commentsResponse.map((comment) => { + return this.commentsOffline.removeComment(contextLevel, instanceId, component, itemId, area, siteId); + }); + + return Promise.all(promises); + }).then(() => { + if (errors && errors.length) { + errors.forEach((error) => { + warnings.push(this.translate.instant('addon.notes.warningnotenotsent', { + contextLevel: contextLevel, + instanceId: instanceId, + componentName: component, + itemId: itemId, + area: area, + error: error + })); + }); + } + }); + }).then(() => { + // All done, return the warnings. + return warnings; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Get the ID of a comments sync. + * + * @param {string} contextLevel Contextlevel system, course, user... + * @param {number} instanceId The Instance id of item associated with the context level. + * @param {string} component Component name. + * @param {number} itemId Associated id. + * @param {string} [area=''] String comment area. Default empty. + * @return {string} Sync ID. + */ + protected getSyncId(contextLevel: string, instanceId: number, component: string, itemId: number, area: string = ''): string { + return contextLevel + '#' + instanceId + '#' + component + '#' + itemId + '#' + area; + } +}