MOBILE-3632 notes: Add notes funcionality

main
Pau Ferrer Ocaña 2021-03-10 16:23:17 +01:00
parent 37d6bb0464
commit 9867a48461
24 changed files with 2020 additions and 15 deletions

View File

@ -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,

View File

@ -110,7 +110,7 @@
{{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}}
</p>
<p *ngIf="discussions.isOfflineDiscussion(discussion)">
<ion-icon name="time"></ion-icon>
<ion-icon name="fas-clock"></ion-icon>
{{ 'core.notsent' | translate }}
</p>
</div>
@ -119,7 +119,7 @@
class="ion-text-center addon-mod-forum-discussion-more-info">
<ion-col class="ion-text-start">
<ion-note>
<ion-icon name="time"></ion-icon> {{ 'addon.mod_forum.lastpost' | translate }}
<ion-icon name="fas-clock"></ion-icon> {{ 'addon.mod_forum.lastpost' | translate }}
<ng-container *ngIf="discussion.timemodified > discussion.created">
{{ discussion.timemodified | coreTimeAgo }}
</ng-container>
@ -151,7 +151,7 @@
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="forum && canAddDiscussion">
<ion-fab-button (click)="openNewDiscussion()" [attr.aria-label]="addDiscussionText">
<ion-icon name="add"></ion-icon>
<ion-icon name="fas-plus"></ion-icon>
</ion-fab-button>
</ion-fab>
</core-split-view>

View File

@ -6,7 +6,7 @@
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" (click)="deletePost()" *ngIf="offlinePost || (canDelete && isOnline)">
<ion-icon name="trash" slot="start"></ion-icon>
<ion-icon name="fas-trash" slot="start"></ion-icon>
<ion-label>
<h2 *ngIf="!offlinePost">{{ 'addon.mod_forum.delete' | translate }}</h2>
<h2 *ngIf="offlinePost">{{ 'core.discard' | translate }}</h2>

View File

@ -37,7 +37,7 @@
</ng-container>
</p>
<p *ngIf="post.timecreated">{{post.timecreated * 1000 | coreFormatDate: "strftimerecentfull"}}</p>
<p *ngIf="!post.timecreated"><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</p>
<p *ngIf="!post.timecreated"><ion-icon name="fas-clock"></ion-icon> {{ 'core.notsent' | translate }}</p>
</div>
<ng-container *ngIf="!displaySubject">
<ion-note *ngIf="trackPosts && post.unread"

View File

@ -0,0 +1,34 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'addon.notes.addnewnote' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-times" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<form name="itemEdit" (ngSubmit)="addNote($event)" #itemEdit>
<ion-item>
<ion-label>{{ 'addon.notes.publishstate' | translate }}</ion-label>
<ion-select [(ngModel)]="type" name="publishState" interface="popover">
<ion-select-option value="personal">{{ 'addon.notes.personalnotes' | translate }}</ion-select-option>
<ion-select-option value="course">{{ 'addon.notes.coursenotes' | translate }}</ion-select-option>
<ion-select-option value="site">{{ 'addon.notes.sitenotes' | translate }}</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-label>
<ion-textarea placeholder="{{ 'addon.notes.note' | translate }}" rows="5" [(ngModel)]="text" name="text"
required="required">
</ion-textarea>
</ion-label>
</ion-item>
<div class="ion-padding">
<ion-button expand="block" type="submit" [disabled]="processing || text.length < 2">
{{ 'addon.notes.addnewnote' | translate }}
</ion-button>
</div>
</form>
</ion-content>

View File

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

View File

@ -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 {}

View File

@ -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}}"
}

View File

@ -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 {}

View File

