From 9867a4846118681ce50b6399d2db4918c524fd07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 10 Mar 2021 16:23:17 +0100 Subject: [PATCH] MOBILE-3632 notes: Add notes funcionality --- src/addons/addons.module.ts | 2 + .../mod/forum/components/index/index.html | 6 +- .../post-options-menu/post-options-menu.html | 2 +- .../mod/forum/components/post/post.html | 2 +- .../notes/components/add/add-modal.html | 34 ++ src/addons/notes/components/add/add-modal.ts | 78 +++ .../notes/components/components.module.ts | 30 ++ src/addons/notes/lang.json | 15 + src/addons/notes/notes-lazy.module.ts | 41 ++ src/addons/notes/notes.module.ts | 70 +++ src/addons/notes/pages/list/list.html | 100 ++++ src/addons/notes/pages/list/list.page.ts | 295 ++++++++++ src/addons/notes/services/database/notes.ts | 95 ++++ .../notes/services/handlers/course-option.ts | 81 +++ .../notes/services/handlers/sync-cron.ts | 43 ++ src/addons/notes/services/handlers/user.ts | 73 +++ src/addons/notes/services/notes-offline.ts | 259 +++++++++ src/addons/notes/services/notes-sync.ts | 270 ++++++++++ src/addons/notes/services/notes.ts | 506 ++++++++++++++++++ src/core/classes/errors/wserror.ts | 14 +- src/core/features/compile/services/compile.ts | 8 +- src/core/features/course/services/course.ts | 4 +- .../features/login/services/login-helper.ts | 2 +- .../pages/participants/participants.page.ts | 5 +- 24 files changed, 2020 insertions(+), 15 deletions(-) create mode 100644 src/addons/notes/components/add/add-modal.html create mode 100644 src/addons/notes/components/add/add-modal.ts create mode 100644 src/addons/notes/components/components.module.ts create mode 100644 src/addons/notes/lang.json create mode 100644 src/addons/notes/notes-lazy.module.ts create mode 100644 src/addons/notes/notes.module.ts create mode 100644 src/addons/notes/pages/list/list.html create mode 100644 src/addons/notes/pages/list/list.page.ts create mode 100644 src/addons/notes/services/database/notes.ts create mode 100644 src/addons/notes/services/handlers/course-option.ts create mode 100644 src/addons/notes/services/handlers/sync-cron.ts create mode 100644 src/addons/notes/services/handlers/user.ts create mode 100644 src/addons/notes/services/notes-offline.ts create mode 100644 src/addons/notes/services/notes-sync.ts create mode 100644 src/addons/notes/services/notes.ts diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 85b2f0917..d5b5264c0 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -29,6 +29,7 @@ import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module'; import { AddonQtypeModule } from './qtype/qtype.module'; import { AddonBlogModule } from './blog/blog.module'; import { AddonRemoteThemesModule } from './remotethemes/remotethemes.module'; +import { AddonNotesModule } from './notes/notes.module'; @NgModule({ imports: [ @@ -44,6 +45,7 @@ import { AddonRemoteThemesModule } from './remotethemes/remotethemes.module'; AddonNotificationsModule, AddonMessageOutputModule, AddonModModule, + AddonNotesModule, AddonQbehaviourModule, AddonQtypeModule, AddonRemoteThemesModule, diff --git a/src/addons/mod/forum/components/index/index.html b/src/addons/mod/forum/components/index/index.html index 5a76edcc0..599c5e192 100644 --- a/src/addons/mod/forum/components/index/index.html +++ b/src/addons/mod/forum/components/index/index.html @@ -110,7 +110,7 @@ {{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}}

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

@@ -119,7 +119,7 @@ class="ion-text-center addon-mod-forum-discussion-more-info"> - {{ 'addon.mod_forum.lastpost' | translate }} + {{ 'addon.mod_forum.lastpost' | translate }} {{ discussion.timemodified | coreTimeAgo }} @@ -151,7 +151,7 @@ - + diff --git a/src/addons/mod/forum/components/post-options-menu/post-options-menu.html b/src/addons/mod/forum/components/post-options-menu/post-options-menu.html index cbf595c92..07fe05d72 100644 --- a/src/addons/mod/forum/components/post-options-menu/post-options-menu.html +++ b/src/addons/mod/forum/components/post-options-menu/post-options-menu.html @@ -6,7 +6,7 @@ - +

{{ 'addon.mod_forum.delete' | translate }}

{{ 'core.discard' | translate }}

diff --git a/src/addons/mod/forum/components/post/post.html b/src/addons/mod/forum/components/post/post.html index b5a6a8b76..fe92206fd 100644 --- a/src/addons/mod/forum/components/post/post.html +++ b/src/addons/mod/forum/components/post/post.html @@ -37,7 +37,7 @@

{{post.timecreated * 1000 | coreFormatDate: "strftimerecentfull"}}

-

{{ 'core.notsent' | translate }}

+

{{ 'core.notsent' | translate }}