@ -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<unknown>[] = [
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 {}

View File

@ -0,0 +1,100 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.notes.notes' | translate }}</ion-title>
<ion-buttons slot="end">
</ion-buttons>
</ion-toolbar>
</ion-header>
<core-navbar-buttons slot="end">
<ion-button [hidden]="!canDeleteNotes" slot="end" fill="clear" (click)="toggleDelete()"
[attr.aria-label]="'core.delete' | translate">
<ion-icon name="fas-pen" slot="icon-only"></ion-icon>
</ion-button>
<core-context-menu>
<core-context-menu-item [hidden]="!(notesLoaded && !hasOffline)" [priority]="100"
[content]="'core.refresh' | translate" (action)="refreshNotes(false)"
[iconAction]="refreshIcon" [closeOnClick]="true">
</core-context-menu-item>
<core-context-menu-item [hidden]="!(notesLoaded && hasOffline)" [priority]="100"
[content]="'core.settings.synchronizenow' | translate" (action)="refreshNotes(true)"
[iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!notesLoaded" (ionRefresh)="refreshNotes(false, $event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="notesLoaded" class="core-loading-center">
<ion-item class="ion-text-wrap" *ngIf="user">
<core-user-avatar [user]="user" [courseId]="courseId" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label><h2>{{user!.fullname}}</h2></ion-label>
</ion-item>
<div class="ion-padding">
<ion-select [(ngModel)]="type" (ngModelChange)="typeChanged()" interface="popover" class="core-button-select">
<ion-select-option value="site">{{ 'addon.notes.sitenotes' | translate }}</ion-select-option>
<ion-select-option value="course">{{ 'addon.notes.coursenotes' | translate }}</ion-select-option>
<ion-select-option value="personal">{{ 'addon.notes.personalnotes' | translate }}</ion-select-option>
</ion-select>
</div>
<ion-card class="core-warning-card" *ngIf="hasOffline">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>
{{ 'core.thereisdatatosync' | translate:{$a: 'addon.notes.notes' | translate | lowercase } }}
</ion-label>
</ion-item>
</ion-card>
<core-empty-box *ngIf="notes && notes.length == 0" icon="fas-receipt" [message]="'addon.notes.nonotes' | translate">
</core-empty-box>
<ng-container *ngIf="notes && notes.length > 0">
<ion-card *ngFor="let note of notes">
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="note" [courseId]="courseId" slot="start" *ngIf="!userId"></core-user-avatar>
<ion-label>
<h2 *ngIf="!userId">{{note.userfullname}}</h2>
<p *ngIf="!note.deleted && !note.offline" slot="end">
<span class="ion-text-wrap">{{note.lastmodified | coreDateDayOrTime}}</span>
</p>
</ion-label>
<p *ngIf="note.offline" slot="end">
<ion-icon name="far-clock"></ion-icon> <span class="ion-text-wrap">
{{ 'core.notsent' | translate }}
</span>
</p>
<p *ngIf="note.deleted" slot="end">
<ion-icon name="fas-trash"></ion-icon> <span class="ion-text-wrap">
{{ 'core.deletedoffline' | translate }}
</span>
</p>
<ion-button *ngIf="note.deleted" slot="end" fill="clear" color="danger" (click)="undoDeleteNote($event, note)"
[attr.aria-label]="'core.restore' | translate">
<ion-icon name="fas-undo-alt" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="showDelete && !note.deleted && (type != 'personal' || note.usermodified == currentUserId)"
slot="end" fill="clear" [@coreSlideInOut]="'fromRight'" color="danger" (click)="deleteNote($event, note)"
[attr.aria-label]="'core.delete' | translate">
<ion-icon name="fas-trash" slot="icon-only"></ion-icon>
</ion-button>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label><core-format-text [text]="note.content" [filter]="false"></core-format-text></ion-label>
</ion-item>
</ion-card>
</ng-container>
</core-loading>
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="userId && notesLoaded">
<ion-fab-button (click)="addNote($event)" [attr.aria-label]="'addon.notes.addnewnote' |translate">
<ion-icon name="fas-plus"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

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

View File

@ -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;
};

View File

@ -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<boolean> {
return AddonNotes.isPluginEnabled();
}
/**
* @inheritdoc
*/
async isEnabledForCourse(
courseId: number,
accessData: CoreCourseAccess,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): Promise<boolean> {
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<void> {
await AddonNotes.getNotes(course.id, undefined, true);
}
}
export const AddonNotesCourseOptionHandler = makeSingleton(AddonNotesCourseOptionHandlerService);

View File

@ -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<void> {
return AddonNotesSync.syncAllNotes(siteId, force);
}
/**
* @inheritdoc
*/
getInterval(): number {
return 300000; // 5 minutes.
}
}
export const AddonNotesSyncCronHandler = makeSingleton(AddonNotesSyncCronHandlerService);

View File

@ -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<boolean> {
return AddonNotes.isPluginEnabled();
}
/**
* @inheritdoc
*/
async isEnabledForUser(user: CoreUserProfile, courseId?: number): Promise<boolean> {
// 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);

View File

@ -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<void> {
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<AddonNotesDeletedDBRecord[]> {
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<AddonNotesDeletedDBRecord[]> {
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<AddonNotesDBRecord[]> {
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<AddonNotesDBRecord> {
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<AddonNotesDBRecord[]> {
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<AddonNotesDBRecord[]> {
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<AddonNotesDBRecord[]> {
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<AddonNotesDBRecord[]> {
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
const site = await CoreSites.getSite(siteId);
await site.getDb().deleteRecords(NOTES_DELETED_TABLE, { noteid: noteId });
}
}
export const AddonNotesOffline = makeSingleton(AddonNotesOfflineProvider);

View File

@ -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<AddonNotesSyncResult> {
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<void> {
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<void> {
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<AddonNotesSyncResult | undefined> {
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<AddonNotesSyncResult> {
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<AddonNotesSyncResult> {
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<void>[] = [];
// 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<void>[] = 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;
}
}

View File

@ -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<boolean> {
siteId = siteId || CoreSites.getCurrentSiteId();
// Convenience function to store a note to be synchronized later.
const storeOffline = async (): Promise<boolean> => {
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<void> {
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<AddonNotesCreateNotesWSResponse> {
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<boolean> {
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<boolean> => {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<AddonNotesGetCourseNotesWSResponse> {
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<AddonNotesGetCourseNotesWSResponse>('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<void> {
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<AddonNotesNoteFormatted[]> {
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<void> {
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<void> {
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.
};

View File

@ -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.
};

View File

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

View File

@ -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[];
};
/**

View File

@ -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');

View File

@ -209,7 +209,10 @@ class CoreUserParticipantsManager extends CorePageItemsListManager<CoreUserParti
*/
async select(participant: CoreUserParticipant | CoreUserData): Promise<void> {
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;
}