+ + {{ 'addon.notes.addnewnote' | translate }} + + + + + + + + +
+ + {{ 'addon.notes.publishstate' | translate }} + + {{ 'addon.notes.personalnotes' | translate }} + {{ 'addon.notes.coursenotes' | translate }} + {{ 'addon.notes.sitenotes' | translate }} + + + + + + + + +
+ + {{ 'addon.notes.addnewnote' | translate }} + +
+
+
diff --git a/src/addons/notes/components/add/add-modal.ts b/src/addons/notes/components/add/add-modal.ts new file mode 100644 index 000000000..6ab234f07 --- /dev/null +++ b/src/addons/notes/components/add/add-modal.ts @@ -0,0 +1,78 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { AddonNotes } from '@addons/notes/services/notes'; +import { Component, ViewChild, ElementRef, Input } from '@angular/core'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController } from '@singletons'; + +/** + * Component that displays a text area for composing a note. + */ +@Component({ + templateUrl: 'add-modal.html', +}) +export class AddonNotesAddComponent { + + @ViewChild('itemEdit') formElement?: ElementRef; + + @Input() protected courseId!: number; + @Input() protected userId?: number; + @Input() type = 'personal'; + text = ''; + processing = false; + + /** + * Send the note or store it offline. + * + * @param e Event. + */ + async addNote(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + CoreApp.closeKeyboard(); + const loadingModal = await CoreDomUtils.showModalLoading('core.sending', true); + + // Freeze the add note button. + this.processing = true; + try { + this.userId = this.userId || CoreSites.getCurrentSiteUserId(); + const sent = await AddonNotes.addNote(this.userId, this.courseId, this.type, this.text); + + CoreDomUtils.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.getCurrentSiteId()); + + ModalController.dismiss({ type: this.type, sent: true }).finally(() => { + CoreDomUtils.showToast(sent ? 'addon.notes.eventnotecreated' : 'core.datastoredoffline', true, 3000); + }); + } catch (error){ + CoreDomUtils.showErrorModal(error); + this.processing = false; + } finally { + loadingModal.dismiss(); + } + } + + /** + * Close modal. + */ + closeModal(): void { + CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + + ModalController.dismiss({ type: this.type }); + } + +} diff --git a/src/addons/notes/components/components.module.ts b/src/addons/notes/components/components.module.ts new file mode 100644 index 000000000..089653d03 --- /dev/null +++ b/src/addons/notes/components/components.module.ts @@ -0,0 +1,30 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { AddonNotesAddComponent } from './add/add-modal'; + +@NgModule({ + declarations: [ + AddonNotesAddComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + AddonNotesAddComponent, + ], +}) +export class AddonNotesComponentsModule {} diff --git a/src/addons/notes/lang.json b/src/addons/notes/lang.json new file mode 100644 index 000000000..c8256d0c4 --- /dev/null +++ b/src/addons/notes/lang.json @@ -0,0 +1,15 @@ +{ + "addnewnote": "Add a new note", + "coursenotes": "Course notes", + "deleteconfirm": "Delete this note?", + "eventnotecreated": "Note created", + "eventnotedeleted": "Note deleted", + "nonotes": "There are no notes of this type yet", + "note": "Note", + "notes": "Notes", + "personalnotes": "Personal notes", + "publishstate": "Context", + "sitenotes": "Site notes", + "userwithid": "User with ID {{id}}", + "warningnotenotsent": "Couldn't add note(s) to course {{course}}. {{error}}" +} \ No newline at end of file diff --git a/src/addons/notes/notes-lazy.module.ts b/src/addons/notes/notes-lazy.module.ts new file mode 100644 index 000000000..cac3ef486 --- /dev/null +++ b/src/addons/notes/notes-lazy.module.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { CoreCommentsComponentsModule } from '@features/comments/components/components.module'; +import { CoreTagComponentsModule } from '@features/tag/components/components.module'; +import { AddonNotesListPage } from './pages/list/list.page'; + +const routes: Routes = [ + { + path: '', + component: AddonNotesListPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + CoreCommentsComponentsModule, + CoreTagComponentsModule, + ], + exports: [RouterModule], + declarations: [ + AddonNotesListPage, + ], +}) +export class AddonNotesLazyModule {} diff --git a/src/addons/notes/notes.module.ts b/src/addons/notes/notes.module.ts new file mode 100644 index 000000000..e7c9bcef5 --- /dev/null +++ b/src/addons/notes/notes.module.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { AddonNotesProvider } from './services/notes'; +import { AddonNotesOfflineProvider } from './services/notes-offline'; +import { AddonNotesSyncProvider } from './services/notes-sync'; +import { CoreCronDelegate } from '@services/cron'; +import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { AddonNotesCourseOptionHandler } from './services/handlers/course-option'; +import { AddonNotesSyncCronHandler } from './services/handlers/sync-cron'; +import { AddonNotesUserHandler } from './services/handlers/user'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { NOTES_OFFLINE_SITE_SCHEMA } from './services/database/notes'; +import { AddonNotesComponentsModule } from './components/components.module'; +import { Routes } from '@angular/router'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreCourseIndexRoutingModule } from '@features/course/pages/index/index-routing.module'; + +// List of providers (without handlers). +export const ADDON_NOTES_SERVICES: Type[] = [ + AddonNotesProvider, + AddonNotesOfflineProvider, + AddonNotesSyncProvider, +]; + +const routes: Routes = [ + { + path: 'notes', + loadChildren: () => import('@addons/notes/notes-lazy.module').then(m => m.AddonNotesLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + CoreCourseIndexRoutingModule.forChild({ children: routes }), + AddonNotesComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [NOTES_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => async () => { + CoreUserDelegate.registerHandler(AddonNotesUserHandler.instance); + CoreCourseOptionsDelegate.registerHandler(AddonNotesCourseOptionHandler.instance); + CoreCronDelegate.register(AddonNotesSyncCronHandler.instance); + }, + }, + ], +}) +export class AddonNotesModule {} diff --git a/src/addons/notes/pages/list/list.html b/src/addons/notes/pages/list/list.html new file mode 100644 index 000000000..a007ca146 --- /dev/null +++ b/src/addons/notes/pages/list/list.html @@ -0,0 +1,100 @@ + + + + + + + {{ 'addon.notes.notes' | translate }} + + + + + + + + + + + + + + + + + + + + + + +

{{user!.fullname}}

+
+ +
+ + {{ 'addon.notes.sitenotes' | translate }} + {{ 'addon.notes.coursenotes' | translate }} + {{ 'addon.notes.personalnotes' | translate }} + +
+ + + + + + {{ 'core.thereisdatatosync' | translate:{$a: 'addon.notes.notes' | translate | lowercase } }} + + + + + + + + + + + + +

{{note.userfullname}}

+

+ {{note.lastmodified | coreDateDayOrTime}} +

+
+

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

+

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

+ + + + + + +
+ + + +
+
+
+ + + + + + +
diff --git a/src/addons/notes/pages/list/list.page.ts b/src/addons/notes/pages/list/list.page.ts new file mode 100644 index 000000000..bd3528a1a --- /dev/null +++ b/src/addons/notes/pages/list/list.page.ts @@ -0,0 +1,295 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreConstants } from '@/core/constants'; +import { AddonNotesAddComponent } from '@addons/notes/components/add/add-modal'; +import { AddonNotes, AddonNotesNoteFormatted } from '@addons/notes/services/notes'; +import { AddonNotesOffline } from '@addons/notes/services/notes-offline'; +import { AddonNotesSync, AddonNotesSyncProvider } from '@addons/notes/services/notes-sync'; +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { CoreAnimations } from '@components/animations'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; + +/** + * Page that displays a list of notes. + */ +@Component({ + selector: 'page-addon-notes-list-page', + templateUrl: 'list.html', + animations: [CoreAnimations.SLIDE_IN_OUT], +}) +export class AddonNotesListPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + + courseId: number; + userId?: number; + type = 'course'; + refreshIcon = CoreConstants.ICON_LOADING; + syncIcon = CoreConstants.ICON_LOADING; + notes: AddonNotesNoteFormatted[] = []; + hasOffline = false; + notesLoaded = false; + user?: CoreUserProfile; + showDelete = false; + canDeleteNotes = false; + currentUserId: number; + + protected syncObserver: CoreEventObserver; + + constructor() { + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.userId = CoreNavigator.getRouteNumberParam('userId'); + + // Refresh data if notes are synchronized automatically. + this.syncObserver = CoreEvents.on(AddonNotesSyncProvider.AUTO_SYNCED, (data) => { + if (data.courseId == this.courseId) { + // Show the sync warnings. + this.showSyncWarnings(data.warnings); + + // Refresh the data. + this.notesLoaded = false; + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; + + this.content?.scrollToTop(); + this.fetchNotes(false); + } + }, CoreSites.getCurrentSiteId()); + + this.currentUserId = CoreSites.getCurrentSiteUserId(); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + await this.fetchNotes(true); + + CoreUtils.ignoreErrors(AddonNotes.logView(this.courseId, this.userId)); + } + + /** + * Fetch notes. + * + * @param sync When to resync notes. + * @param showErrors When to display errors or not. + * @return Promise with the notes. + */ + protected async fetchNotes(sync = false, showErrors = false): Promise { + if (sync) { + await this.syncNotes(showErrors); + } + + try { + const allNotes = await AddonNotes.getNotes(this.courseId, this.userId); + + const notesList: AddonNotesNoteFormatted[] = allNotes[this.type + 'notes'] || []; + + notesList.forEach((note) => { + note.content = CoreTextUtils.decodeHTML(note.content); + }); + + await AddonNotes.setOfflineDeletedNotes(notesList, this.courseId); + + this.hasOffline = notesList.some((note) => note.offline || note.deleted); + + if (this.userId) { + this.notes = notesList; + + // Get the user profile to retrieve the user image. + this.user = await CoreUser.getProfile(this.userId, this.courseId, true); + } else { + this.notes = await AddonNotes.getNotesUserData(notesList); + } + } catch (error) { + CoreDomUtils.showErrorModal(error); + } finally { + let canDelete = this.notes && this.notes.length > 0; + if (canDelete && this.type == 'personal') { + canDelete = !!this.notes.find((note) => note.usermodified == this.currentUserId); + } + this.canDeleteNotes = canDelete; + + this.notesLoaded = true; + this.refreshIcon = CoreConstants.ICON_REFRESH; + this.syncIcon = CoreConstants.ICON_SYNC; + } + } + + /** + * Refresh notes on PTR. + * + * @param showErrors Whether to display errors or not. + * @param refresher Refresher instance. + */ + refreshNotes(showErrors: boolean, refresher?: IonRefresher): void { + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; + + AddonNotes.invalidateNotes(this.courseId, this.userId).finally(() => { + this.fetchNotes(true, showErrors).finally(() => { + if (refresher) { + refresher?.complete(); + } + }); + }); + } + + /** + * Function called when the type has changed. + */ + async typeChanged(): Promise { + this.notesLoaded = false; + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; + + await this.fetchNotes(true); + CoreUtils.ignoreErrors(AddonNotes.logView(this.courseId, this.userId)); + } + + /** + * Add a new Note to user and course. + * + * @param e Event. + */ + async addNote(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + const modal = await ModalController.create({ + component: AddonNotesAddComponent, + componentProps: { + userId: this.userId, + courseId: this.courseId, + type: this.type, + }, + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + + if (typeof result.data != 'undefined') { + + if (result.data.sent && result.data.type) { + if (result.data.type != this.type) { + this.type = result.data.type; + this.notesLoaded = false; + } + + this.refreshNotes(false); + } else if (result.data.type && result.data.type != this.type) { + this.type = result.data.type; + this.typeChanged(); + } + } + } + + /** + * Delete a note. + * + * @param e Click event. + * @param note Note to delete. + */ + async deleteNote(e: Event, note: AddonNotesNoteFormatted): Promise { + e.preventDefault(); + e.stopPropagation(); + + try { + await CoreDomUtils.showDeleteConfirm('addon.notes.deleteconfirm'); + try { + await AddonNotes.deleteNote(note, this.courseId); + this.showDelete = false; + + this.refreshNotes(false); + + CoreDomUtils.showToast('addon.notes.eventnotedeleted', true, 3000); + + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Delete note failed.'); + } + } catch { + // User cancelled, nothing to do. + } + } + + /** + * Restore a note. + * + * @param e Click event. + * @param note Note to delete. + */ + async undoDeleteNote(e: Event, note: AddonNotesNoteFormatted): Promise { + e.preventDefault(); + e.stopPropagation(); + + await AddonNotesOffline.undoDeleteNote(note.id); + this.refreshNotes(true); + } + + /** + * Toggle delete. + */ + toggleDelete(): void { + this.showDelete = !this.showDelete; + } + + /** + * Tries to synchronize course notes. + * + * @param showErrors Whether to display errors or not. + * @return Promise resolved when done. + */ + protected async syncNotes(showErrors: boolean): Promise { + try { + const result = await AddonNotesSync.syncNotes(this.courseId); + + this.showSyncWarnings(result.warnings); + } catch (error) { + if (showErrors) { + CoreDomUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + } + } + + /** + * Show sync warnings if any. + * + * @param warnings the warnings + */ + protected showSyncWarnings(warnings: string[]): void { + const message = CoreTextUtils.buildMessage(warnings); + + if (message) { + CoreDomUtils.showErrorModal(message); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.syncObserver && this.syncObserver.off(); + } + +} diff --git a/src/addons/notes/services/database/notes.ts b/src/addons/notes/services/database/notes.ts new file mode 100644 index 000000000..ed0a69ae9 --- /dev/null +++ b/src/addons/notes/services/database/notes.ts @@ -0,0 +1,95 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonNotesOfflineProvider. + */ +export const NOTES_TABLE = 'addon_notes_offline_notes'; +export const NOTES_DELETED_TABLE = 'addon_notes_deleted_offline_notes'; +export const NOTES_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonNotesOfflineProvider', + version: 2, + tables: [ + { + name: NOTES_TABLE, + columns: [ + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'publishstate', + type: 'TEXT', + }, + { + name: 'content', + type: 'TEXT', + }, + { + name: 'format', + type: 'INTEGER', + }, + { + name: 'created', + type: 'INTEGER', + }, + { + name: 'lastmodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['userid', 'content', 'created'], + }, + { + name: NOTES_DELETED_TABLE, + columns: [ + { + name: 'noteid', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'deleted', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + ], + }, + ], +}; + +export type AddonNotesDBRecord = { + userid: number; // Primary key. + content: string; // Primary key. + created: number; // Primary key. + courseid: number; + publishstate: string; + format: number; + lastmodified: number; +}; + +export type AddonNotesDeletedDBRecord = { + noteid: number; // Primary key. + deleted: number; + courseid: number; +}; diff --git a/src/addons/notes/services/handlers/course-option.ts b/src/addons/notes/services/handlers/course-option.ts new file mode 100644 index 000000000..80ae7da99 --- /dev/null +++ b/src/addons/notes/services/handlers/course-option.ts @@ -0,0 +1,81 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreCourseProvider } from '@features/course/services/course'; +import { + CoreCourseAccess, + CoreCourseOptionsHandler, + CoreCourseOptionsHandlerData, +} from '@features/course/services/course-options-delegate'; +import { CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper'; +import { makeSingleton } from '@singletons'; +import { AddonNotes } from '../notes'; + +/** + * Handler to inject an option into the course main menu. + */ +@Injectable( { providedIn: 'root' } ) +export class AddonNotesCourseOptionHandlerService implements CoreCourseOptionsHandler { + + name = 'AddonNotes'; + priority = 200; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return AddonNotes.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + async isEnabledForCourse( + courseId: number, + accessData: CoreCourseAccess, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + if (accessData && accessData.type == CoreCourseProvider.ACCESS_GUEST) { + return false; // Not enabled for guests. + } + + if (navOptions && typeof navOptions.notes != 'undefined') { + return navOptions.notes; + } + + return AddonNotes.isPluginViewNotesEnabledForCourse(courseId); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreCourseOptionsHandlerData { + return { + title: 'addon.notes.notes', + class: 'addon-notes-course-handler', + page: 'notes', + }; + } + + /** + * @inheritdoc + */ + async prefetch(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise { + await AddonNotes.getNotes(course.id, undefined, true); + } + +} +export const AddonNotesCourseOptionHandler = makeSingleton(AddonNotesCourseOptionHandlerService); diff --git a/src/addons/notes/services/handlers/sync-cron.ts b/src/addons/notes/services/handlers/sync-cron.ts new file mode 100644 index 000000000..1e5b2d96a --- /dev/null +++ b/src/addons/notes/services/handlers/sync-cron.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonNotesSync } from '../notes-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable( { providedIn: 'root' } ) +export class AddonNotesSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonNotesSyncCronHandler'; + + /** + * @inheritdoc + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonNotesSync.syncAllNotes(siteId, force); + } + + /** + * @inheritdoc + */ + getInterval(): number { + return 300000; // 5 minutes. + } + +} +export const AddonNotesSyncCronHandler = makeSingleton(AddonNotesSyncCronHandlerService); diff --git a/src/addons/notes/services/handlers/user.ts b/src/addons/notes/services/handlers/user.ts new file mode 100644 index 000000000..f0dc13001 --- /dev/null +++ b/src/addons/notes/services/handlers/user.ts @@ -0,0 +1,73 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreUserProfile } from '@features/user/services/user'; +import { CoreUserProfileHandler, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { AddonNotes } from '../notes'; + +/** + * Profile notes handler. + */ +@Injectable( { providedIn: 'root' } ) +export class AddonNotesUserHandlerService implements CoreUserProfileHandler { + + name = 'AddonNotes:notes'; + priority = 100; + type = CoreUserDelegateService.TYPE_NEW_PAGE; + cacheEnabled = true; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return AddonNotes.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + async isEnabledForUser(user: CoreUserProfile, courseId?: number): Promise { + // Active course required. + if (!courseId || user.id == CoreSites.getCurrentSiteUserId()) { + return false; + } + + // We are not using isEnabledForCourse because we need to cache the call. + return AddonNotes.isPluginViewNotesEnabledForCourse(courseId); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + icon: 'fas-receipt', + title: 'addon.notes.notes', + class: 'addon-notes-handler', + action: (event, user, courseId): void => { + event.preventDefault(); + event.stopPropagation(); + CoreNavigator.navigateToSitePath('/notes', { + params: { courseId, userId: user.id }, + }); + }, + }; + } + +} +export const AddonNotesUserHandler = makeSingleton(AddonNotesUserHandlerService); diff --git a/src/addons/notes/services/notes-offline.ts b/src/addons/notes/services/notes-offline.ts new file mode 100644 index 000000000..9968e07f7 --- /dev/null +++ b/src/addons/notes/services/notes-offline.ts @@ -0,0 +1,259 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton } from '@singletons'; +import { AddonNotesDBRecord, AddonNotesDeletedDBRecord, NOTES_DELETED_TABLE, NOTES_TABLE } from './database/notes'; + +/** + * Service to handle offline notes. + */ +@Injectable( { providedIn: 'root' } ) +export class AddonNotesOfflineProvider { + + /** + * Delete an offline note. + * + * @param userId User ID the note is about. + * @param content The note content. + * @param timecreated The time the note was created. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteOfflineNote(userId: number, content: string, timecreated: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.getDb().deleteRecords(NOTES_TABLE, { + userid: userId, + content: content, + created: timecreated, + }); + } + + /** + * Get all offline deleted notes. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with notes. + */ + async getAllDeletedNotes(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecords(NOTES_DELETED_TABLE); + } + + /** + * Get course offline deleted notes. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with notes. + */ + async getCourseDeletedNotes(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecords(NOTES_DELETED_TABLE, { courseid: courseId }); + } + + /** + * Get all offline notes. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with notes. + */ + async getAllNotes(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecords(NOTES_TABLE); + } + + /** + * Get an offline note. + * + * @param userId User ID the note is about. + * @param content The note content. + * @param timecreated The time the note was created. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the notes. + */ + async getNote(userId: number, content: string, timecreated: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecord(NOTES_TABLE, { + userid: userId, + content: content, + created: timecreated, + }); + } + + /** + * Get offline notes for a certain course and user. + * + * @param courseId Course ID. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with notes. + */ + async getNotesForCourseAndUser(courseId: number, userId?: number, siteId?: string): Promise { + if (!userId) { + return this.getNotesForCourse(courseId, siteId); + } + + const site = await CoreSites.getSite(siteId); + + return await site.getDb().getRecords(NOTES_TABLE, { userid: userId, courseid: courseId }); + } + + /** + * Get offline notes for a certain course. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with notes. + */ + async getNotesForCourse(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getRecords(NOTES_TABLE, { courseid: courseId }); + } + + /** + * Get offline notes for a certain user. + * + * @param userId User ID the notes are about. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with notes. + */ + async getNotesForUser(userId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return await site.getDb().getRecords(NOTES_TABLE, { userid: userId }); + } + + /** + * Get offline notes with a certain publish state (Personal, Site or Course). + * + * @param state Publish state ('personal', 'site' or 'course'). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with notes. + */ + async getNotesWithPublishState(state: string, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return await site.getDb().getRecords(NOTES_TABLE, { publishstate: state }); + } + + /** + * Check if there are offline notes for a certain course. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline notes, false otherwise. + */ + async hasNotesForCourse(courseId: number, siteId?: string): Promise { + const notes = await this.getNotesForCourse(courseId, siteId); + + return !!notes.length; + } + + /** + * Check if there are offline notes for a certain user. + * + * @param userId User ID the notes are about. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline notes, false otherwise. + */ + async hasNotesForUser(userId: number, siteId?: string): Promise { + const notes = await this.getNotesForUser(userId, siteId); + + return !!notes.length; + } + + /** + * Check if there are offline notes with a certain publish state (Personal, Site or Course). + * + * @param state Publish state ('personal', 'site' or 'course'). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline notes, false otherwise. + */ + async hasNotesWithPublishState(state: string, siteId?: string): Promise { + const notes = await this.getNotesWithPublishState(state, siteId); + + return !!notes.length; + } + + /** + * Save a note to be sent later. + * + * @param userId User ID the note is about. + * @param courseId Course ID. + * @param state Publish state ('personal', 'site' or 'course'). + * @param content The note content. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveNote(userId: number, courseId: number, state: string, content: string, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const now = CoreTimeUtils.timestamp(); + const data: AddonNotesDBRecord = { + userid: userId, + courseid: courseId, + publishstate: state, + content: content, + format: 1, + created: now, + lastmodified: now, + }; + + await site.getDb().insertRecord(NOTES_TABLE, data); + } + + /** + * Delete a note offline to be sent later. + * + * @param noteId Note ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteNote(noteId: number, courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const data: AddonNotesDeletedDBRecord = { + noteid: noteId, + courseid: courseId, + deleted: CoreTimeUtils.timestamp(), + }; + + await site.getDb().insertRecord(NOTES_DELETED_TABLE, data); + } + + /** + * Undo delete a note. + * + * @param noteId Note ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async undoDeleteNote(noteId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.getDb().deleteRecords(NOTES_DELETED_TABLE, { noteid: noteId }); + } + +} +export const AddonNotesOffline = makeSingleton(AddonNotesOfflineProvider); diff --git a/src/addons/notes/services/notes-sync.ts b/src/addons/notes/services/notes-sync.ts new file mode 100644 index 000000000..29dbefe92 --- /dev/null +++ b/src/addons/notes/services/notes-sync.ts @@ -0,0 +1,270 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourses } from '@features/courses/services/courses'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate, makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonNotesDBRecord, AddonNotesDeletedDBRecord } from './database/notes'; +import { AddonNotes, AddonNotesCreateNoteData } from './notes'; +import { AddonNotesOffline } from './notes-offline'; + +/** + * Service to sync notes. + */ +@Injectable( { providedIn: 'root' } ) +export class AddonNotesSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_notes_autom_synced'; + + constructor() { + super('AddonNotesSync'); + } + + /** + * Try to synchronize all the notes in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllNotes(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all notes', this.syncAllNotesFunc.bind(this, siteId, force), siteId); + } + + /** + * Synchronize all the notes in a certain site + * + * @param siteId Site ID to sync. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllNotesFunc(siteId: string, force: boolean): Promise { + const notesArray = await Promise.all([ + AddonNotesOffline.getAllNotes(siteId), + AddonNotesOffline.getAllDeletedNotes(siteId), + ]); + + // Get all the courses to be synced. + const courseIds: number[] = []; + notesArray.forEach((notes: (AddonNotesDeletedDBRecord | AddonNotesDBRecord)[]) => { + const courseIds = notes.map((note) => note.courseid); + + courseIds.concat(courseIds); + }, []); + + CoreUtils.uniqueArray(courseIds); + + // Sync all courses. + const promises = courseIds.map(async (courseId) => { + const result = await (force + ? this.syncNotes(courseId, siteId) + : this.syncNotesIfNeeded(courseId, siteId)); + + if (typeof result != 'undefined') { + // Sync successful, send event. + CoreEvents.trigger(AddonNotesSyncProvider.AUTO_SYNCED, { + courseId, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(promises); + } + + /** + * Sync course notes only if a certain time has passed since the last time. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the notes are synced or if they don't need to be synced. + */ + protected async syncNotesIfNeeded(courseId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(courseId, siteId); + + if (needed) { + return this.syncNotes(courseId, siteId); + } + } + + /** + * Synchronize notes of a course. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncNotes(courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (this.isSyncing(courseId, siteId)) { + // There's already a sync ongoing for notes, return the promise. + return this.getOngoingSync(courseId, siteId)!; + } + + this.logger.debug('Try to sync notes for course ' + courseId); + + const syncPromise = this.performSyncNotes(courseId, siteId); + + return this.addOngoingSync(courseId, syncPromise, siteId); + } + + /** + * Perform the synchronization of the notes of a course. + * + * @param courseId Course ID. + * @param siteId Site ID. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async performSyncNotes(courseId: number, siteId?: string): Promise { + const result: AddonNotesSyncResult = { + warnings: [], + }; + + // Get offline notes to be sent and deleted. + const [offlineNotes, deletedNotes] = await Promise.all([ + AddonNotesOffline.getAllNotes(siteId), + AddonNotesOffline.getAllDeletedNotes(siteId), + ]); + + if (!offlineNotes.length && !deletedNotes.length) { + // Nothing to sync. + return result; + } + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const errors: string[] = []; + const promises: Promise[] = []; + + // Format the notes to be sent. + const notesToSend: AddonNotesCreateNoteData[] = offlineNotes.map((note) => ({ + userid: note.userid, + publishstate: note.publishstate, + courseid: note.courseid, + text: note.content, + format: 1, + })); + + // Send the notes. + promises.push(AddonNotes.addNotesOnline(notesToSend, siteId).then((response) => { + // Search errors in the response. + response.forEach((entry) => { + if (entry.noteid === -1 && entry.errormessage && errors.indexOf(entry.errormessage) == -1) { + errors.push(entry.errormessage); + } + }); + + return; + }).catch((error) => { + if (CoreUtils.isWebServiceError(error)) { + // It's a WebService error, this means the user cannot send notes. + errors.push(error); + + return; + } + + // Not a WebService error, reject the synchronization to try again. + throw error; + }).then(async () => { + // Notes were sent, delete them from local DB. + const promises: Promise[] = offlineNotes.map((note) => + AddonNotesOffline.deleteOfflineNote(note.userid, note.content, note.created, siteId)); + + await Promise.all(promises); + + return; + })); + + // Format the notes to be sent. + const notesToDelete = deletedNotes.map((note) => note.noteid); + + // Delete the notes. + promises.push(AddonNotes.deleteNotesOnline(notesToDelete, courseId, siteId).catch((error) => { + if (CoreUtils.isWebServiceError(error)) { + // It's a WebService error, this means the user cannot send notes. + errors.push(error); + + return; + } + + // Not a WebService error, reject the synchronization to try again. + throw error; + }).then(async () => { + // Notes were sent, delete them from local DB. + const promises = notesToDelete.map((noteId) => AddonNotesOffline.undoDeleteNote(noteId, siteId)); + + await Promise.all(promises); + + return; + })); + + await Promise.all(promises); + + // Fetch the notes from server to be sure they're up to date. + await CoreUtils.ignoreErrors(AddonNotes.invalidateNotes(courseId, undefined, siteId)); + + await CoreUtils.ignoreErrors(AddonNotes.getNotes(courseId, undefined, false, true, siteId)); + + if (errors && errors.length) { + // At least an error occurred, get course name and add errors to warnings array. + const course = await CoreUtils.ignoreErrors(CoreCourses.getUserCourse(courseId, true, siteId), {}); + + result.warnings = errors.map((error) => + Translate.instant('addon.notes.warningnotenotsent', { + course: 'fullname' in course ? course.fullname : courseId, + error: error, + })); + } + + // All done, return the warnings. + return result; + } + +} +export const AddonNotesSync = makeSingleton(AddonNotesSyncProvider); + +export type AddonNotesSyncResult = { + warnings: string[]; // List of warnings. +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonNotesSyncAutoSyncData = { + courseId: number; + warnings: string[]; +}; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonNotesSyncProvider.AUTO_SYNCED]: AddonNotesSyncAutoSyncData; + } + +} diff --git a/src/addons/notes/services/notes.ts b/src/addons/notes/services/notes.ts new file mode 100644 index 000000000..7f5855bf9 --- /dev/null +++ b/src/addons/notes/services/notes.ts @@ -0,0 +1,506 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreWSError } from '@classes/errors/wserror'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; +import { CoreUser } from '@features/user/services/user'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonNotesOffline } from './notes-offline'; + +const ROOT_CACHE_KEY = 'mmaNotes:'; + +/** + * Service to handle notes. + */ +@Injectable( { providedIn: 'root' } ) +export class AddonNotesProvider { + + /** + * Add a note. + * + * @param userId User ID of the person to add the note. + * @param courseId Course ID where the note belongs. + * @param publishState Personal, Site or Course. + * @param noteText The note text. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if note was sent to server, false if stored in device. + */ + async addNote(userId: number, courseId: number, publishState: string, noteText: string, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a note to be synchronized later. + const storeOffline = async (): Promise => { + await AddonNotesOffline.saveNote(userId, courseId, publishState, noteText, siteId); + + return false; + }; + + if (!CoreApp.isOnline()) { + // App is offline, store the note. + return storeOffline(); + } + + // Send note to server. + try { + await this.addNoteOnline(userId, courseId, publishState, noteText, siteId); + + return true; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + throw error; + } + + return storeOffline(); + } + } + + /** + * Add a note. It will fail if offline or cannot connect. + * + * @param userId User ID of the person to add the note. + * @param courseId Course ID where the note belongs. + * @param publishState Personal, Site or Course. + * @param noteText The note text. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when added, rejected otherwise. + */ + async addNoteOnline(userId: number, courseId: number, publishState: string, noteText: string, siteId?: string): Promise { + const notes: AddonNotesCreateNoteData[] = [ + { + courseid: courseId, + format: 1, + publishstate: publishState, + text: noteText, + userid: userId, + }, + ]; + + const response = await this.addNotesOnline(notes, siteId); + if (response && response[0] && response[0].noteid === -1) { + // There was an error, and it should be translated already. + throw new CoreWSError({ message: response[0].errormessage }); + } + + await CoreUtils.ignoreErrors(this.invalidateNotes(courseId, undefined, siteId)); + } + + /** + * Add several notes. It will fail if offline or cannot connect. + * + * @param notes Notes to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when added, rejected otherwise. Promise resolved doesn't mean that notes + * have been added, the resolve param can contain errors for notes not sent. + */ + async addNotesOnline(notes: AddonNotesCreateNoteData[], siteId?: string): Promise { + if (!notes || !notes.length) { + return []; + } + + const site = await CoreSites.getSite(siteId); + + const data: AddonNotesCreateNotesWSParams = { + notes: notes, + }; + + return site.write('core_notes_create_notes', data); + } + + /** + * Delete a note. + * + * @param note Note object to delete. + * @param courseId Course ID where the note belongs. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected otherwise. Promise resolved doesn't mean that notes + * have been deleted, the resolve param can contain errors for notes not deleted. + */ + async deleteNote(note: AddonNotesNoteFormatted, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (note.offline) { + await AddonNotesOffline.deleteOfflineNote(note.userid, note.content, note.created, siteId); + + return true; + } + + // Convenience function to store the action to be synchronized later. + const storeOffline = async (): Promise => { + await AddonNotesOffline.deleteNote(note.id, courseId, siteId); + + return false; + }; + + if (!CoreApp.isOnline()) { + // App is offline, store the note. + return storeOffline(); + } + + // Send note to server. + try { + await this.deleteNotesOnline([note.id], courseId, siteId); + + return true; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the note so don't store it. + throw error; + } + + return await storeOffline(); + } + } + + /** + * Delete a note. It will fail if offline or cannot connect. + * + * @param noteIds Note IDs to delete. + * @param courseId Course ID where the note belongs. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected otherwise. Promise resolved doesn't mean that notes + * have been deleted, the resolve param can contain errors for notes not deleted. + */ + async deleteNotesOnline(noteIds: number[], courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonNotesDeleteNotesWSParams = { + notes: noteIds, + }; + + await site.write('core_notes_delete_notes', params); + + CoreUtils.ignoreErrors(this.invalidateNotes(courseId, undefined, siteId)); + } + + /** + * Returns whether or not the notes plugin is enabled for a certain site. + * + * This method is called quite often and thus should only perform a quick + * check, we should not be calling WS from here. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if enabled, resolved with false or rejected otherwise. + */ + async isPluginEnabled(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.canUseAdvancedFeature('enablenotes'); + } + + /** + * Returns whether or not the add note plugin is enabled for a certain course. + * + * @param courseId ID of the course. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if enabled, resolved with false or rejected otherwise. + */ + async isPluginAddNoteEnabledForCourse(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + // The only way to detect if it's enabled is to perform a WS call. + // We use an invalid user ID (-1) to avoid saving the note if the user has permissions. + const params: AddonNotesCreateNotesWSParams = { + notes: [ + { + userid: -1, + publishstate: 'personal', + courseid: courseId, + text: '', + format: 1, + }, + ], + }; + const preSets: CoreSiteWSPreSets = { + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + // Use .read to cache data and be able to check it in offline. This means that, if a user loses the capabilities + // to add notes, he'll still see the option in the app. + return CoreUtils.promiseWorks(site.read('core_notes_create_notes', params, preSets)); + } + + /** + * Returns whether or not the read notes plugin is enabled for a certain course. + * + * @param courseId ID of the course. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if enabled, resolved with false or rejected otherwise. + */ + isPluginViewNotesEnabledForCourse(courseId: number, siteId?: string): Promise { + return CoreUtils.promiseWorks(this.getNotes(courseId, undefined, false, true, siteId)); + } + + /** + * Get prefix cache key for course notes. + * + * @param courseId ID of the course to get the notes from. + * @return Cache key. + */ + getNotesPrefixCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'notes:' + courseId + ':'; + } + + /** + * Get the cache key for the get notes call. + * + * @param courseId ID of the course to get the notes from. + * @param userId ID of the user to get the notes from if requested. + * @return Cache key. + */ + getNotesCacheKey(courseId: number, userId?: number): string { + return this.getNotesPrefixCacheKey(courseId) + (userId ? userId : ''); + } + + /** + * Get users notes for a certain site, course and personal notes. + * + * @param courseId ID of the course to get the notes from. + * @param userId ID of the user to get the notes from if requested. + * @param ignoreCache True when we should not get the value from the cache. + * @param onlyOnline True to return only online notes, false to return both online and offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise to be resolved when the notes are retrieved. + */ + async getNotes( + courseId: number, + userId?: number, + ignoreCache = false, + onlyOnline = false, + siteId?: string, + ): Promise { + + const site = await CoreSites.getSite(siteId); + const params: AddonNotesGetCourseNotesWSParams = { + courseid: courseId, + }; + if (userId) { + params.userid = userId; + } + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getNotesCacheKey(courseId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + const notes = await site.read('core_notes_get_course_notes', params, preSets); + if (onlyOnline) { + return notes; + } + + const offlineNotes = await AddonNotesOffline.getNotesForCourseAndUser(courseId, userId, siteId); + offlineNotes.forEach((note: AddonNotesNote) => { + const fieldName = note.publishstate + 'notes'; + if (!notes[fieldName]) { + notes[fieldName] = []; + } + note.offline = true; + // Add note to the start of array since last notes are shown first. + notes[fieldName].unshift(note); + }); + + return notes; + } + + /** + * Get offline deleted notes and set the state. + * + * @param notes Array of notes. + * @param courseId ID of the course the notes belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setOfflineDeletedNotes( + notes: AddonNotesNoteFormatted[], + courseId: number, + siteId?: string, + ): Promise { + const deletedNotes = await AddonNotesOffline.getCourseDeletedNotes(courseId, siteId); + + notes.forEach((note) => { + note.deleted = deletedNotes.some((n) => n.noteid == note.id); + }); + } + + /** + * Get user data for notes since they only have userid. + * + * @param notes Notes to get the data for. + * @return Promise always resolved. Resolve param is the formatted notes. + */ + async getNotesUserData(notes: AddonNotesNoteFormatted[]): Promise { + const promises = notes.map((note) => + // Get the user profile to retrieve the user image. + CoreUser.getProfile(note.userid, note.courseid, true).then((user) => { + note.userfullname = user.fullname; + note.userprofileimageurl = user.profileimageurl; + + return; + }).catch(() => { + note.userfullname = Translate.instant('addon.notes.userwithid', { id: note.userid }); + })); + + await Promise.all(promises); + + return notes; + } + + /** + * Invalidate get notes WS call. + * + * @param courseId Course ID. + * @param userId User ID if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data is invalidated. + */ + async invalidateNotes(courseId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + if (userId) { + await site.invalidateWsCacheForKey(this.getNotesCacheKey(courseId, userId)); + + return; + } + + await site.invalidateWsCacheForKeyStartingWith(this.getNotesPrefixCacheKey(courseId)); + } + + /** + * Report notes as being viewed. + * + * @param courseId ID of the course. + * @param userId User ID if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logView(courseId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonNotesViewNotesWSParams = { + courseid: courseId, + userid: userId || 0, + }; + + CorePushNotifications.logViewListEvent('notes', 'core_notes_view_notes', params, site.getId()); + + await site.write('core_notes_view_notes', params); + } + +} +export const AddonNotes = makeSingleton(AddonNotesProvider); + +/** + * Params of core_notes_view_notes WS. + */ +type AddonNotesViewNotesWSParams = { + courseid: number; // Course id, 0 for notes at system level. + userid?: number; // User id, 0 means view all the user notes. +}; + +/** + * Params of core_notes_get_course_notes WS. + */ +export type AddonNotesGetCourseNotesWSParams = { + courseid: number; // Course id, 0 for SITE. + userid?: number; // User id. +}; + +/** + * Note data returned by core_notes_get_course_notes. + */ +export type AddonNotesNote = { + id: number; // Id of this note. + courseid: number; // Id of the course. + userid: number; // User id. + content: string; // The content text formated. + format: number; // Content format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + created: number; // Time created (timestamp). + lastmodified: number; // Time of last modification (timestamp). + usermodified: number; // User id of the creator of this note. + publishstate: string; // State of the note (i.e. draft, public, site). + offline?: boolean; +}; + +/** + * Result of WS core_notes_get_course_notes. + */ +export type AddonNotesGetCourseNotesWSResponse = { + sitenotes?: AddonNotesNote[]; // Site notes. + coursenotes?: AddonNotesNote[]; // Couse notes. + personalnotes?: AddonNotesNote[]; // Personal notes. + canmanagesystemnotes?: boolean; // @since 3.7. Whether the user can manage notes at system level. + canmanagecoursenotes?: boolean; // @since 3.7. Whether the user can manage notes at the given course. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS core_notes_view_notes. + */ +export type AddonNotesViewNotesResult = { + status: boolean; // Status: true if success. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Notes with some calculated data. + */ +export type AddonNotesNoteFormatted = AddonNotesNote & { + offline?: boolean; // Calculated in the app. Whether it's an offline note. + deleted?: boolean; // Calculated in the app. Whether the note was deleted in offline. + userfullname?: string; // Calculated in the app. Full name of the user the note refers to. + userprofileimageurl?: string; // Calculated in the app. Avatar url of the user the note refers to. +}; + +export type AddonNotesCreateNoteData = { + userid: number; // Id of the user the note is about. + publishstate: string; // 'personal', 'course' or 'site'. + courseid: number; // Course id of the note (in Moodle a note can only be created into a course, + // even for site and personal notes). + text: string; // The text of the message - text or HTML. + format?: number; // Text format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + clientnoteid?: string; // Your own client id for the note. If this id is provided, the fail message id will be returned to you. +}; + +/** + * Params of core_notes_create_notes WS. + */ +type AddonNotesCreateNotesWSParams = { + notes: AddonNotesCreateNoteData[]; +}; + +/** + * Note returned by WS core_notes_create_notes. + */ +export type AddonNotesCreateNotesWSResponse = { + clientnoteid?: string; // Your own id for the note. + noteid: number; // ID of the created note when successful, -1 when failed. + errormessage?: string; // Error message - if failed. +}[]; + +/** + * Params of core_notes_delete_notes WS. + */ +type AddonNotesDeleteNotesWSParams = { + notes: number[]; // Array of Note Ids to be deleted. +}; diff --git a/src/core/classes/errors/wserror.ts b/src/core/classes/errors/wserror.ts index a3b4c9c0f..f34ac0587 100644 --- a/src/core/classes/errors/wserror.ts +++ b/src/core/classes/errors/wserror.ts @@ -27,8 +27,7 @@ export class CoreWSError extends CoreError { debuginfo?: string; // Debug info. Only if debug mode is enabled. backtrace?: string; // Backtrace. Only if debug mode is enabled. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(error: any) { + constructor(error: CoreWSErrorData) { super(error.message); this.exception = error.exception; @@ -41,3 +40,14 @@ export class CoreWSError extends CoreError { } } + +type CoreWSErrorData = { + message?: string; + exception?: string; // Name of the Moodle exception. + errorcode?: string; + warningcode?: string; + link?: string; // Link to the site. + moreinfourl?: string; // Link to a page with more info. + debuginfo?: string; // Debug info. Only if debug mode is enabled. + backtrace?: string; // Backtrace. Only if debug mode is enabled. +}; diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 95e1d523f..2eeeff320 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -141,10 +141,10 @@ import { ADDON_MOD_RESOURCE_SERVICES } from '@addons/mod/resource/resource.modul import { ADDON_MOD_URL_SERVICES } from '@addons/mod/url/url.module'; // @todo import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module'; // @todo import { ADDON_MOD_WORKSHOP_SERVICES } from '@addons/mod/workshop/workshop.module'; -// @todo import { ADDON_NOTES_SERVICES } from '@addons/notes/notes.module'; +import { ADDON_NOTES_SERVICES } from '@addons/notes/notes.module'; import { ADDON_NOTIFICATIONS_SERVICES } from '@addons/notifications/notifications.module'; import { ADDON_PRIVATEFILES_SERVICES } from '@addons/privatefiles/privatefiles.module'; -// @todo import { ADDON_REMOTETHEMES_SERVICES } from '@addons/remotethemes/remotethemes.module'; +import { ADDON_REMOTETHEMES_SERVICES } from '@addons/remotethemes/remotethemes.module'; // Import some addon modules that define components, directives and pipes. Only import the important ones. import { AddonModAssignComponentsModule } from '@addons/mod/assign/components/components.module'; @@ -306,10 +306,10 @@ export class CoreCompileProvider { ...ADDON_MOD_URL_SERVICES, // @todo ...ADDON_MOD_WIKI_SERVICES, // @todo ...ADDON_MOD_WORKSHOP_SERVICES, - // @todo ...ADDON_NOTES_SERVICES, + ...ADDON_NOTES_SERVICES, ...ADDON_NOTIFICATIONS_SERVICES, ...ADDON_PRIVATEFILES_SERVICES, - // @todo ...ADDON_REMOTETHEMES_SERVICES, + ...ADDON_REMOTETHEMES_SERVICES, ]; // We cannot inject anything to this constructor. Use the Injector to inject all the providers into the instance. diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index 4052c53da..03e3192c2 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -24,7 +24,7 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreSiteWSPreSets, CoreSite } from '@classes/site'; import { CoreConstants } from '@/core/constants'; import { makeSingleton, Platform, Translate } from '@singletons'; -import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile } from '@services/ws'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { CoreCourseStatusDBRecord, COURSE_STATUS_TABLE } from './database/course'; import { CoreCourseOffline } from './course-offline'; @@ -1404,7 +1404,7 @@ type CoreCourseGetCourseModuleByInstanceWSParams = { */ export type CoreCourseGetCourseModuleWSResponse = { cm: CoreCourseModuleBasicInfo; - warnings?: CoreStatusWithWarningsWSResponse[]; + warnings?: CoreWSExternalWarning[]; }; /** diff --git a/src/core/features/login/services/login-helper.ts b/src/core/features/login/services/login-helper.ts index 0bb494c98..fae50ddc2 100644 --- a/src/core/features/login/services/login-helper.ts +++ b/src/core/features/login/services/login-helper.ts @@ -1086,7 +1086,7 @@ export class CoreLoginHelperProvider { ); if (!result.status) { - throw new CoreWSError(result.warnings?.[0]); + throw new CoreWSError(result.warnings![0]); } const message = Translate.instant('core.login.emailconfirmsentsuccess'); diff --git a/src/core/features/user/pages/participants/participants.page.ts b/src/core/features/user/pages/participants/participants.page.ts index 7c587f5de..3d7aa1bd8 100644 --- a/src/core/features/user/pages/participants/participants.page.ts +++ b/src/core/features/user/pages/participants/participants.page.ts @@ -209,7 +209,10 @@ class CoreUserParticipantsManager extends CorePageItemsListManager { if (CoreScreen.isMobile) { - await CoreNavigator.navigateToSitePath('/user/profile', { params: { userId: participant.id } }); + await CoreNavigator.navigateToSitePath( + '/user/profile', + { params: { userId: participant.id, courseId: this.courseId } }, + ); return; }