diff --git a/src/addon/calendar/pages/event/event.html b/src/addon/calendar/pages/event/event.html index f3f20e707..b55da9358 100644 --- a/src/addon/calendar/pages/event/event.html +++ b/src/addon/calendar/pages/event/event.html @@ -27,7 +27,7 @@

- {{event.moduleName}} + {{event.moduleName}}

diff --git a/src/addon/calendar/pages/event/event.ts b/src/addon/calendar/pages/event/event.ts index a976a1201..2778df277 100644 --- a/src/addon/calendar/pages/event/event.ts +++ b/src/addon/calendar/pages/event/event.ts @@ -21,7 +21,7 @@ import { CoreCoursesProvider } from '../../../../core/courses/providers/courses' import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; import { CoreSitesProvider } from '../../../../providers/sites'; import { CoreLocalNotificationsProvider } from '../../../../providers/local-notifications'; -//import { CoreCourseProvider } from '../../../core/course/providers/course'; +import { CoreCourseProvider } from '../../../../core/course/providers/course'; import * as moment from 'moment'; /** @@ -48,7 +48,7 @@ export class AddonCalendarEventPage { constructor(private translate: TranslateService, private calendarProvider: AddonCalendarProvider, private navParams: NavParams, private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider, private calendarHelper: AddonCalendarHelperProvider, private sitesProvider: CoreSitesProvider, - private localNotificationsProvider: CoreLocalNotificationsProvider/*, private courseProvider: CoreCourseProvider*/) { + private localNotificationsProvider: CoreLocalNotificationsProvider, private courseProvider: CoreCourseProvider) { this.eventId = navParams.get('id'); this.notificationsEnabled = localNotificationsProvider.isAvailable(); @@ -95,8 +95,8 @@ export class AddonCalendarEventPage { // Guess event title. let title = this.translate.instant('addon.calendar.type' + event.eventtype); if (event.moduleIcon) { - // @todo: It's a module event, translate the module name to the current language. - let name = "" //this.courseProvider.translateModuleName(event.modulename); + // It's a module event, translate the module name to the current language. + let name = this.courseProvider.translateModuleName(event.modulename); if (name.indexOf('core.mod_') === -1) { event.moduleName = name; } diff --git a/src/addon/calendar/pages/list/list.html b/src/addon/calendar/pages/list/list.html index 7cdae7575..f61ed9e87 100644 --- a/src/addon/calendar/pages/list/list.html +++ b/src/addon/calendar/pages/list/list.html @@ -22,7 +22,7 @@ - +

{{ event.timestart | coreToLocaleString }}

diff --git a/src/addon/calendar/providers/calendar.ts b/src/addon/calendar/providers/calendar.ts index 886a21670..d5a9a89fc 100644 --- a/src/addon/calendar/providers/calendar.ts +++ b/src/addon/calendar/providers/calendar.ts @@ -230,8 +230,8 @@ export class AddonCalendarProvider { return this.groupsProvider.getUserGroups(courses, siteId).then((groups) => { let now = this.timeUtils.timestamp(), - start = now + (CoreConstants.secondsDay * daysToStart), - end = start + (CoreConstants.secondsDay * daysInterval); + start = now + (CoreConstants.SECONDS_DAY * daysToStart), + end = start + (CoreConstants.SECONDS_DAY * daysInterval); // The core_calendar_get_calendar_events needs all the current user courses and groups. let data = { diff --git a/src/addon/calendar/providers/helper.ts b/src/addon/calendar/providers/helper.ts index 54076fcd7..c0b578986 100644 --- a/src/addon/calendar/providers/helper.ts +++ b/src/addon/calendar/providers/helper.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '../../../providers/logger'; import { CoreSitesProvider } from '../../../providers/sites'; -//import { CoreCourseProvider } from '../../../core/course/providers/course'; +import { CoreCourseProvider } from '../../../core/course/providers/course'; /** * Service that provides some features regarding lists of courses and categories. @@ -32,7 +32,7 @@ export class AddonCalendarHelperProvider { 'category': 'albums' }; - constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider/*, private courseProvider: CoreCourseProvider*/) { + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private courseProvider: CoreCourseProvider) { this.logger = logger.getInstance('AddonCalendarHelperProvider'); } @@ -44,8 +44,7 @@ export class AddonCalendarHelperProvider { formatEventData(e: any) { e.icon = this.EVENTICONS[e.eventtype] || false; if (!e.icon) { - // @todo: It's a module event. - //e.icon = this.courseProvider.getModuleIconSrc(e.modulename); + e.icon = this.courseProvider.getModuleIconSrc(e.modulename); e.moduleIcon = e.icon; } }; diff --git a/src/app/app.ios.scss b/src/app/app.ios.scss index 84a20c0c0..662eb401e 100644 --- a/src/app/app.ios.scss +++ b/src/app/app.ios.scss @@ -12,18 +12,6 @@ height: calc(100% - #{($card-ios-margin-end + $card-ios-margin-start)}); } -// Top tabs -// ------------------------- -.ios .core-top-tabbar { - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; - > a { - font-size: 1.6rem; - } -} - .bar-buttons core-context-menu .button-clear-ios { color: $toolbar-ios-button-color; } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 807ed7af9..12c7bfd48 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -48,6 +48,7 @@ import { CoreFilepoolProvider } from '../providers/filepool'; import { CoreUpdateManagerProvider } from '../providers/update-manager'; import { CorePluginFileDelegate } from '../providers/plugin-file-delegate'; +// Core modules. import { CoreComponentsModule } from '../components/components.module'; import { CoreEmulatorModule } from '../core/emulator/emulator.module'; import { CoreLoginModule } from '../core/login/login.module'; @@ -55,6 +56,9 @@ import { CoreMainMenuModule } from '../core/mainmenu/mainmenu.module'; import { CoreCoursesModule } from '../core/courses/courses.module'; import { CoreFileUploaderModule } from '../core/fileuploader/fileuploader.module'; import { CoreSharedFilesModule } from '../core/sharedfiles/sharedfiles.module'; +import { CoreCourseModule } from '../core/course/course.module'; + +// Addon modules. import { AddonCalendarModule } from '../addon/calendar/calendar.module'; // For translate loader. AoT requires an exported function for factories. @@ -80,13 +84,14 @@ export function createTranslateLoader(http: HttpClient) { deps: [HttpClient] } }), + CoreComponentsModule, CoreEmulatorModule, CoreLoginModule, CoreMainMenuModule, CoreCoursesModule, CoreFileUploaderModule, CoreSharedFilesModule, - CoreComponentsModule, + CoreCourseModule, AddonCalendarModule ], bootstrap: [IonicApp], diff --git a/src/app/app.scss b/src/app/app.scss index 8c06237ba..862c6232a 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -31,6 +31,9 @@ } } +.opacity-hide { opacity: 0; } +.core-big { font-size: 115%; } + @media only screen and (min-width: 430px) { .core-center-view .scroll-content { display: flex!important; @@ -63,6 +66,10 @@ margin: 0; } +.item-dimmed { + opacity: 0.6; +} + .core-oauth-icon, .item.core-oauth-icon, .list .item.core-oauth-icon { min-height: 32px; img, .label { @@ -82,6 +89,20 @@ font-weight: bold; } +.core-module-icon { + width: auto; +} + +.core-button-spinner { + min-height: 44px; + min-width: 50px; + text-align: center; + + .spinner { + margin-top: 8px; + } +} + // Avatar // ------------------------- // Large centered avatar @@ -133,7 +154,8 @@ ion-avatar ion-img, ion-avatar img { } /** Format Text */ -core-format-text[maxHeight], *[core-format-text][maxHeight] { +core-format-text[maxHeight], *[core-format-text][maxHeight], +core-format-text[ng-reflect-max-height], *[core-format-text][ng-reflect-max-height] { display: block; position: relative; width: 100%; @@ -148,6 +170,7 @@ core-format-text[maxHeight], *[core-format-text][maxHeight] { // This is to allow clicks in radio/checkbox content. &.core-text-formatted { cursor: pointer; + pointer-events: auto; .core-show-more { display: none; @@ -173,19 +196,6 @@ core-format-text[maxHeight], *[core-format-text][maxHeight] { z-index: 1001; background-color: $white; padding-left: 10px; - - /* @todo - &:after { - @extend .ion; - content: $ionicon-var-chevron-down; - margin-left: 10px; - color: $item-icon-accessory-color; - } - */ - } - - &.core-expand-in-fullview .core-show-more:after { - // content: $ionicon-var-chevron-right; @todo } &:before { @@ -205,6 +215,49 @@ core-format-text[maxHeight], *[core-format-text][maxHeight] { } } } + + &.core-expand-in-fullview { + .core-show-more { + @include svg-background-image($item-md-detail-push-svg, true); + @include padding-horizontal(null, 18px); + @include background-position(end, 0, center); + + background-repeat: no-repeat; + background-size: 14px 14px; + } + } +} + +.core-media-adapt-width { + max-width: 100%; +} + +audio.core-media-adapt-width { + width: 100%; +} + +.core-adapted-img-container { + position: relative; + display: inline-block; +} + +.core-image-viewer-icon { + position: absolute; + right: 10px; + bottom: 10px; + color: $black; + border-radius: 5px; + background: rgba(255, 255, 255, .5); + text-align: center; + + width: 32px; + height: 32px; + max-width: 32px; + line-height: 32px; + font-size: 24px; + ion-icon { + font-size: 24px; + } } core-format-text, *[core-format-text] { @@ -226,6 +279,21 @@ core-format-text, *[core-format-text] { .badge { position: initial !important; } + + // Images in ion-card have width 100% and display block. Remove that when the image is in core-format-text. + img { + width: initial; + display: inline; + } + + .core-disable-media-adapt, + .core-disable-media-adapt .core-media-adapt-width { + max-width: none !important; + max-height: none !important; + width: initial !important; + height: initial !important; + display: inline-block !important; + } } // Message item. @@ -249,32 +317,6 @@ ion-select { position: relative } -// Top tabs -// ------------------------- - -.core-top-tabbar { - @include position(null, null, 0, 0); - - z-index: $z-index-toolbar; - display: flex; - width: 100%; - background: $core-top-tabs-background; - - > a { - @extend .tab-button; - - background: $core-top-tabs-background; - color: $core-top-tabs-color !important; - border-bottom: 1px solid $core-top-tabs-border; - font-size: 1.6rem; - - &[aria-selected=true] { - color: $core-top-tabs-color-active !important; - border-bottom: 2px solid $core-top-tabs-color-active; - } - } -} - // File uploader. // ------------------------- @@ -293,3 +335,76 @@ ion-select { cursor: pointer; } } + +// Atto styles +// ------------------------- +.atto_image_preview { + width: 100%; + height: 100%; + margin-left: auto; + margin-right: auto; +} + +.atto_image_preview_box { + max-height: 200px; + margin-bottom: 1em; + overflow: auto; +} + +.editor_atto_content img { + cursor: pointer; +} + +.atto_image_size { + display: inline-block; +} + +.atto_image_size input[type=checkbox] { + margin-left: 1em; + margin-right: 1em; +} + +.atto_image_size input[type=text] { + width: 3em; +} + +.atto_image_size label { + display: inline-block; +} + +.atto_image_button_text-top { + vertical-align: text-top; + margin: 0 0.5em; +} + +.atto_image_button_middle { + vertical-align: middle; + margin: 0 0.5em; +} + +.atto_image_button_text-bottom { + vertical-align: text-bottom; + margin: 0 0.5em; +} + +.atto_image_button_text-top.img-responsive, +.atto_image_button_middle.img-responsive, +.atto_image_button_text-bottom.img-responsive { + /* If the image is display: block then linking the image to URLs won't work. */ + display: inline-block; + max-width: calc(100% - 1em); +} + +/*rtl:begin:ignore*/ +.atto_image_button_left { + float: left; + margin: 0 0.5em 0 0; + max-width: calc(100% - 1em); +} + +.atto_image_button_right { + float: right; + margin: 0 0 0 0.5em; + max-width: calc(100% - 1em); +} +/*rtl:end:ignore*/ \ No newline at end of file diff --git a/src/assets/img/completion/completion-auto-fail.svg b/src/assets/img/completion/completion-auto-fail.svg new file mode 100644 index 000000000..771adf36f --- /dev/null +++ b/src/assets/img/completion/completion-auto-fail.svg @@ -0,0 +1,18 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-auto-n-override.svg b/src/assets/img/completion/completion-auto-n-override.svg new file mode 100644 index 000000000..6100638d0 --- /dev/null +++ b/src/assets/img/completion/completion-auto-n-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-auto-n.svg b/src/assets/img/completion/completion-auto-n.svg new file mode 100644 index 000000000..6a8bc6222 --- /dev/null +++ b/src/assets/img/completion/completion-auto-n.svg @@ -0,0 +1,15 @@ + + + +]> + + + + + diff --git a/src/assets/img/completion/completion-auto-pass.svg b/src/assets/img/completion/completion-auto-pass.svg new file mode 100644 index 000000000..44df83f15 --- /dev/null +++ b/src/assets/img/completion/completion-auto-pass.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-auto-y-override.svg b/src/assets/img/completion/completion-auto-y-override.svg new file mode 100644 index 000000000..13cf5d700 --- /dev/null +++ b/src/assets/img/completion/completion-auto-y-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-auto-y.svg b/src/assets/img/completion/completion-auto-y.svg new file mode 100644 index 000000000..14822e173 --- /dev/null +++ b/src/assets/img/completion/completion-auto-y.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-manual-n-override.svg b/src/assets/img/completion/completion-manual-n-override.svg new file mode 100644 index 000000000..cccfb99cd --- /dev/null +++ b/src/assets/img/completion/completion-manual-n-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-manual-n.svg b/src/assets/img/completion/completion-manual-n.svg new file mode 100644 index 000000000..f7750e25a --- /dev/null +++ b/src/assets/img/completion/completion-manual-n.svg @@ -0,0 +1,14 @@ + + + +]> + + + + + diff --git a/src/assets/img/completion/completion-manual-y-override.svg b/src/assets/img/completion/completion-manual-y-override.svg new file mode 100644 index 000000000..69270ba3e --- /dev/null +++ b/src/assets/img/completion/completion-manual-y-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-manual-y.svg b/src/assets/img/completion/completion-manual-y.svg new file mode 100644 index 000000000..3b91bdbc7 --- /dev/null +++ b/src/assets/img/completion/completion-manual-y.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/assets/img/mod/assign.svg b/src/assets/img/mod/assign.svg new file mode 100644 index 000000000..41a788985 --- /dev/null +++ b/src/assets/img/mod/assign.svg @@ -0,0 +1,89 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/assignment.svg b/src/assets/img/mod/assignment.svg new file mode 100644 index 000000000..41a788985 --- /dev/null +++ b/src/assets/img/mod/assignment.svg @@ -0,0 +1,89 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/book.svg b/src/assets/img/mod/book.svg new file mode 100644 index 000000000..740a35160 --- /dev/null +++ b/src/assets/img/mod/book.svg @@ -0,0 +1,80 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/chat.svg b/src/assets/img/mod/chat.svg new file mode 100644 index 000000000..9dd304b78 --- /dev/null +++ b/src/assets/img/mod/chat.svg @@ -0,0 +1,77 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/choice.svg b/src/assets/img/mod/choice.svg new file mode 100644 index 000000000..4d455910c --- /dev/null +++ b/src/assets/img/mod/choice.svg @@ -0,0 +1,46 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/data.svg b/src/assets/img/mod/data.svg new file mode 100644 index 000000000..954777f09 --- /dev/null +++ b/src/assets/img/mod/data.svg @@ -0,0 +1,87 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/database.svg b/src/assets/img/mod/database.svg new file mode 100644 index 000000000..954777f09 --- /dev/null +++ b/src/assets/img/mod/database.svg @@ -0,0 +1,87 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/external-tool.svg b/src/assets/img/mod/external-tool.svg new file mode 100644 index 000000000..ebbbe3084 --- /dev/null +++ b/src/assets/img/mod/external-tool.svg @@ -0,0 +1,55 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/feedback.svg b/src/assets/img/mod/feedback.svg new file mode 100644 index 000000000..58d0f080b --- /dev/null +++ b/src/assets/img/mod/feedback.svg @@ -0,0 +1,133 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/file.svg b/src/assets/img/mod/file.svg new file mode 100644 index 000000000..2039a2ea2 --- /dev/null +++ b/src/assets/img/mod/file.svg @@ -0,0 +1,60 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/folder.svg b/src/assets/img/mod/folder.svg new file mode 100644 index 000000000..6c2a9fe19 --- /dev/null +++ b/src/assets/img/mod/folder.svg @@ -0,0 +1,65 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/forum.svg b/src/assets/img/mod/forum.svg new file mode 100644 index 000000000..aab9a8f44 --- /dev/null +++ b/src/assets/img/mod/forum.svg @@ -0,0 +1,71 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/glossary.svg b/src/assets/img/mod/glossary.svg new file mode 100644 index 000000000..f330727e3 --- /dev/null +++ b/src/assets/img/mod/glossary.svg @@ -0,0 +1,146 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/ims.svg b/src/assets/img/mod/ims.svg new file mode 100644 index 000000000..5589cd0c5 --- /dev/null +++ b/src/assets/img/mod/ims.svg @@ -0,0 +1,156 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/imscp.svg b/src/assets/img/mod/imscp.svg new file mode 100644 index 000000000..5589cd0c5 --- /dev/null +++ b/src/assets/img/mod/imscp.svg @@ -0,0 +1,156 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/label.svg b/src/assets/img/mod/label.svg new file mode 100644 index 000000000..ac232fc58 --- /dev/null +++ b/src/assets/img/mod/label.svg @@ -0,0 +1,94 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/lesson.svg b/src/assets/img/mod/lesson.svg new file mode 100644 index 000000000..0a0e5dfd5 --- /dev/null +++ b/src/assets/img/mod/lesson.svg @@ -0,0 +1,126 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/lti.svg b/src/assets/img/mod/lti.svg new file mode 100644 index 000000000..ebbbe3084 --- /dev/null +++ b/src/assets/img/mod/lti.svg @@ -0,0 +1,55 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/page.svg b/src/assets/img/mod/page.svg new file mode 100644 index 000000000..eb7cae6c8 --- /dev/null +++ b/src/assets/img/mod/page.svg @@ -0,0 +1,112 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/quiz.svg b/src/assets/img/mod/quiz.svg new file mode 100644 index 000000000..90473416f --- /dev/null +++ b/src/assets/img/mod/quiz.svg @@ -0,0 +1,90 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/resource.svg b/src/assets/img/mod/resource.svg new file mode 100644 index 000000000..2039a2ea2 --- /dev/null +++ b/src/assets/img/mod/resource.svg @@ -0,0 +1,60 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/scorm.svg b/src/assets/img/mod/scorm.svg new file mode 100644 index 000000000..77891eca4 --- /dev/null +++ b/src/assets/img/mod/scorm.svg @@ -0,0 +1,84 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/survey.svg b/src/assets/img/mod/survey.svg new file mode 100644 index 000000000..a97fe77ef --- /dev/null +++ b/src/assets/img/mod/survey.svg @@ -0,0 +1,89 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/url.svg b/src/assets/img/mod/url.svg new file mode 100644 index 000000000..56bdb5541 --- /dev/null +++ b/src/assets/img/mod/url.svg @@ -0,0 +1,485 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/wiki.svg b/src/assets/img/mod/wiki.svg new file mode 100644 index 000000000..f3101ce19 --- /dev/null +++ b/src/assets/img/mod/wiki.svg @@ -0,0 +1,228 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/mod/workshop.svg b/src/assets/img/mod/workshop.svg new file mode 100644 index 000000000..f466455a6 --- /dev/null +++ b/src/assets/img/mod/workshop.svg @@ -0,0 +1,98 @@ + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/classes/cache.ts b/src/classes/cache.ts new file mode 100644 index 000000000..c117dc71b --- /dev/null +++ b/src/classes/cache.ts @@ -0,0 +1,99 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * A cache to store values in memory to speed up processes. + * + * The data is organized by "entries" that are identified by an ID. Each entry can have multiple values stored, + * and each value has its own timemodified. + * + * Values expire after a certain time. + */ +export class CoreCache { + protected cacheStore = {}; + + constructor() {} + + /** + * Clear the cache. + */ + clear() { + this.cacheStore = {}; + } + + /** + * Get all the data stored in the cache for a certain id. + * + * @param {any} id The ID to identify the entry. + * @return {any} The data from the cache. Undefined if not found. + */ + getEntry(id: any) : any { + if (!this.cacheStore[id]) { + this.cacheStore[id] = {}; + } + + return this.cacheStore[id]; + } + + /** + * Get the status of a module from the "cache". + * + * @param {any} id The ID to identify the entry. + * @param {string} name Name of the value to get. + * @param {boolean} [ignoreInvalidate] Whether it should always return the cached data, even if it's expired. + * @return {any} Cached value. Undefined if not cached or expired. + */ + getValue(id: any, name: string, ignoreInvalidate?: boolean) : any { + const entry = this.getEntry(id); + + if (entry[name] && typeof entry[name].value != 'undefined') { + const now = Date.now(); + // Invalidate after 5 minutes. + if (ignoreInvalidate || entry[name].timemodified + 300000 >= now) { + return entry[name].value; + } + } + + return undefined; + } + + /** + * Invalidate all the cached data for a certain entry. + * + * @param {any} id The ID to identify the entry. + */ + invalidate(id: any) : void { + const entry = this.getEntry(id); + for (let name in entry) { + entry[name].timemodified = 0; + } + } + + /** + * Update the status of a module in the "cache". + * + * @param {any} id The ID to identify the entry. + * @param {string} name Name of the value to set. + * @param {any} value Value to set. + * @return {any} The set value. + */ + setValue(id: any, name: string, value: any) : any { + const entry = this.getEntry(id); + entry[name] = { + value: value, + timemodified: Date.now() + }; + return value; + } +} diff --git a/src/classes/site.ts b/src/classes/site.ts index 553a1cf19..43272d116 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -32,26 +32,110 @@ import { CoreConfigConstants } from '../configconstants'; import { Md5 } from 'ts-md5/dist/md5'; import { InAppBrowserObject } from '@ionic-native/in-app-browser'; +/** + * PreSets accepted by the WS call. + */ export interface CoreSiteWSPreSets { - getFromCache?: boolean; // Get the value from the cache if it's still valid. - saveToCache?: boolean; // Save the result to the cache. - omitExpires?: boolean; // Ignore cache expiration. - emergencyCache?: boolean; // Use the cache when a request fails. Defaults to true. - cacheKey?: string; // Extra key to add to the cache when storing this call, to identify the entry. - getCacheUsingCacheKey?: boolean; // Whether it should use cache key to retrieve the cached data instead of the request params. - getEmergencyCacheUsingCacheKey?: boolean; // Same as getCacheUsingCacheKey, but for emergency cache. - uniqueCacheKey?: boolean; // Whether it should only be 1 entry for this cache key (all entries with same key will be deleted). - filter?: boolean; // Whether to filter WS response (moodlewssettingfilter). Defaults to true. - rewriteurls?: boolean; // Whether to rewrite URLs (moodlewssettingfileurl). Defaults to true. - responseExpected?: boolean; // Defaults to true. Set to false when the expected response is null. - typeExpected?: string; // Defaults to 'object'. Use it when you expect a type that's not an object|array. + /** + * Get the value from the cache if it's still valid. + * @type {boolean} + */ + getFromCache?: boolean; + + /** + * Save the result to the cache. + * @type {boolean} + */ + saveToCache?: boolean; + + /** + * Ignore cache expiration. + * @type {boolean} + */ + omitExpires?: boolean; + + /** + * Use the cache when a request fails. Defaults to true. + * @type {boolean} + */ + emergencyCache?: boolean; + + /** + * Extra key to add to the cache when storing this call, to identify the entry. + * @type {string} + */ + cacheKey?: string; + + /** + * Whether it should use cache key to retrieve the cached data instead of the request params. + * @type {boolean} + */ + getCacheUsingCacheKey?: boolean; + + /** + * Same as getCacheUsingCacheKey, but for emergency cache. + * @type {boolean} + */ + getEmergencyCacheUsingCacheKey?: boolean; + + /** + * Whether it should only be 1 entry for this cache key (all entries with same key will be deleted). + * @type {boolean} + */ + uniqueCacheKey?: boolean; + + /** + * Whether to filter WS response (moodlewssettingfilter). Defaults to true. + * @type {boolean} + */ + filter?: boolean; + + /** + * Whether to rewrite URLs (moodlewssettingfileurl). Defaults to true. + * @type {boolean} + */ + rewriteurls?: boolean; + + /** + * Defaults to true. Set to false when the expected response is null. + * @type {boolean} + */ + responseExpected?: boolean; + + /** + * Defaults to 'object'. Use it when you expect a type that's not an object|array. + * @type {string} + */ + typeExpected?: string; }; +/** + * Response of checking local_mobile status. + */ export interface LocalMobileResponse { - code: number; // Code to identify the authentication method to use. - service?: string; // Name of the service to use. - warning?: string; // Code of the warning message. - coreSupported?: boolean; // Whether core SSO is supported. + /** + * Code to identify the authentication method to use. + * @type {number} + */ + code: number; + + /** + * Name of the service to use. + * @type {string} + */ + service?: string; + + /** + * Code of the warning message. + * @type {string} + */ + warning?: string; + + /** + * Whether core SSO is supported. + * @type {boolean} + */ + coreSupported?: boolean; } /** @@ -435,7 +519,7 @@ export class CoreSite { // Check if the method is available, use a prefixed version if possible. // We ignore this check when we do not have the site info, as the list of functions is not loaded yet. if (this.getInfo() && !this.wsAvailable(method, false)) { - const compatibilityMethod = CoreConstants.wsPrefix + method; + const compatibilityMethod = CoreConstants.WS_PREFIX + method; if (this.wsAvailable(compatibilityMethod, false)) { this.logger.info(`Using compatibility WS method '${compatibilityMethod}'`); method = compatibilityMethod; @@ -482,14 +566,7 @@ export class CoreSite { // We pass back a clone of the original object, this may // prevent errors if in the callback the object is modified. - if (typeof response == 'object') { - if (Array.isArray(response)) { - return Array.from(response); - } else { - return Object.assign({}, response); - } - } - return response; + return this.utils.clone(response); }).catch((error) => { if (error.errorcode == 'invalidtoken' || (error.errorcode == 'accessexception' && error.message.indexOf('Invalid token - token expired') > -1)) { @@ -572,7 +649,7 @@ export class CoreSite { // Let's try again with the compatibility prefix. if (checkPrefix) { - return this.wsAvailable(CoreConstants.wsPrefix + method, false); + return this.wsAvailable(CoreConstants.WS_PREFIX + method, false); } return false; @@ -884,7 +961,7 @@ export class CoreSite { return Promise.resolve({code: 0}); } - let observable = this.http.post(checkUrl, {service: service}).timeout(CoreConstants.wsTimeout); + let observable = this.http.post(checkUrl, {service: service}).timeout(CoreConstants.WS_TIMEOUT); return this.utils.observableToPromise(observable).then((data: any) => { if (typeof data != 'undefined' && data.errorcode === 'requirecorrectaccess') { if (!retrying) { @@ -937,7 +1014,7 @@ export class CoreSite { } this.infos.functions.forEach((func) => { - if (func.name.indexOf(CoreConstants.wsPrefix) != -1) { + if (func.name.indexOf(CoreConstants.WS_PREFIX) != -1) { appUsesLocalMobile = true; } }); @@ -1081,7 +1158,7 @@ export class CoreSite { }; if (!this.privateToken || !this.wsAvailable('tool_mobile_get_autologin_key') || - (this.lastAutoLogin && this.timeUtils.timestamp() - this.lastAutoLogin < 6 * CoreConstants.secondsMinute)) { + (this.lastAutoLogin && this.timeUtils.timestamp() - this.lastAutoLogin < 6 * CoreConstants.SECONDS_MINUTE)) { // No private token, WS not available or last auto-login was less than 6 minutes ago. // Open the final URL without auto-login. return Promise.resolve(open(url)); diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 0ce4dbc4d..a9b03da57 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -34,6 +34,8 @@ import { CoreCoursePickerMenuPopoverComponent } from './course-picker-menu/cours import { CoreChronoComponent } from './chrono/chrono'; import { CoreLocalFileComponent } from './local-file/local-file'; import { CoreSitePickerComponent } from './site-picker/site-picker'; +import { CoreTabsComponent } from './tabs/tabs'; +import { CoreTabComponent } from './tabs/tab'; @NgModule({ declarations: [ @@ -53,7 +55,9 @@ import { CoreSitePickerComponent } from './site-picker/site-picker'; CoreCoursePickerMenuPopoverComponent, CoreChronoComponent, CoreLocalFileComponent, - CoreSitePickerComponent + CoreSitePickerComponent, + CoreTabsComponent, + CoreTabComponent ], entryComponents: [ CoreContextMenuPopoverComponent, @@ -80,7 +84,9 @@ import { CoreSitePickerComponent } from './site-picker/site-picker'; CoreContextMenuItemComponent, CoreChronoComponent, CoreLocalFileComponent, - CoreSitePickerComponent + CoreSitePickerComponent, + CoreTabsComponent, + CoreTabComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/context-menu/context-menu-item.ts b/src/components/context-menu/context-menu-item.ts index 61ab66d29..7527c3953 100644 --- a/src/components/context-menu/context-menu-item.ts +++ b/src/components/context-menu/context-menu-item.ts @@ -38,7 +38,7 @@ export class CoreContextMenuItemComponent implements OnInit, OnDestroy, OnChange @Input() iconDescription?: string; // Name of the icon to be shown on the left side of the item. @Input() iconAction?: string; // Name of the icon to be shown on the right side of the item. It represents the action to do on // click. If is "spinner" an spinner will be shown. If no icon or spinner is selected, no action - // or link will work. If href but no iconAction is provided ion-arrow-right-c will be used. + // or link will work. If href but no iconAction is provided arrow-right will be used. @Input() ariaDescription?: string; // Aria label to add to iconDescription. @Input() ariaAction?: string; // Aria label to add to iconAction. If not set, it will be equal to content. @Input() href?: string; // Link to go if no action provided. diff --git a/src/components/context-menu/context-menu-popover.ts b/src/components/context-menu/context-menu-popover.ts index fa144228e..19ad6f0d2 100644 --- a/src/components/context-menu/context-menu-popover.ts +++ b/src/components/context-menu/context-menu-popover.ts @@ -15,6 +15,7 @@ import { Component } from '@angular/core'; import { NavParams, ViewController } from 'ionic-angular'; import { CoreContextMenuItemComponent } from './context-menu-item'; +import { CoreLoggerProvider } from '../../providers/logger'; /** * Component to display a list of items received by param in a popover. @@ -26,10 +27,12 @@ import { CoreContextMenuItemComponent } from './context-menu-item'; export class CoreContextMenuPopoverComponent { title: string; items: CoreContextMenuItemComponent[]; + protected logger: any; - constructor(navParams: NavParams, private viewCtrl: ViewController) { + constructor(navParams: NavParams, private viewCtrl: ViewController, logger: CoreLoggerProvider) { this.title = navParams.get('title'); this.items = navParams.get('items') || []; + this.logger = logger.getInstance('CoreContextMenuPopoverComponent'); } /** @@ -51,7 +54,10 @@ export class CoreContextMenuPopoverComponent { event.preventDefault(); event.stopPropagation(); - if (!item.iconAction || item.iconAction == 'spinner') { + if (!item.iconAction) { + this.logger.warn('Items with action must have an icon action to work', item); + return false; + } else if (item.iconAction == 'spinner') { return false; } diff --git a/src/components/empty-box/empty-box.scss b/src/components/empty-box/empty-box.scss index 0b7562355..4147e73e7 100644 --- a/src/components/empty-box/empty-box.scss +++ b/src/components/empty-box/empty-box.scss @@ -27,6 +27,7 @@ core-empty-box { left: initial; right: initial; z-index: initial; + height: auto; } .icon { diff --git a/src/components/file/file.ts b/src/components/file/file.ts index 0506bea4f..4c33517d8 100644 --- a/src/components/file/file.ts +++ b/src/components/file/file.ts @@ -102,10 +102,10 @@ export class CoreFileComponent implements OnInit, OnDestroy { return this.filepoolProvider.getFileStateByUrl(this.siteId, this.fileUrl, this.timemodified).then((state) => { let canDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); - this.isDownloaded = state === CoreConstants.downloaded || state === CoreConstants.outdated; - this.isDownloading = canDownload && state === CoreConstants.downloading; - this.showDownload = canDownload && (state === CoreConstants.notDownloaded || state === CoreConstants.outdated || - (this.alwaysDownload && state === CoreConstants.downloaded)); + this.isDownloaded = state === CoreConstants.DOWNLOADED || state === CoreConstants.OUTDATED; + this.isDownloading = canDownload && state === CoreConstants.DOWNLOADING; + this.showDownload = canDownload && (state === CoreConstants.NOT_DOWNLOADED || state === CoreConstants.OUTDATED || + (this.alwaysDownload && state === CoreConstants.DOWNLOADED)); }); } @@ -207,7 +207,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { let subPromise; - if (status === CoreConstants.notDownloaded) { + if (status === CoreConstants.NOT_DOWNLOADED) { // File is not downloaded, download and then return the local URL. subPromise = this.downloadFile(); } else { diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts new file mode 100644 index 000000000..16a9a297e --- /dev/null +++ b/src/components/tabs/tab.ts @@ -0,0 +1,64 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, OnInit, OnDestroy, ElementRef, EventEmitter } from '@angular/core'; +import { CoreTabsComponent } from './tabs'; + +/** + * A tab to use inside core-tabs. The content of this tab will be displayed when the tab is selected. + * + * You must provide either a title or an icon for the tab. + * + * Example usage: + * + * + * + * + * + * + */ +@Component({ + selector: 'core-tab', + template: '' +}) +export class CoreTabComponent implements OnInit, OnDestroy { + @Input() title?: string; // The tab title. + @Input() icon?: string; // The tab icon. + @Input() badge?: string; // A badge to add in the tab. + @Input() badgeStyle?: string; // The badge color. + @Input() enabled?: boolean = true; // Whether the tab is enabled. + @Input() show?: boolean = true; // Whether the tab should be shown. + @Input() id?: string; // An ID to identify the tab. + @Output() ionSelect: EventEmitter = new EventEmitter(); + + element: HTMLElement; // The core-tab element. + + constructor(private tabs: CoreTabsComponent, element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.tabs.addTab(this); + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + this.tabs.removeTab(this); + } +} diff --git a/src/components/tabs/tabs.html b/src/components/tabs/tabs.html new file mode 100644 index 000000000..9bac58750 --- /dev/null +++ b/src/components/tabs/tabs.html @@ -0,0 +1,12 @@ +
+ + + + {{ tab.title }} + {{tab.badge}} + + +
+
+ +
\ No newline at end of file diff --git a/src/components/tabs/tabs.ios.scss b/src/components/tabs/tabs.ios.scss new file mode 100644 index 000000000..b9134b986 --- /dev/null +++ b/src/components/tabs/tabs.ios.scss @@ -0,0 +1,11 @@ +core-tabs { + .core-tabs-bar { + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + > a { + font-size: 1.6rem; + } + } +} diff --git a/src/components/tabs/tabs.scss b/src/components/tabs/tabs.scss new file mode 100644 index 000000000..b7daa7804 --- /dev/null +++ b/src/components/tabs/tabs.scss @@ -0,0 +1,32 @@ +core-tabs { + .core-tabs-bar { + @include position(null, null, 0, 0); + + z-index: $z-index-toolbar; + display: flex; + width: 100%; + background: $core-top-tabs-background; + + > a { + @extend .tab-button; + + background: $core-top-tabs-background; + color: $core-top-tabs-color !important; + border-bottom: 1px solid $core-top-tabs-border; + font-size: 1.6rem; + + &[aria-selected=true] { + color: $core-top-tabs-color-active !important; + border-bottom: 2px solid $core-top-tabs-color-active; + } + } + } + + core-tab { + display: none; + + &.selected { + display: block; + } + } +} diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts new file mode 100644 index 000000000..93ab502fa --- /dev/null +++ b/src/components/tabs/tabs.ts @@ -0,0 +1,183 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnInit, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; +import { CoreTabComponent } from './tab'; + +/** + * This component displays some tabs that usually share data between them. + * + * If your tabs don't share any data then you should probably use ion-tabs. This component doesn't use different ion-nav + * for each tab, so it will not load pages. + * + * Example usage: + * + * + * + * + * + * + * + * Obviously, the tab contents will only be shown if that tab is selected. + */ +@Component({ + selector: 'core-tabs', + templateUrl: 'tabs.html' +}) +export class CoreTabsComponent implements OnInit, AfterViewInit { + @Input() selectedIndex?: number = 0; // Index of the tab to select. + @Output() ionChange: EventEmitter = new EventEmitter(); // Emitted when the tab changes. + @ViewChild('originalTabs') originalTabsRef: ElementRef; + + tabs: CoreTabComponent[] = []; // List of tabs. + selected: number; // Selected tab number. + protected originalTabsContainer: HTMLElement; // The container of the original tabs. It will include each tab's content. + + constructor() {} + + /** + * Component being initialized. + */ + ngOnInit() { + this.originalTabsContainer = this.originalTabsRef.nativeElement; + } + + /** + * View has been initialized. + */ + ngAfterViewInit() { + let selectedIndex = this.selectedIndex || 0, + selectedTab = this.tabs[selectedIndex]; + + if (!selectedTab.enabled || !selectedTab.show) { + // The tab is not enabled or not shown. Get the first tab that is enabled. + selectedTab = this.tabs.find((tab, index) => { + if (tab.enabled && tab.show) { + selectedIndex = index; + return true; + } + return false; + }); + } + + if (selectedTab) { + this.selectTab(selectedIndex); + } + } + + /** + * Add a new tab if it isn't already in the list of tabs. + * + * @param {CoreTabComponent} tab The tab to add. + */ + addTab(tab: CoreTabComponent) : void { + // Check if tab is already in the list. + if (this.getIndex(tab) == -1) { + this.tabs.push(tab); + this.sortTabs(); + } + } + + /** + * Get the index of tab. + * + * @param {any} tab [description] + * @return {number} [description] + */ + getIndex(tab: any) : number { + for (let i = 0; i < this.tabs.length; i++) { + let t = this.tabs[i]; + if (t === tab || (typeof t.id != 'undefined' && t.id === tab.id)) { + return i; + } + } + return -1; + } + + /** + * Get the current selected tab. + * + * @return {CoreTabComponent} Selected tab. + */ + getSelected() : CoreTabComponent { + return this.tabs[this.selected]; + } + + /** + * Remove a tab from the list of tabs. + * + * @param {CoreTabComponent} tab The tab to remove. + */ + removeTab(tab: CoreTabComponent) : void { + const index = this.getIndex(tab); + this.tabs.splice(index, 1); + } + + /** + * Select a certain tab. + * + * @param {number} index The index of the tab to select. + */ + selectTab(index: number) : void { + if (index == this.selected) { + // Already selected. + return; + } + + if (index < 0 || index >= this.tabs.length) { + // Index isn't valid, select the first one. + index = 0; + } + + const currenTab = this.getSelected(), + newTab = this.tabs[index]; + + if (!newTab.enabled || !newTab.show) { + // The tab isn't enabled or shown, stop. + return; + } + + if (currenTab) { + // Unselect previous selected tab. + currenTab.element.classList.remove('selected'); + } + + this.selected = index; + newTab.element.classList.add('selected'); + newTab.ionSelect.emit(newTab); + this.ionChange.emit(newTab); + } + + /** + * Sort the tabs, keeping the same order as in the original list. + */ + protected sortTabs() { + if (this.originalTabsContainer) { + let newTabs = [], + newSelected; + + this.tabs.forEach((tab, index) => { + let originalIndex = Array.prototype.indexOf.call(this.originalTabsContainer.children, tab.element); + if (originalIndex != -1) { + newTabs[originalIndex] = tab; + if (this.selected == index) { + newSelected = originalIndex; + } + } + }); + + this.tabs = newTabs; + } + } +} diff --git a/src/core/constants.ts b/src/core/constants.ts index 08b8d83e8..519460a68 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -16,33 +16,34 @@ * Static class to contain all the core constants. */ export class CoreConstants { - public static secondsYear = 31536000; - public static secondsDay = 86400; - public static secondsHour = 3600; - public static secondsMinute = 60; - public static wifiDownloadThreshold = 104857600; // 100MB. - public static downloadThreshold = 10485760; // 10MB. - public static dontShowError = 'CoreDontShowError'; - public static noSiteId = 'NoSite'; + public static SECONDS_YEAR = 31536000; + public static SECONDS_WEEK = 604800; + public static SECONDS_DAY = 86400; + public static SECONDS_HOUR = 3600; + public static SECONDS_MINUTE = 60; + public static WIFI_DOWNLOAD_THRESHOLD = 104857600; // 100MB. + public static DOWNLOAD_THRESHOLD = 10485760; // 10MB. + public static DONT_SHOW_ERROR = 'CoreDontShowError'; + public static NO_SITE_ID = 'NoSite'; // Settings constants. - public static settingsRichTextEditor = 'CoreSettingsRichTextEditor'; - public static settingsNotificationSound = 'CoreSettingsNotificationSound'; - public static settingsSyncOnlyOnWifi = 'mmCoreSyncOnlyOnWifi'; + public static SETTINGS_RICH_TEXT_EDITOR = 'CoreSettingsRichTextEditor'; + public static SETTINGS_NOTIFICATION_SOUND = 'CoreSettingsNotificationSound'; + public static SETTINGS_SYNC_ONLY_ON_WIFI = 'CoreSettingsSyncOnlyOnWifi'; // WS constants. - public static wsTimeout = 30000; - public static wsPrefix = 'local_mobile_'; + public static WS_TIMEOUT = 30000; + public static WS_PREFIX = 'local_mobile_'; // Login constants. - public static loginSSOCode = 2; // SSO in browser window is required. - public static loginSSOInAppCode = 3; // SSO in embedded browser is required. - public static loginLaunchData = 'mmLoginLaunchData'; + public static LOGIN_SSO_CODE = 2; // SSO in browser window is required. + public static LOGIN_SSO_INAPP_CODE = 3; // SSO in embedded browser is required. + public static LOGIN_LAUNCH_DATA = 'CoreLoginLaunchData'; // Download status constants. - public static downloaded = 'downloaded'; - public static downloading = 'downloading'; - public static notDownloaded = 'notdownloaded'; - public static outdated = 'outdated'; - public static notDownloadable = 'notdownloadable'; + public static DOWNLOADED = 'downloaded'; + public static DOWNLOADING = 'downloading'; + public static NOT_DOWNLOADED = 'notdownloaded'; + public static OUTDATED = 'outdated'; + public static NOT_DOWNLOADABLE = 'notdownloadable'; } diff --git a/src/core/course/classes/module-prefetch-handler.ts b/src/core/course/classes/module-prefetch-handler.ts new file mode 100644 index 000000000..392296bff --- /dev/null +++ b/src/core/course/classes/module-prefetch-handler.ts @@ -0,0 +1,514 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injector } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreFilepoolProvider } from '../../../providers/filepool'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreCourseProvider } from '../providers/course'; +import { CoreCourseModulePrefetchHandler } from '../providers/module-prefetch-delegate'; +import { CoreConstants } from '../../constants'; + +/** + * A prefetch function to be passed to prefetchPackage. + * This function should NOT call storePackageStatus, downloadPackage or prefetchPakage from filepool. + * It receives the same params as prefetchPackage except the function itself. This includes all extra parameters sent after siteId. + * The string returned by this function will be stored as "extra" data in the filepool package. If you don't need to store + * extra data, don't return anything. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} siteId Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the prefetch finishes. The string returned will be stored as "extra" data in the + * filepool package. If you don't need to store extra data, don't return anything. + */ +export type prefetchFunction = (module: any, courseId: number, single: boolean, siteId: string, ...args) => Promise; + +/** + * Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. It is useful to minimize the amount of + * functions that handlers need to implement. It also provides some helper features like preventing a module to be + * downloaded twice at the same time. + * + * If your handler inherits from this service, you just need to override the functions that you want to change. + * + * The implementation of this default handler is aimed for resources that only need to prefetch files, not WebService calls. + * + * By default, prefetching a module will only download its files (downloadOrPrefetch). This might be enough for resources. + * If you need to prefetch WebServices, then you need to override the "download" and "prefetch" functions. In this case, it's + * recommended to call the prefetchPackage function since it'll handle changing the status of the module. + */ +export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePrefetchHandler { + /** + * A name to identify the addon. + * @type {string} + */ + name = 'CoreCourseModulePrefetchHandlerBase'; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + * @type {string} + */ + modname = ''; + + /** + * The handler's component. + * @type {string} + */ + component = 'core_module'; + + /** + * The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked + * as outdated. This RegExp is ignored if hasUpdates function is defined. + * @type {RegExp} + */ + updatesNames = /^.*files$/; + + /** + * Whether the module is a resource (true) or an activity (false). + * @type {boolean} + */ + isResource: boolean; + + /** + * List of download promises to prevent downloading the module twice at the same time. + * @type {{[s: string]: {[s: string]: Promise}}} + */ + protected downloadPromises: {[s: string]: {[s: string]: Promise}} = {}; + + // List of services that will be injected using injector. It's done like this so subclasses don't have to send all the + // services to the parent in the constructor. + protected translate: TranslateService; + protected appProvider: CoreAppProvider; + protected courseProvider: CoreCourseProvider; + protected filepoolProvider: CoreFilepoolProvider; + protected sitesProvider: CoreSitesProvider; + protected domUtils: CoreDomUtilsProvider; + protected utils: CoreUtilsProvider; + + constructor(injector: Injector) { + this.translate = injector.get(TranslateService); + this.appProvider = injector.get(CoreAppProvider); + this.courseProvider = injector.get(CoreCourseProvider); + this.filepoolProvider = injector.get(CoreFilepoolProvider); + this.sitesProvider = injector.get(CoreSitesProvider); + this.domUtils = injector.get(CoreDomUtilsProvider); + this.utils = injector.get(CoreUtilsProvider); + } + + /** + * Add an ongoing download to the downloadPromises list. When the promise finishes it will be removed. + * + * @param {number} id Unique identifier per component. + * @param {Promise} promise Promise to add. + * @param {String} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise of the current download. + */ + addOngoingDownload(id: number, promise: Promise, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const uniqueId = this.getUniqueId(id); + + if (!this.downloadPromises[siteId]) { + this.downloadPromises[siteId] = {}; + } + + this.downloadPromises[siteId][uniqueId] = promise.finally(() => { + delete this.downloadPromises[siteId][uniqueId]; + }); + + return this.downloadPromises[siteId][uniqueId]; + } + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number) : Promise { + return this.downloadOrPrefetch(module, courseId, false); + } + + /** + * Download or prefetch the content. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {boolean} [prefetch] True to prefetch, false to download right away. + * @param {string} [dirPath] Path of the directory where to store all the content files. This is to keep the files + * relative paths and make the package work in an iframe. Undefined to download the files + * in the filepool root folder. + * @return {Promise} Promise resolved when all content is downloaded. Data returned is not reliable. + */ + downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string) : Promise { + if (!this.appProvider.isOnline()) { + // Cannot download in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + const siteId = this.sitesProvider.getCurrentSiteId(); + + // Load module contents (ignore cache so we always have the latest data). + return this.loadContents(module, courseId, true).then(() => { + // Get the intro files. + return this.getIntroFiles(module, courseId); + }).then((introFiles) => { + let downloadFn = prefetch ? this.filepoolProvider.prefetchPackage.bind(this.filepoolProvider) : + this.filepoolProvider.downloadPackage.bind(this.filepoolProvider), + contentFiles = this.getContentDownloadableFiles(module), + promises = []; + + if (dirPath) { + // Download intro files in filepool root folder. + promises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, introFiles, prefetch, false, + this.component, module.id)); + + // Download content files inside dirPath. + promises.push(downloadFn(siteId, contentFiles, this.component, module.id, undefined, dirPath)); + } else { + // No dirPath, download everything in filepool root folder. + let files = introFiles.concat(contentFiles); + promises.push(downloadFn(siteId, files, this.component, module.id)); + } + + return Promise.all(promises); + }); + } + + /** + * Returns a list of content files that can be downloaded. + * + * @param {any} module The module object returned by WS. + * @return {any[]} List of files. + */ + getContentDownloadableFiles(module: any) { + let files = []; + + if (module.contents && module.contents.length) { + module.contents.forEach((content) => { + if (this.isFileDownloadable(content)) { + files.push(content); + } + }); + } + + return files; + } + + /** + * Get the download size of a module. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(module: any, courseId: number, single?: boolean) : Promise<{size: number, total: boolean}> { + return this.getFiles(module, courseId).then((files) => { + return this.utils.sumFileSizes(files); + }).catch(() => { + return {size: -1, total: false}; + }); + } + + /** + * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {number|Promise} Size, or promise resolved with the size. + */ + getDownloadedSize?(module: any, courseId: number) : number|Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.getFilesSizeByComponent(siteId, this.component, module.id); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean) : Promise { + // Load module contents if needed. + return this.loadContents(module, courseId).then(() => { + return this.getIntroFiles(module, courseId).then((files) => { + return files.concat(this.getContentDownloadableFiles(module)); + }); + }); + } + + /** + * Returns module intro files. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved with list of intro files. + */ + getIntroFiles(module: any, courseId: number) : Promise { + return Promise.resolve(this.getIntroFilesFromInstance(module)); + } + + /** + * Returns module intro files from instance. + * + * @param {any} module The module object returned by WS. + * @param {any} [instance] The instance to get the intro files (book, assign, ...). If not defined, module will be used. + * @return {any[]} List of intro files. + */ + getIntroFilesFromInstance(module: any, instance?: any) { + if (instance) { + if (typeof instance.introfiles != 'undefined') { + return instance.introfiles; + } else if (instance.intro) { + return this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(instance.intro); + } + } + + if (module.description) { + return this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(module.description); + } + + return []; + } + + /** + * If there's an ongoing download for a certain identifier return it. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise of the current download. + */ + getOngoingDownload(id: number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.isDownloading(id, siteId)) { + // There's already a download ongoing, return the promise. + return this.downloadPromises[siteId][this.getUniqueId(id)]; + } + return Promise.resolve(); + } + + /** + * Create unique identifier using component and id. + * + * @param {number} id Unique ID inside component. + * @return {string} Unique ID. + */ + getUniqueId(id: number) { + return this.component + '#' + id; + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number) : Promise { + const promises = [], + siteId = this.sitesProvider.getCurrentSiteId(); + + promises.push(this.courseProvider.invalidateModule(moduleId)); + promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, this.component, moduleId)); + + return Promise.all(promises); + } + + /** + * Invalidate WS calls needed to determine module status. It doesn't need to invalidate check updates. + * It should NOT invalidate files nor all the prefetched data. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number) : Promise { + return this.courseProvider.invalidateModule(module.id); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable(module: any, courseId: number) : boolean|Promise { + // By default, mark all instances as downloadable. + return true; + } + + /** + * Check if a there's an ongoing download for the given identifier. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Boolean} True if downloading, false otherwise. + */ + isDownloading(id: number, siteId?: string) : boolean { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return !!(this.downloadPromises[siteId] && this.downloadPromises[siteId][this.getUniqueId(id)]); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled() : boolean|Promise { + return true; + } + + /** + * Check if a file is downloadable. + * + * @param {any} file File to check. + * @return {boolean} Whether the file is downloadable. + */ + isFileDownloadable(file: any) : boolean { + return file.type === 'file'; + } + + /** + * Load module contents into module.contents if they aren't loaded already. + * + * @param {any} module Module to load the contents. + * @param {number} [courseId] The course ID. Recommended to speed up the process and minimize data usage. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @return {Promise} Promise resolved when loaded. + */ + loadContents(module: any, courseId: number, ignoreCache?: boolean) : Promise { + if (this.isResource) { + return this.courseProvider.loadModuleContents(module, courseId, undefined, false, ignoreCache); + } + return Promise.resolve(); + } + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean): Promise { + return this.downloadOrPrefetch(module, courseId, true); + } + + /** + * Prefetch the module, setting package status at start and finish. + * + * Example usage from a child instance: + * return this.prefetchPackage(module, courseId, single, this.prefetchModule.bind(this), siteId, someParam, anotherParam); + * + * Then the function "prefetchModule" will receive params: + * prefetchModule(module, courseId, single, siteId, someParam, anotherParam) + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {prefetchFunction} downloadFn Function to perform the prefetch. Please check the documentation of prefetchFunction. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the module has been downloaded. Data returned is not reliable. + */ + prefetchPackage(module: any, courseId: number, single: boolean, downloadFn: prefetchFunction, siteId?: string, ...args) : + Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!this.appProvider.isOnline()) { + // Cannot prefetch in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + if (this.isDownloading(module.id, siteId)) { + // There's already a download ongoing for this module, return the promise. + return this.getOngoingDownload(module.id, siteId); + } + + const prefetchPromise = this.setDownloading(module.id, siteId).then(() => { + // Package marked as downloading, call the download function. + // Send all the params except downloadFn. This includes all params passed after siteId. + return downloadFn.apply(downloadFn, [module, courseId, single, siteId].concat(args)); + }).then((extra: string) => { + // Prefetch finished, mark as downloaded. + return this.setDownloaded(module.id, siteId, extra); + }).catch((error) => { + // Error prefetching, go back to previous status and reject the promise. + return this.setPreviousStatusAndReject(module.id, error, siteId); + }); + + return this.addOngoingDownload(module.id, prefetchPromise, siteId); + } + + /** + * Mark the module as downloaded. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {string} [extra] Extra data to store. + * @return {Promise} Promise resolved when done. + */ + setDownloaded(id: number, siteId?: string, extra?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.storePackageStatus(siteId, CoreConstants.DOWNLOADED, this.component, id, extra); + } + + /** + * Mark the module as downloading. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + setDownloading(id: number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.storePackageStatus(siteId, CoreConstants.DOWNLOADING, this.component, id); + } + + /** + * Set previous status and return a rejected promise. + * + * @param {number} id Unique identifier per component. + * @param {any} [error] Error to return. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Rejected promise. + */ + setPreviousStatusAndReject(id: number, error?: any, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.setPackagePreviousStatus(siteId, this.component, id).then(() => { + return Promise.reject(error); + }); + } + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + removeFiles(module: any, courseId: number) : Promise { + return this.filepoolProvider.removeFilesByComponent(this.sitesProvider.getCurrentSiteId(), this.component, module.id); + } +} diff --git a/src/core/course/components/components.module.ts b/src/core/course/components/components.module.ts new file mode 100644 index 000000000..ab679c5f3 --- /dev/null +++ b/src/core/course/components/components.module.ts @@ -0,0 +1,55 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../components/components.module'; +import { CoreDirectivesModule } from '../../../directives/directives.module'; +import { CoreCourseFormatComponent } from './format/format'; +import { CoreCourseModuleComponent } from './module/module'; +import { CoreCourseModuleCompletionComponent } from './module-completion/module-completion'; +import { CoreCourseModuleDescriptionComponent } from './module-description/module-description'; +import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsupported-module'; + +@NgModule({ + declarations: [ + CoreCourseFormatComponent, + CoreCourseModuleComponent, + CoreCourseModuleCompletionComponent, + CoreCourseModuleDescriptionComponent, + CoreCourseUnsupportedModuleComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + ], + exports: [ + CoreCourseFormatComponent, + CoreCourseModuleComponent, + CoreCourseModuleCompletionComponent, + CoreCourseModuleDescriptionComponent, + CoreCourseUnsupportedModuleComponent + ], + entryComponents: [ + CoreCourseUnsupportedModuleComponent + ] +}) +export class CoreCourseComponentsModule {} diff --git a/src/core/course/components/format/format.html b/src/core/course/components/format/format.html new file mode 100644 index 000000000..60289795a --- /dev/null +++ b/src/core/course/components/format/format.html @@ -0,0 +1,83 @@ + +
+ + + + + + + + + + +
+ + + {{section.formattedName || section.name}} + + + +
+ + + +
+ + + + + +
+ + +
+ + + + + + +
+
+
+ + + +
+ + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + + + {{section.count}} / {{section.total}} +
+
+ + + \ No newline at end of file diff --git a/src/core/course/components/format/format.ts b/src/core/course/components/format/format.ts new file mode 100644 index 000000000..f6ff16cdb --- /dev/null +++ b/src/core/course/components/format/format.ts @@ -0,0 +1,319 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, OnChanges, OnDestroy, ViewContainerRef, ComponentFactoryResolver, ViewChild, ChangeDetectorRef, + SimpleChange, Output, EventEmitter } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreLoggerProvider } from '../../../../providers/logger'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCourseProvider } from '../../../course/providers/course'; +import { CoreCourseHelperProvider } from '../../../course/providers/helper'; +import { CoreCourseFormatDelegate } from '../../../course/providers/format-delegate'; +import { CoreCourseModulePrefetchDelegate } from '../../../course/providers/module-prefetch-delegate'; + +/** + * Component to display course contents using a certain format. If the format isn't found, use default one. + * + * The inputs of this component will be shared with the course format components. Please use CoreCourseFormatDelegate + * to register your handler for course formats. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-format', + templateUrl: 'format.html' +}) +export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { + @Input() course: any; // The course to render. + @Input() sections: any[]; // List of course sections. + @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + @Output() completionChanged?: EventEmitter; // Will emit an event when any module completion changes. + + // Get the containers where to inject dynamic components. We use a setter because they might be inside a *ngIf. + @ViewChild('courseFormat', { read: ViewContainerRef }) set courseFormat(el: ViewContainerRef) { + if (this.course) { + this.createComponent('courseFormat', this.cfDelegate.getCourseFormatComponent(this.course), el); + } else { + // The component hasn't been initialized yet. Store the container. + this.componentContainers['courseFormat'] = el; + } + }; + @ViewChild('courseSummary', { read: ViewContainerRef }) set courseSummary(el: ViewContainerRef) { + this.createComponent('courseSummary', this.cfDelegate.getCourseSummaryComponent(this.course), el); + }; + @ViewChild('sectionSelector', { read: ViewContainerRef }) set sectionSelector(el: ViewContainerRef) { + this.createComponent('sectionSelector', this.cfDelegate.getSectionSelectorComponent(this.course), el); + }; + @ViewChild('singleSection', { read: ViewContainerRef }) set singleSection(el: ViewContainerRef) { + this.createComponent('singleSection', this.cfDelegate.getSingleSectionComponent(this.course), el); + }; + @ViewChild('allSections', { read: ViewContainerRef }) set allSections(el: ViewContainerRef) { + this.createComponent('allSections', this.cfDelegate.getAllSectionsComponent(this.course), el); + }; + + // Instances and containers of all the components that the handler could define. + protected componentContainers: {[type: string]: ViewContainerRef} = {}; + componentInstances: {[type: string]: any} = {}; + + displaySectionSelector: boolean; + selectedSection: any; + allSectionsId: number = CoreCourseProvider.ALL_SECTIONS_ID; + selectOptions: any = {}; + loaded: boolean; + + protected logger; + protected sectionStatusObserver; + + constructor(logger: CoreLoggerProvider, private cfDelegate: CoreCourseFormatDelegate, translate: TranslateService, + private factoryResolver: ComponentFactoryResolver, private cdr: ChangeDetectorRef, + private courseHelper: CoreCourseHelperProvider, private domUtils: CoreDomUtilsProvider, + eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, + prefetchDelegate: CoreCourseModulePrefetchDelegate) { + + this.logger = logger.getInstance('CoreCourseFormatComponent'); + this.selectOptions.title = translate.instant('core.course.sections'); + this.completionChanged = new EventEmitter(); + + // Listen for section status changes. + this.sectionStatusObserver = eventsProvider.on(CoreEventsProvider.SECTION_STATUS_CHANGED, (data) => { + if (this.downloadEnabled && this.sections && this.sections.length && this.course && data.sectionId && + data.courseId == this.course.id) { + // Check if the affected section is being downloaded. If so, we don't update section status + // because it'll already be updated when the download finishes. + let downloadId = this.courseHelper.getSectionDownloadId({id: data.sectionId}); + if (prefetchDelegate.isBeingDownloaded(downloadId)) { + return; + } + + // Get the affected section. + let section; + for (let i = 0; i < this.sections.length; i++) { + let s = this.sections[i]; + if (s.id === data.sectionId) { + section = s; + break; + } + } + + if (!section) { + // Section not found, stop. + return; + } + + // Recalculate the status. + this.courseHelper.calculateSectionStatus(section, this.course.id, false).then(() => { + if (section.isDownloading && !prefetchDelegate.isBeingDownloaded(downloadId)) { + // All the modules are now downloading, set a download all promise. + this.prefetch(section, false); + } + }); + } + }, this.sitesProvider.getCurrentSiteId()); + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.displaySectionSelector = this.cfDelegate.displaySectionSelector(this.course); + + this.createComponent( + 'courseFormat', this.cfDelegate.getCourseFormatComponent(this.course), this.componentContainers['courseFormat']); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes.sections && this.sections) { + if (!this.selectedSection) { + // There is no selected section yet, calculate which one to get. + this.cfDelegate.getCurrentSection(this.course, this.sections).then((section) => { + this.loaded = true; + this.sectionChanged(section); + }); + } else { + // We have a selected section, but the list has changed. Search the section in the list. + let newSection; + for (let i = 0; i < this.sections.length; i++) { + let section = this.sections[i]; + if (this.compareSections(section, this.selectedSection)) { + newSection = section; + break; + } + } + + if (!newSection) { + // Section not found, calculate which one to use. + newSection = this.cfDelegate.getCurrentSection(this.course, this.sections); + } + this.sectionChanged(newSection); + } + } + + if (changes.downloadEnabled && this.downloadEnabled) { + this.calculateSectionsStatus(false); + } + + // Apply the changes to the components and call ngOnChanges if it exists. + for (let type in this.componentInstances) { + let instance = this.componentInstances[type]; + + for (let name in changes) { + instance[name] = changes[name].currentValue; + } + + if (instance.ngOnChanges) { + instance.ngOnChanges(changes); + } + } + } + + /** + * Create a component, add it to a container and set the input data. + * + * @param {string} type The "type" of the component. + * @param {any} componentClass The class of the component to create. + * @param {ViewContainerRef} container The container to add the component to. + * @return {boolean} Whether the component was successfully created. + */ + protected createComponent(type: string, componentClass: any, container: ViewContainerRef) : boolean { + if (!componentClass || !container) { + // No component to instantiate or container doesn't exist right now. + return false; + } + + if (this.componentInstances[type] && container === this.componentContainers[type]) { + // Component already instantiated and the component hasn't been destroyed, nothing to do. + return true; + } + + try { + // Create the component and add it to the container. + const factory = this.factoryResolver.resolveComponentFactory(componentClass), + componentRef = container.createComponent(factory); + + this.componentContainers[type] = container; + this.componentInstances[type] = componentRef.instance; + + // Set the Input data. + this.componentInstances[type].course = this.course; + this.componentInstances[type].sections = this.sections; + this.componentInstances[type].downloadEnabled = this.downloadEnabled; + + this.cdr.detectChanges(); // The instances are used in ngIf, tell Angular that something has changed. + + return true; + } catch(ex) { + this.logger.error('Error creating component', type, ex); + return false; + } + } + + /** + * Function called when selected section changes. + * + * @param {any} newSection The new selected section. + */ + sectionChanged(newSection: any) { + let previousValue = this.selectedSection; + this.selectedSection = newSection; + + // If there is a component to render the current section, update its section. + if (this.componentInstances.singleSection) { + this.componentInstances.singleSection.section = this.selectedSection; + if (this.componentInstances.singleSection.ngOnChanges) { + this.componentInstances.singleSection.ngOnChanges({ + section: new SimpleChange(previousValue, newSection, typeof previousValue != 'undefined') + }); + } + } + } + + /** + * Compare if two sections are equal. + * + * @param {any} s1 First section. + * @param {any} s2 Second section. + * @return {boolean} Whether they're equal. + */ + compareSections(s1: any, s2: any) : boolean { + return s1 && s2 ? s1.id === s2.id : s1 === s2; + } + + /** + * Calculate the status of sections. + * + * @param {boolean} refresh [description] + */ + protected calculateSectionsStatus(refresh?: boolean) : void { + this.courseHelper.calculateSectionsStatus(this.sections, this.course.id, refresh).catch(() => { + // Ignore errors (shouldn't happen). + }); + } + + /** + * Confirm and prefetch a section. If the section is "all sections", prefetch all the sections. + * + * @param {Event} e Click event. + * @param {any} section Section to download. + */ + prefetch(e: Event, section: any) : void { + e.preventDefault(); + e.stopPropagation(); + + section.isCalculating = true; + this.courseHelper.confirmDownloadSizeSection(this.course.id, section, this.sections).then(() => { + this.prefetchSection(section, true); + }, (error) => { + // User cancelled or there was an error calculating the size. + if (error) { + this.domUtils.showErrorModal(error); + } + }).finally(() => { + section.isCalculating = false; + }); + } + + /** + * Prefetch a section. + * + * @param {any} section The section to download. + * @param {boolean} [manual] Whether the prefetch was started manually or it was automatically started because all modules + * are being downloaded. + */ + protected prefetchSection(section: any, manual?: boolean) { + this.courseHelper.prefetchSection(section, this.course.id, this.sections).catch((error) => { + // Don't show error message if it's an automatic download. + if (!manual) { + return; + } + + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + if (this.sectionStatusObserver) { + this.sectionStatusObserver.off(); + } + } +} diff --git a/src/core/course/components/module-completion/module-completion.html b/src/core/course/components/module-completion/module-completion.html new file mode 100644 index 000000000..7de6d78d5 --- /dev/null +++ b/src/core/course/components/module-completion/module-completion.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/core/course/components/module-completion/module-completion.scss b/src/core/course/components/module-completion/module-completion.scss new file mode 100644 index 000000000..b0b4a663c --- /dev/null +++ b/src/core/course/components/module-completion/module-completion.scss @@ -0,0 +1,7 @@ +core-course-module-completion a { + img { + padding: 5px; + width: 30px; + vertical-align: middle; + } +} \ No newline at end of file diff --git a/src/core/course/components/module-completion/module-completion.ts b/src/core/course/components/module-completion/module-completion.ts new file mode 100644 index 000000000..1bd9e99a2 --- /dev/null +++ b/src/core/course/components/module-completion/module-completion.ts @@ -0,0 +1,150 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; + +/** + * Component to handle activity completion. It shows a checkbox with the current status, and allows manually changing + * the completion if it's allowed. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-module-completion', + templateUrl: 'module-completion.html' +}) +export class CoreCourseModuleCompletionComponent implements OnChanges { + @Input() completion: any; // The completion status. + @Input() moduleName?: string; // The name of the module this completion affects. + @Output() completionChanged?: EventEmitter; // Will emit an event when the completion changes. + + completionImage: string; + completionDescription: string; + + constructor(private textUtils: CoreTextUtilsProvider, private translate: TranslateService, + private domUtils: CoreDomUtilsProvider, private sitesProvider: CoreSitesProvider) { + this.completionChanged = new EventEmitter(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes.completion && this.completion) { + this.showStatus(); + } + } + + /** + * Completion clicked. + * + * @param {Event} e The click event. + */ + completionClicked(e: Event) : void { + if (this.completion) { + if (typeof this.completion.cmid == 'undefined' || this.completion.tracking !== 1) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + let modal = this.domUtils.showModalLoading(), + params = { + cmid: this.completion.cmid, + completed: this.completion.state === 1 ? 0 : 1 + }, + currentSite = this.sitesProvider.getCurrentSite(); + + currentSite.write('core_completion_update_activity_completion_status_manually', params).then((response) => { + if (!response.status) { + return Promise.reject(null); + } + + this.completionChanged.emit(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errorchangecompletion', true); + }).finally(() => { + modal.dismiss(); + }); + } + } + + /** + * Set image and description to show as completion icon. + */ + protected showStatus() : void { + let langKey, + moduleName = this.moduleName || '', + image; + + if (this.completion.tracking === 1 && this.completion.state === 0) { + image = 'completion-manual-n'; + langKey = 'core.completion-alt-manual-n'; + } else if (this.completion.tracking === 1 && this.completion.state === 1) { + image = 'completion-manual-y'; + langKey = 'core.completion-alt-manual-y'; + } else if (this.completion.tracking === 2 && this.completion.state === 0) { + image = 'completion-auto-n'; + langKey = 'core.completion-alt-auto-n'; + } else if (this.completion.tracking === 2 && this.completion.state === 1) { + image = 'completion-auto-y'; + langKey = 'core.completion-alt-auto-y'; + } else if (this.completion.tracking === 2 && this.completion.state === 2) { + image = 'completion-auto-pass'; + langKey = 'core.completion-alt-auto-pass'; + } else if (this.completion.tracking === 2 && this.completion.state === 3) { + image = 'completion-auto-fail'; + langKey = 'core.completion-alt-auto-fail'; + } + + if (image) { + if (this.completion.overrideby > 0) { + image += '-override'; + } + this.completionImage = 'assets/img/completion/' + image + '.svg'; + } + + if (moduleName) { + this.textUtils.formatText(moduleName, true, true, 50).then((modNameFormatted) => { + let promise; + + if (this.completion.overrideby > 0) { + langKey += '-override'; + + // @todo: Get user profile. + // promise = $mmUser.getProfile(scope.completion.overrideby, scope.completion.courseId, true).then(function(profile) { + // return { + // overrideuser: profile.fullname, + // modname: modNameFormatted + // }; + // }); + } else { + promise = Promise.resolve(modNameFormatted); + } + + return promise.then((translateParams) => { + this.completionDescription = this.translate.instant(langKey, {$a: translateParams}); + }); + }); + } + } +} diff --git a/src/core/course/components/module-description/module-description.html b/src/core/course/components/module-description/module-description.html new file mode 100644 index 000000000..8ccd37fc2 --- /dev/null +++ b/src/core/course/components/module-description/module-description.html @@ -0,0 +1,6 @@ + + + + {{ note }} + + \ No newline at end of file diff --git a/src/core/course/components/module-description/module-description.ts b/src/core/course/components/module-description/module-description.ts new file mode 100644 index 000000000..d10994df4 --- /dev/null +++ b/src/core/course/components/module-description/module-description.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; + +/** + * Component to display the description of a module. + * + * This directive is meant to display a module description in a similar way throughout all the app. + * + * You can add a note at the right side of the description by using the 'note' attribute. + * + * You can also pass a component and componentId to be used in format-text. + * + * Module descriptions are shortened by default, allowing the user to see the full description by clicking in it. + * If you want the whole description to be shown you can use the 'showFull' attribute. + * + * Example usage: + * + * + + + + + +
+ + + + + +
+ +
+ {{ 'core.course.hiddenfromstudents' | translate }} + +
+ + \ No newline at end of file diff --git a/src/core/course/components/module/module.scss b/src/core/course/components/module/module.scss new file mode 100644 index 000000000..8eeee189e --- /dev/null +++ b/src/core/course/components/module/module.scss @@ -0,0 +1,30 @@ +core-course-module { + + a.core-course-module-handler { + align-items: flex-start; + item-inner { + padding-right: 0; + } + } + + .core-module-icon { + align-items: flex-start; + } + + .core-module-buttons { + display: flex; + flex-flow: row; + align-items: center; + z-index: 1; + cursor: pointer; + pointer-events: auto; + position: absolute; + right: 0; + top: 4px; + + .spinner { + right: 7px; + position: absolute; + } + } +} \ No newline at end of file diff --git a/src/core/course/components/module/module.ts b/src/core/course/components/module/module.ts new file mode 100644 index 000000000..1353c4d64 --- /dev/null +++ b/src/core/course/components/module/module.ts @@ -0,0 +1,71 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreCourseModuleHandlerButton } from '../../providers/module-delegate'; + +/** + * Component to display a module entry in a list of modules. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-module', + templateUrl: 'module.html' +}) +export class CoreCourseModuleComponent implements OnInit { + @Input() module: any; // The module to render. + @Input() courseId: number; // The course the module belongs to. + @Output() completionChanged?: EventEmitter; // Will emit an event when the module completion changes. + + constructor(private navCtrl: NavController) { + this.completionChanged = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit() { + // Handler data must be defined. If it isn't, set it to prevent errors. + if (this.module && !this.module.handlerData) { + this.module.handlerData = {}; + } + } + + /** + * Function called when the module is clicked. + * + * @param {Event} event Click event. + */ + moduleClicked(event: Event) { + if (this.module.uservisible !== false && this.module.handlerData.action) { + this.module.handlerData.action(event, this.navCtrl, this.module, this.courseId); + } + } + + /** + * Function called when a button is clicked. + * + * @param {Event} event Click event. + * @param {CoreCourseModuleHandlerButton} button The clicked button. + */ + buttonClicked(event: Event, button: CoreCourseModuleHandlerButton) { + if (button && button.action) { + button.action(event, this.navCtrl, this.module, this.courseId); + } + } +} diff --git a/src/core/course/components/unsupported-module/unsupported-module.html b/src/core/course/components/unsupported-module/unsupported-module.html new file mode 100644 index 000000000..775aff655 --- /dev/null +++ b/src/core/course/components/unsupported-module/unsupported-module.html @@ -0,0 +1,18 @@ +
+ +

{{ 'core.whoops' | translate }}

+

{{ 'core.uhoh' | translate }}

+ +

{{ 'core.course.activitydisabled' | translate }}

+

{{ 'core.course.activitynotyetviewablesiteupgradeneeded' | translate }}

+

{{ 'core.course.activitynotyetviewableremoteaddon' | translate }}

+

{{ 'core.course.askadmintosupport' | translate }}

+ +
+

{{ 'core.course.useactivityonbrowser' | translate }}

+ + {{ 'core.openinbrowser' | translate }} + + +
+
\ No newline at end of file diff --git a/src/core/course/components/unsupported-module/unsupported-module.ts b/src/core/course/components/unsupported-module/unsupported-module.ts new file mode 100644 index 000000000..1c1910d87 --- /dev/null +++ b/src/core/course/components/unsupported-module/unsupported-module.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreCourseProvider } from '../../providers/course'; +import { CoreCourseModuleDelegate } from '../../providers/module-delegate'; + +/** + * Component that displays info about an unsupported module. + */ +@Component({ + selector: 'core-course-unsupported-module', + templateUrl: 'unsupported-module.html', +}) +export class CoreCourseUnsupportedModuleComponent implements OnInit { + @Input() course: any; // The course to module belongs to. + @Input() module: any; // The module to render. + + isDisabledInSite: boolean; + isSupportedByTheApp: boolean; + moduleName: string; + + constructor(navParams: NavParams, private translate: TranslateService, private textUtils: CoreTextUtilsProvider, + private courseProvider: CoreCourseProvider, private moduleDelegate: CoreCourseModuleDelegate) {} + + /** + * Component being initialized. + */ + ngOnInit() { + this.isDisabledInSite = this.moduleDelegate.isModuleDisabledInSite(this.module.modname); + this.isSupportedByTheApp = this.moduleDelegate.hasHandler(this.module.modname); + this.moduleName = this.courseProvider.translateModuleName(this.module.modname); + } +} diff --git a/src/core/course/course.module.ts b/src/core/course/course.module.ts new file mode 100644 index 000000000..1c74f500a --- /dev/null +++ b/src/core/course/course.module.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreCourseProvider } from './providers/course'; +import { CoreCourseHelperProvider } from './providers/helper'; +import { CoreCourseFormatDelegate } from './providers/format-delegate'; +import { CoreCourseModuleDelegate } from './providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from './providers/module-prefetch-delegate'; +import { CoreCourseFormatDefaultHandler } from './providers/default-format'; +import { CoreCourseFormatSingleActivityModule } from './formats/singleactivity/singleactivity.module'; +import { CoreCourseFormatSocialModule } from './formats/social/social.module'; +import { CoreCourseFormatTopicsModule} from './formats/topics/topics.module'; +import { CoreCourseFormatWeeksModule } from './formats/weeks/weeks.module'; + +@NgModule({ + declarations: [], + imports: [ + CoreCourseFormatSingleActivityModule, + CoreCourseFormatTopicsModule, + CoreCourseFormatWeeksModule, + CoreCourseFormatSocialModule + ], + providers: [ + CoreCourseProvider, + CoreCourseHelperProvider, + CoreCourseFormatDelegate, + CoreCourseModuleDelegate, + CoreCourseModulePrefetchDelegate, + CoreCourseFormatDefaultHandler + ], + exports: [] +}) +export class CoreCourseModule {} diff --git a/src/core/course/formats/singleactivity/components/format.ts b/src/core/course/formats/singleactivity/components/format.ts new file mode 100644 index 000000000..571f258f0 --- /dev/null +++ b/src/core/course/formats/singleactivity/components/format.ts @@ -0,0 +1,119 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnChanges, ViewContainerRef, ComponentFactoryResolver, ChangeDetectorRef, + SimpleChange } from '@angular/core'; +import { CoreLoggerProvider } from '../../../../../providers/logger'; +import { CoreCourseModuleDelegate } from '../../../providers/module-delegate'; +import { CoreCourseUnsupportedModuleComponent } from '../../../components/unsupported-module/unsupported-module'; + +/** + * Component to display single activity format. It will determine the right component to use and instantiate it. + * + * The instantiated component will receive the course and the module as inputs. + */ +@Component({ + selector: 'core-course-format-single-activity', + template: '' +}) +export class CoreCourseFormatSingleActivityComponent implements OnChanges { + @Input() course: any; // The course to render. + @Input() sections: any[]; // List of course sections. + @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + + protected logger: any; + protected module: any; + protected componentInstance: any; + + constructor(logger: CoreLoggerProvider, private viewRef: ViewContainerRef, private factoryResolver: ComponentFactoryResolver, + private cdr: ChangeDetectorRef, private moduleDelegate: CoreCourseModuleDelegate) { + this.logger = logger.getInstance('CoreCourseFormatSingleActivityComponent'); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (this.course && this.sections && this.sections.length) { + // In single activity the module should only have 1 section and 1 module. Get the module. + let module = this.sections[0] && this.sections[0].modules && this.sections[0].modules[0]; + if (module && !this.componentInstance) { + // We haven't created the component yet. Create it now. + this.createComponent(module); + } + + if (this.componentInstance && this.componentInstance.ngOnChanges) { + // Call ngOnChanges of the component. + let newChanges: {[name: string]: SimpleChange} = {}; + + // Check if course has changed. + if (changes.course) { + newChanges.course = changes.course + this.componentInstance.course = this.course; + } + + // Check if module has changed. + if (changes.sections && module != this.module) { + newChanges.module = { + currentValue: module, + firstChange: changes.sections.firstChange, + previousValue: this.module, + isFirstChange: () => { + return newChanges.module.firstChange; + } + }; + this.componentInstance.module = module; + this.module = module; + } + + if (Object.keys(newChanges).length) { + this.componentInstance.ngOnChanges(newChanges); + } + } + } + } + + /** + * Create the component, add it to the container and set the input data. + * + * @param {any} module The module. + * @return {boolean} Whether the component was successfully created. + */ + protected createComponent(module: any) : boolean { + let componentClass = this.moduleDelegate.getMainComponent(this.course, module) || CoreCourseUnsupportedModuleComponent; + if (!componentClass) { + // No component to instantiate. + return false; + } + + try { + // Create the component and add it to the container. + const factory = this.factoryResolver.resolveComponentFactory(componentClass), + componentRef = this.viewRef.createComponent(factory); + + this.componentInstance = componentRef.instance; + + // Set the Input data. + this.componentInstance.courseId = this.course.id; + this.componentInstance.module = module; + + // this.cdr.detectChanges(); // The instances are used in ngIf, tell Angular that something has changed. + + return true; + } catch(ex) { + this.logger.error('Error creating component', ex); + return false; + } + } +} diff --git a/src/core/course/formats/singleactivity/providers/handler.ts b/src/core/course/formats/singleactivity/providers/handler.ts new file mode 100644 index 000000000..396c94971 --- /dev/null +++ b/src/core/course/formats/singleactivity/providers/handler.ts @@ -0,0 +1,83 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseFormatHandler } from '../../../providers/format-delegate'; +import { CoreCourseFormatSingleActivityComponent } from '../components/format'; + +/** + * Handler to support singleactivity course format. + */ +@Injectable() +export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHandler { + name = 'singleactivity'; + + constructor() {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled() : boolean|Promise { + return true; + } + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param {any} course The course to check. + * @type {boolean} Whether it can view all sections. + */ + canViewAllSections(course: any) : boolean { + return false; + } + + /** + * Get the title to use in course page. If not defined, course fullname. + * This function will be called without sections first, and then call it again when the sections are retrieved. + * + * @param {any} course The course. + * @param {any[]} [sections] List of sections. + * @return {string} Title. + */ + getCourseTitle(course: any, sections?: any[]) : string { + if (sections && sections[0] && sections[0].modules && sections[0].modules[0]) { + return sections[0].modules[0].name; + } + return course.fullname || ''; + } + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @type {boolean} Whether the default section selector should be displayed. + */ + displaySectionSelector(course: any) : boolean { + return false; + } + + /** + * Return the Component to use to display the course format instead of using the default one. + * Use it if you want to display a format completely different from the default one. + * If you want to customize the default format there are several methods to customize parts of it. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getCourseFormatComponent(course: any) : any { + return CoreCourseFormatSingleActivityComponent; + } +} diff --git a/src/core/course/formats/singleactivity/singleactivity.module.ts b/src/core/course/formats/singleactivity/singleactivity.module.ts new file mode 100644 index 000000000..0ccb98388 --- /dev/null +++ b/src/core/course/formats/singleactivity/singleactivity.module.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreCourseFormatSingleActivityComponent } from './components/format'; +import { CoreCourseFormatSingleActivityHandler } from './providers/handler'; +import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; + +@NgModule({ + declarations: [ + CoreCourseFormatSingleActivityComponent + ], + imports: [ + ], + providers: [ + CoreCourseFormatSingleActivityHandler + ], + exports: [ + CoreCourseFormatSingleActivityComponent + ], + entryComponents: [ + CoreCourseFormatSingleActivityComponent + ] +}) +export class CoreCourseFormatSingleActivityModule { + constructor(formatDelegate: CoreCourseFormatDelegate, handler: CoreCourseFormatSingleActivityHandler) { + formatDelegate.registerHandler(handler); + } +} diff --git a/src/core/course/formats/social/providers/handler.ts b/src/core/course/formats/social/providers/handler.ts new file mode 100644 index 000000000..25d942515 --- /dev/null +++ b/src/core/course/formats/social/providers/handler.ts @@ -0,0 +1,25 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseFormatSingleActivityHandler } from '../../singleactivity/providers/handler'; + +/** + * Handler to support social course format. + * This format is like singleactivity in the mobile app, so we'll use the same implementation for both. + */ +@Injectable() +export class CoreCourseFormatSocialHandler extends CoreCourseFormatSingleActivityHandler { + name = 'social'; +} diff --git a/src/core/course/formats/social/social.module.ts b/src/core/course/formats/social/social.module.ts new file mode 100644 index 000000000..0ee3677fa --- /dev/null +++ b/src/core/course/formats/social/social.module.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreCourseFormatSocialHandler } from './providers/handler'; +import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; + +@NgModule({ + declarations: [], + imports: [ + ], + providers: [ + CoreCourseFormatSocialHandler + ], + exports: [] +}) +export class CoreCourseFormatSocialModule { + constructor(formatDelegate: CoreCourseFormatDelegate, handler: CoreCourseFormatSocialHandler) { + formatDelegate.registerHandler(handler); + } +} diff --git a/src/core/course/formats/topics/providers/handler.ts b/src/core/course/formats/topics/providers/handler.ts new file mode 100644 index 000000000..18d2c1759 --- /dev/null +++ b/src/core/course/formats/topics/providers/handler.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseFormatHandler } from '../../../providers/format-delegate'; + +/** + * Handler to support topics course format. + */ +@Injectable() +export class CoreCourseFormatTopicsHandler implements CoreCourseFormatHandler { + name = 'topics'; + + constructor() {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled() : boolean|Promise { + return true; + } +} diff --git a/src/core/course/formats/topics/topics.module.ts b/src/core/course/formats/topics/topics.module.ts new file mode 100644 index 000000000..97cedcc73 --- /dev/null +++ b/src/core/course/formats/topics/topics.module.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreCourseFormatTopicsHandler } from './providers/handler'; +import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; + +@NgModule({ + declarations: [], + imports: [ + ], + providers: [ + CoreCourseFormatTopicsHandler + ], + exports: [] +}) +export class CoreCourseFormatTopicsModule { + constructor(formatDelegate: CoreCourseFormatDelegate, handler: CoreCourseFormatTopicsHandler) { + formatDelegate.registerHandler(handler); + } +} diff --git a/src/core/course/formats/weeks/providers/handler.ts b/src/core/course/formats/weeks/providers/handler.ts new file mode 100644 index 000000000..b6f5beff7 --- /dev/null +++ b/src/core/course/formats/weeks/providers/handler.ts @@ -0,0 +1,87 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseFormatHandler } from '../../../providers/format-delegate'; +import { CoreTimeUtilsProvider } from '../../../../../providers/utils/time'; +import { CoreConstants } from '../../../../constants'; + +/** + * Handler to support weeks course format. + */ +@Injectable() +export class CoreCourseFormatWeeksHandler implements CoreCourseFormatHandler { + name = 'weeks'; + + constructor(private timeUtils: CoreTimeUtilsProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled() : boolean|Promise { + return true; + } + + /** + * Given a list of sections, get the "current" section that should be displayed first. + * + * @param {any} course The course to get the title. + * @param {any[]} sections List of sections. + * @return {any|Promise} Current section (or promise resolved with current section). + */ + getCurrentSection(course: any, sections: any[]) : any|Promise { + let now = this.timeUtils.timestamp(); + + if (now < course.startdate || (course.enddate && now > course.enddate)) { + // Course hasn't started yet or it has ended already. Return the first section. + return sections[1]; + } + + for (let i = 0; i < sections.length; i++) { + let section = sections[i]; + if (typeof section.section == 'undefined' || section.section < 1) { + continue; + } + + let dates = this.getSectionDates(section, course.startdate); + if ((now >= dates.start) && (now < dates.end)) { + return section; + } + } + + // The section wasn't found, return the first section. + return sections[1]; + } + + /** + * Return the start and end date of a section. + * + * @param {any} section The section to treat. + * @param {number} startDate The course start date (in seconds). + * @return {{start: number, end: number}} An object with the start and end date of the section. + */ + protected getSectionDates(section: any, startDate: number) : {start: number, end: number} { + // Hack alert. We add 2 hours to avoid possible DST problems. (e.g. we go into daylight savings and the date changes). + startDate = startDate + 7200; + + let dates = { + start: startDate + (CoreConstants.SECONDS_WEEK * (section.section - 1)), + end: 0 + }; + dates.end = dates.start + CoreConstants.SECONDS_WEEK; + return dates; + } +} diff --git a/src/core/course/formats/weeks/weeks.module.ts b/src/core/course/formats/weeks/weeks.module.ts new file mode 100644 index 000000000..b26e66f44 --- /dev/null +++ b/src/core/course/formats/weeks/weeks.module.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreCourseFormatWeeksHandler } from './providers/handler'; +import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; + +@NgModule({ + declarations: [], + imports: [ + ], + providers: [ + CoreCourseFormatWeeksHandler + ], + exports: [] +}) +export class CoreCourseFormatWeeksModule { + constructor(formatDelegate: CoreCourseFormatDelegate, handler: CoreCourseFormatWeeksHandler) { + formatDelegate.registerHandler(handler); + } +} diff --git a/src/core/course/lang/en.json b/src/core/course/lang/en.json new file mode 100644 index 000000000..f4997d593 --- /dev/null +++ b/src/core/course/lang/en.json @@ -0,0 +1,23 @@ +{ + "activitydisabled": "Your organisation has disabled this activity in the mobile app.", + "activitynotyetviewableremoteaddon": "Your organisation installed a plugin that is not yet supported.", + "activitynotyetviewablesiteupgradeneeded": "Your organisation's Moodle installation needs to be updated.", + "allsections": "All sections", + "askadmintosupport": "Contact the site administrator and tell them you want to use this activity with the Moodle Mobile app.", + "confirmdeletemodulefiles": "Are you sure you want to delete these files?", + "confirmdownload": "You are about to download {{size}}. Are you sure you want to continue?", + "confirmdownloadunknownsize": "It was not possible to calculate the size of the download. Are you sure you want to continue?", + "confirmpartialdownloadsize": "You are about to download at least {{size}}. Are you sure you want to continue?", + "contents": "Contents", + "couldnotloadsectioncontent": "Could not load the section content. Please try again later.", + "couldnotloadsections": "Could not load the sections. Please try again later.", + "downloadcourse": "Download course", + "errordownloadingcourse": "Error downloading course.", + "errordownloadingsection": "Error downloading section.", + "errorgetmodule": "Error getting activity data.", + "hiddenfromstudents": "Hidden from students", + "nocontentavailable": "No content available at the moment.", + "overriddennotice": "Your final grade from this activity was manually adjusted.", + "sections": "Sections", + "useactivityonbrowser": "You can still use it using your device's web browser." +} \ No newline at end of file diff --git a/src/core/course/pages/section/section.html b/src/core/course/pages/section/section.html new file mode 100644 index 000000000..6d64c169e --- /dev/null +++ b/src/core/course/pages/section/section.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core/course/pages/section/section.module.ts b/src/core/course/pages/section/section.module.ts new file mode 100644 index 000000000..c9ab2da0f --- /dev/null +++ b/src/core/course/pages/section/section.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCourseSectionPage } from './section'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CoreCourseComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCourseSectionPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule, + IonicPageModule.forChild(CoreCourseSectionPage), + TranslateModule.forChild() + ], +}) +export class CoreCourseSectionPageModule {} diff --git a/src/core/course/pages/section/section.scss b/src/core/course/pages/section/section.scss new file mode 100644 index 000000000..cbcbff326 --- /dev/null +++ b/src/core/course/pages/section/section.scss @@ -0,0 +1,24 @@ +page-core-course-section { + .core-tabs-bar { + @include position(null, null, 0, 0); + + z-index: $z-index-toolbar; + display: flex; + width: 100%; + background: $core-top-tabs-background; + + > a { + @extend .tab-button; + + background: $core-top-tabs-background; + color: $core-top-tabs-color !important; + border-bottom: 1px solid $core-top-tabs-border; + font-size: 1.6rem; + + &[aria-selected=true] { + color: $core-top-tabs-color-active !important; + border-bottom: 2px solid $core-top-tabs-color-active; + } + } + } +} diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts new file mode 100644 index 000000000..91ad41c80 --- /dev/null +++ b/src/core/course/pages/section/section.ts @@ -0,0 +1,274 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild, OnDestroy } from '@angular/core'; +import { IonicPage, NavParams, Content } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreCourseProvider } from '../../providers/course'; +import { CoreCourseHelperProvider } from '../../providers/helper'; +import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; +import { CoreCoursesDelegate, CoreCoursesHandlerToDisplay } from '../../../courses/providers/delegate'; +import { CoreCoursesProvider } from '../../../courses/providers/courses'; + +/** + * Page that displays the list of courses the user is enrolled in. + */ +@IonicPage({segment: 'core-course-section'}) +@Component({ + selector: 'page-core-course-section', + templateUrl: 'section.html', +}) +export class CoreCourseSectionPage implements OnDestroy { + @ViewChild(Content) content: Content; + + title: string; + course: any; + sections: any[]; + courseHandlers: CoreCoursesHandlerToDisplay[]; + dataLoaded: boolean; + downloadEnabled: boolean; + downloadEnabledIcon: string = 'square-outline'; // Disabled by default. + prefetchCourseData = { + prefetchCourseIcon: 'spinner' + }; + + protected moduleId; + protected completionObserver; + protected courseStatusObserver; + protected isDestroyed = false; + + constructor(navParams: NavParams, private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, + private courseFormatDelegate: CoreCourseFormatDelegate, private coursesDelegate: CoreCoursesDelegate, + private translate: TranslateService, private courseHelper: CoreCourseHelperProvider, eventsProvider: CoreEventsProvider, + private textUtils: CoreTextUtilsProvider, private coursesProvider: CoreCoursesProvider, + sitesProvider: CoreSitesProvider) { + this.course = navParams.get('course'); + this.moduleId = navParams.get('moduleId'); + + // Get the title to display. We dont't have sections yet. + this.title = courseFormatDelegate.getCourseTitle(this.course); + + this.completionObserver = eventsProvider.on(CoreEventsProvider.COMPLETION_MODULE_VIEWED, (data) => { + if (data && data.courseId == this.course.id) { + this.refreshAfterCompletionChange(); + } + }); + + // Listen for changes in course status. + this.courseStatusObserver = eventsProvider.on(CoreEventsProvider.COURSE_STATUS_CHANGED, (data) => { + if (data.courseId == this.course.id) { + this.prefetchCourseData.prefetchCourseIcon = this.courseHelper.getCourseStatusIconFromStatus(data.status); + } + }, sitesProvider.getCurrentSiteId()); + } + + /** + * View loaded. + */ + ionViewDidLoad() { + this.loadData().finally(() => { + this.dataLoaded = true; + delete this.moduleId; // Only load module automatically the first time. + + // Determine the course prefetch status. + this.determineCoursePrefetchIcon().then(() => { + if (this.prefetchCourseData.prefetchCourseIcon == 'spinner') { + // Course is being downloaded. Get the download promise. + const promise = this.courseHelper.getCourseDownloadPromise(this.course.id); + if (promise) { + // There is a download promise. Show an error if it fails. + promise.catch((error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }); + } else { + // No download, this probably means that the app was closed while downloading. Set previous status. + this.courseProvider.setCoursePreviousStatus(this.course.id).then((status) => { + this.prefetchCourseData.prefetchCourseIcon = this.courseHelper.getCourseStatusIconFromStatus(status); + }); + } + } + }); + }); + } + + /** + * Fetch and load all the data required for the view. + */ + protected loadData(refresh?: boolean) { + // First of all, get the course because the data might have changed. + return this.coursesProvider.getUserCourse(this.course.id).then((course) => { + let promises = [], + promise; + + this.course = course; + + // Get the completion status. + if (this.course.enablecompletion === false) { + // Completion not enabled. + promise = Promise.resolve({}); + } else { + promise = this.courseProvider.getActivitiesCompletionStatus(this.course.id).catch(() => { + // It failed, don't use completion. + return {}; + }); + } + + promises.push(promise.then((completionStatus) => { + // Get all the sections. + promises.push(this.courseProvider.getSections(this.course.id, false, true).then((sections) => { + this.courseHelper.addHandlerDataForModules(sections, this.course.id, this.moduleId, completionStatus); + + // Format the name of each section and check if it has content. + this.sections = sections.map((section) => { + this.textUtils.formatText(section.name.trim(), true, true).then((name) => { + section.formattedName = name; + }); + section.hasContent = this.courseHelper.sectionHasContent(section); + return section; + }); + + + if (this.courseFormatDelegate.canViewAllSections(this.course)) { + // Add a fake first section (all sections). + this.sections.unshift({ + name: this.translate.instant('core.course.allsections'), + id: CoreCourseProvider.ALL_SECTIONS_ID + }); + } + + // Get the title again now that we have sections. + this.title = this.courseFormatDelegate.getCourseTitle(this.course, this.sections); + })); + })); + + // Load the course handlers. + promises.push(this.coursesDelegate.getHandlersToDisplay(this.course, refresh, false).then((handlers) => { + this.courseHandlers = handlers; + })); + + return Promise.all(promises).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.course.couldnotloadsectioncontent', true); + }); + }); + } + + /** + * Refresh the data. + * + * @param {any} refresher Refresher. + */ + doRefresh(refresher: any) { + this.invalidateData().finally(() => { + this.loadData(true).finally(() => { + refresher.complete(); + }); + }); + } + + /** + * The completion of any of the modules have changed. + */ + onCompletionChange() { + this.invalidateData().finally(() => { + this.refreshAfterCompletionChange(); + }); + } + + /** + * Invalidate the data. + */ + protected invalidateData() { + let promises = []; + + promises.push(this.courseProvider.invalidateSections(this.course.id)); + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.courseFormatDelegate.invalidateData(this.course, this.sections)); + + // if ($scope.sections) { + // promises.push($mmCoursePrefetchDelegate.invalidateCourseUpdates(courseId)); + // } + + return Promise.all(promises); + } + + /** + * Refresh list after a completion change since there could be new activities. + */ + protected refreshAfterCompletionChange() { + // Save scroll position to restore it once done. + let scrollElement = this.content.getScrollElement(), + scrollTop = scrollElement.scrollTop || 0, + scrollLeft = scrollElement.scrollLeft || 0; + + this.dataLoaded = false; + this.content.scrollToTop(); // Scroll top so the spinner is seen. + + this.loadData().finally(() => { + this.dataLoaded = true; + this.content.scrollTo(scrollLeft, scrollTop); + }); + } + + /** + * Determines the prefetch icon of the course. + */ + protected determineCoursePrefetchIcon() { + return this.courseHelper.getCourseStatusIcon(this.course.id).then((icon) => { + this.prefetchCourseData.prefetchCourseIcon = icon; + }); + } + + /** + * Prefetch the whole course. + */ + prefetchCourse() { + this.courseHelper.confirmAndPrefetchCourse(this.prefetchCourseData, this.course, this.sections, this.courseHandlers) + .then((downloaded) => { + if (downloaded && this.downloadEnabled) { + // Recalculate the status. + this.courseHelper.calculateSectionsStatus(this.sections, this.course.id).catch(() => { + // Ignore errors (shouldn't happen). + }); + } + }).catch((error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }); + } + + /** + * Toggle download enabled. + */ + toggleDownload() { + this.downloadEnabled = !this.downloadEnabled; + this.downloadEnabledIcon = this.downloadEnabled ? 'checkbox-outline' : 'square-outline'; + } + + /** + * Page destroyed. + */ + ngOnDestroy() { + this.isDestroyed = true; + if (this.completionObserver) { + this.completionObserver.off(); + } + } +} diff --git a/src/core/course/pages/unsupported-module/unsupported-module.html b/src/core/course/pages/unsupported-module/unsupported-module.html new file mode 100644 index 000000000..8dbdd656d --- /dev/null +++ b/src/core/course/pages/unsupported-module/unsupported-module.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/core/course/pages/unsupported-module/unsupported-module.module.ts b/src/core/course/pages/unsupported-module/unsupported-module.module.ts new file mode 100644 index 000000000..d45906da4 --- /dev/null +++ b/src/core/course/pages/unsupported-module/unsupported-module.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCourseUnsupportedModulePage } from './unsupported-module'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CoreCourseComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCourseUnsupportedModulePage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule, + IonicPageModule.forChild(CoreCourseUnsupportedModulePage), + TranslateModule.forChild() + ], +}) +export class CoreCourseUnsupportedModulePageModule {} diff --git a/src/core/course/pages/unsupported-module/unsupported-module.ts b/src/core/course/pages/unsupported-module/unsupported-module.ts new file mode 100644 index 000000000..9d2be19fe --- /dev/null +++ b/src/core/course/pages/unsupported-module/unsupported-module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { IonicPage, NavParams, NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; + +/** + * Page that displays info about an unsupported module. + */ +@IonicPage({segment: 'core-course-unsupported-module'}) +@Component({ + selector: 'page-core-course-unsupported-module', + templateUrl: 'unsupported-module.html', +}) +export class CoreCourseUnsupportedModulePage { + module: any; + + constructor(navParams: NavParams, private translate: TranslateService, private textUtils: CoreTextUtilsProvider, + private navCtrl: NavController) { + this.module = navParams.get('module') || {}; + } + + /** + * Expand the description. + */ + expandDescription() { + this.textUtils.expandText(this.translate.instant('core.description'), this.module.description); + } +} diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts new file mode 100644 index 000000000..5aced5a1a --- /dev/null +++ b/src/core/course/providers/course.ts @@ -0,0 +1,727 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreTimeUtilsProvider } from '../../../providers/utils/time'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreSiteWSPreSets } from '../../../classes/site'; +import { CoreConstants } from '../../constants'; + +/** + * Service that provides some features regarding a course. + */ +@Injectable() +export class CoreCourseProvider { + public static ALL_SECTIONS_ID = -1; + protected ROOT_CACHE_KEY = 'mmCourse:'; + + // Variables for database. + protected COURSE_STATUS_TABLE = 'course_status'; + protected courseStatusTableSchema = { + name: this.COURSE_STATUS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'status', + type: 'TEXT', + notNull: true + }, + { + name: 'previous', + type: 'TEXT' + }, + { + name: 'updated', + type: 'INTEGER' + }, + { + name: 'downloadTime', + type: 'INTEGER' + }, + { + name: 'previousDownloadTime', + type: 'INTEGER' + } + ] + }; + + protected logger; + protected CORE_MODULES = [ + 'assign', 'assignment', 'book', 'chat', 'choice', 'data', 'database', 'date', 'external-tool', + 'feedback', 'file', 'folder', 'forum', 'glossary', 'ims', 'imscp', 'label', 'lesson', 'lti', 'page', 'quiz', + 'resource', 'scorm', 'survey', 'url', 'wiki', 'workshop' + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider, + private utils: CoreUtilsProvider, private timeUtils: CoreTimeUtilsProvider, private translate: TranslateService) { + this.logger = logger.getInstance('CoreCourseProvider'); + + this.sitesProvider.createTableFromSchema(this.courseStatusTableSchema); + } + + /** + * Check if module completion could have changed. If it could have, trigger event. This function must be used, + * for example, after calling a "module_view" WS since it can change the module completion. + * + * @param {number} courseId Course ID. + * @param {any} completion Completion status of the module. + */ + checkModuleCompletion(courseId: number, completion: any) : void { + if (completion && completion.tracking === 2 && completion.state === 0) { + this.invalidateSections(courseId).finally(() => { + this.eventsProvider.trigger(CoreEventsProvider.COMPLETION_MODULE_VIEWED, {courseId: courseId}); + }); + } + } + + /** + * Clear all courses status in a site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when all status are cleared. + */ + clearAllCoursesStatus(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + this.logger.debug('Clear all course status for site ' + site.id); + + return site.getDb().deleteRecords(this.COURSE_STATUS_TABLE).then(() => { + this.triggerCourseStatusChanged(-1, CoreConstants.NOT_DOWNLOADED, site.id); + }); + }); + } + + /** + * Get completion status of all the activities in a course for a certain user. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, current user. + * @return {Promise} Promise resolved with the completion statuses: object where the key is module ID. + */ + getActivitiesCompletionStatus(courseId: number, siteId?: string, userId?: number) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + this.logger.debug(`Getting completion status for user ${userId} in course ${courseId}`); + + let params = { + courseid: courseId, + userid: userId + }, + preSets = { + cacheKey: this.getActivitiesCompletionCacheKey(courseId, userId) + }; + + return site.read('core_completion_get_activities_completion_status', params, preSets).then((data) => { + if (data && data.statuses) { + return this.utils.arrayToObject(data.statuses, 'cmid'); + } + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for activities completion WS calls. + * + * @param {number} courseId Course ID. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getActivitiesCompletionCacheKey(courseId: number, userId: number) : string { + return this.ROOT_CACHE_KEY + 'activitiescompletion:' + courseId + ':' + userId; + } + + /** + * Get the data stored for a course. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the data. + */ + getCourseStatusData(courseId: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.COURSE_STATUS_TABLE, {id: courseId}).then((entry) => { + if (!entry) { + return Promise.reject(null); + } + return entry; + }); + }); + } + + /** + * Get a course status. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the status. + */ + getCourseStatus(courseId: number, siteId?: string) : Promise { + return this.getCourseStatusData(courseId, siteId).then((entry) => { + return entry.status || CoreConstants.NOT_DOWNLOADED; + }).catch(() => { + return CoreConstants.NOT_DOWNLOADED; + }); + } + + /** + * Get a module from Moodle. + * + * @param {number} moduleId The module ID. + * @param {number} [courseId] The course ID. Recommended to speed up the process and minimize data usage. + * @param {number} [sectionId] The section ID. + * @param {boolean} [preferCache] True if shouldn't call WS if data is cached, false otherwise. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the module. + */ + getModule(moduleId: number, courseId?: number, sectionId?: number, preferCache?: boolean, ignoreCache?: boolean, + siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let promise; + + if (!courseId) { + // No courseId passed, try to retrieve it. + promise = this.getModuleBasicInfo(moduleId, siteId).then((module) => { + return module.course; + }); + } else { + promise = Promise.resolve(courseId); + } + + return promise.then((cid) => { + courseId = cid; + + // Get the site. + return this.sitesProvider.getSite(siteId); + }).then((site) => { + // We have courseId, we can use core_course_get_contents for compatibility. + this.logger.debug(`Getting module ${moduleId} in course ${courseId}`); + + let params = { + courseid: courseId, + options: [ + { + name: 'cmid', + value: moduleId + } + ] + }, + preSets: any = { + cacheKey: this.getModuleCacheKey(moduleId), + omitExpires: preferCache + }; + + if (!preferCache && ignoreCache) { + preSets.getFromCache = 0; + preSets.emergencyCache = 0; + } + + if (sectionId) { + params.options.push({ + name: 'sectionid', + value: sectionId + }); + } + + return site.read('core_course_get_contents', params, preSets).catch(() => { + // Error getting the module. Try to get all contents (without filtering by module). + return this.getSections(courseId, false, false, preSets, siteId); + }).then((sections) => { + for (let i = 0; i < sections.length; i++) { + let section = sections[i]; + for (let j = 0; j < section.modules.length; j++) { + let module = section.modules[j]; + if (module.id == moduleId) { + module.course = courseId; + return module; + } + } + } + return Promise.reject(null); + }); + }); + } + + /** + * Gets a module basic info by module ID. + * + * @param {number} moduleId Module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the module's info. + */ + getModuleBasicInfo(moduleId: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + cmid: moduleId + }, + preSets = { + cacheKey: this.getModuleCacheKey(moduleId) + }; + + return site.read('core_course_get_course_module', params, preSets).then((response) => { + if (response.warnings && response.warnings.length) { + return Promise.reject(response.warnings[0]); + } else if (response.cm) { + return response.cm; + } + return Promise.reject(null); + }); + }); + } + + /** + * Gets a module basic grade info by module ID. + * + * @param {number} moduleId Module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the module's grade info. + */ + getModuleBasicGradeInfo(moduleId: number, siteId?: string) : Promise { + return this.getModuleBasicInfo(moduleId, siteId).then((info) => { + let grade = { + advancedgrading: info.advancedgrading || false, + grade: info.grade || false, + gradecat: info.gradecat || false, + gradepass: info.gradepass || false, + outcomes: info.outcomes || false, + scale: info.scale || false + }; + + if (grade.grade !== false || grade.advancedgrading !== false || grade.outcomes !== false) { + return grade; + } + return false; + }); + } + + /** + * Gets a module basic info by instance. + * + * @param {number} id Instance ID. + * @param {string} module Name of the module. E.g. 'glossary'. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the module's info. + */ + getModuleBasicInfoByInstance(id: number, module: string, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + instance: id, + module: module + }, + preSets = { + cacheKey: this.getModuleBasicInfoByInstanceCacheKey(id, module) + }; + + return site.read('core_course_get_course_module_by_instance', params, preSets).then((response) => { + if (response.warnings && response.warnings.length) { + return Promise.reject(response.warnings[0]); + } else if (response.cm) { + return response.cm; + } + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get module by instance WS calls. + * + * @param {number} id Instance ID. + * @param {string} module Name of the module. E.g. 'glossary'. + * @return {string} Cache key. + */ + protected getModuleBasicInfoByInstanceCacheKey(id: number, module: string) : string { + return this.ROOT_CACHE_KEY + 'moduleByInstance:' + module + ':' + id; + } + + /** + * Get cache key for module WS calls. + * + * @param {number} moduleId Module ID. + * @return {string} Cache key. + */ + protected getModuleCacheKey(moduleId: number) : string { + return this.ROOT_CACHE_KEY + 'module:' + moduleId; + } + + /** + * Returns the source to a module icon. + * + * @param {string} moduleName The module name. + * @return {string} The IMG src. + */ + getModuleIconSrc(moduleName: string) : string { + if (this.CORE_MODULES.indexOf(moduleName) < 0) { + moduleName = 'external-tool'; + } + + return 'assets/img/mod/' + moduleName + '.svg'; + } + + /** + * Get the section ID a module belongs to. + * + * @param {number} moduleId The module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the section ID. + */ + getModuleSectionId(moduleId: number, siteId?: string) : Promise { + // Try to get the section using getModuleBasicInfo. + return this.getModuleBasicInfo(moduleId, siteId).then((module) => { + return module.section; + }); + } + + /** + * Return a specific section. + * + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @param {boolean} [excludeModules] Do not return modules, return only the sections structure. + * @param {boolean} [excludeContents] Do not return module contents (i.e: files inside a resource). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the section. + */ + getSection(courseId: number, sectionId?: number, excludeModules?: boolean, excludeContents?: boolean, siteId?: string) + : Promise { + + if (sectionId < 0) { + return Promise.reject('Invalid section ID'); + } + + return this.getSections(courseId, excludeModules, excludeContents, undefined, siteId).then((sections) => { + for (let i = 0; i < sections.length; i++) { + if (sections[i].id == sectionId) { + return sections[i]; + } + } + + return Promise.reject('Unkown section'); + }); + } + + /** + * Get the course sections. + * + * @param {number} courseId The course ID. + * @param {boolean} [excludeModules] Do not return modules, return only the sections structure. + * @param {boolean} [excludeContents] Do not return module contents (i.e: files inside a resource). + * @param {CoreSiteWSPreSets} [preSets] Presets to use. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} The reject contains the error message, else contains the sections. + */ + getSections(courseId?: number, excludeModules?: boolean, excludeContents?: boolean, preSets?: CoreSiteWSPreSets, + siteId?: string) : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + preSets = preSets || {}; + preSets.cacheKey = this.getSectionsCacheKey(courseId); + preSets.getCacheUsingCacheKey = true; // This is to make sure users don't lose offline access when updating. + + let params = { + courseid: courseId, + options: [ + { + name: 'excludemodules', + value: excludeModules ? 1 : 0 + }, + { + name: 'excludecontents', + value: excludeContents ? 1 : 0 + } + ] + }; + + return site.read('core_course_get_contents', params, preSets).then((sections) => { + let siteHomeId = site.getSiteHomeId(), + showSections = true; + + if (courseId == siteHomeId) { + showSections = site.getStoredConfig('numsections'); + } + + if (typeof showSections != 'undefined' && !showSections && sections.length > 0) { + // Get only the last section (Main menu block section). + sections.pop(); + } + + return sections; + }); + }); + } + + /** + * Get cache key for section WS call. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getSectionsCacheKey(courseId) : string { + return this.ROOT_CACHE_KEY + 'sections:' + courseId; + } + + /** + * Given a list of sections, returns the list of modules in the sections. + * + * @param {any[]} sections Sections. + * @return {any[]} Modules. + */ + getSectionsModules(sections: any[]) : any[] { + if (!sections || !sections.length) { + return []; + } + + let modules = []; + sections.forEach((section) => { + if (section.modules) { + modules = modules.concat(section.modules); + } + }); + return modules; + } + + /** + * Invalidates module WS call. + * + * @param {number} moduleId Module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateModule(moduleId: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getModuleCacheKey(moduleId)); + }); + } + + /** + * Invalidates module WS call. + * + * @param {number} id Instance ID. + * @param {string} module Name of the module. E.g. 'glossary'. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateModuleByInstance(id: number, module: string, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getModuleBasicInfoByInstanceCacheKey(id, module)); + }); + } + + /** + * Invalidates sections WS call. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, current user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateSections(courseId: number, siteId?: string, userId?: number) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let promises = [], + siteHomeId = site.getSiteHomeId(); + + userId = userId || site.getUserId(); + + promises.push(site.invalidateWsCacheForKey(this.getSectionsCacheKey(courseId))); + promises.push(site.invalidateWsCacheForKey(this.getActivitiesCompletionCacheKey(courseId, userId))); + if (courseId == siteHomeId) { + promises.push(site.invalidateConfig()); + } + return Promise.all(promises); + }); + } + + /** + * Load module contents into module.contents if they aren't loaded already. + * + * @param {any} module Module to load the contents. + * @param {number} [courseId] The course ID. Recommended to speed up the process and minimize data usage. + * @param {number} [sectionId] The section ID. + * @param {boolean} [preferCache] True if shouldn't call WS if data is cached, false otherwise. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when loaded. + */ + loadModuleContents(module: any, courseId?: number, sectionId?: number, preferCache?: boolean, ignoreCache?: boolean, + siteId?: string) : Promise { + if (!ignoreCache && module.contents && module.contents.length) { + // Already loaded. + return Promise.resolve(); + } + + return this.getModule(module.id, courseId, sectionId, preferCache, ignoreCache, siteId).then((mod) => { + module.contents = mod.contents; + }); + } + + /** + * Report a course and section as being viewed. + * + * @param {number} courseId Course ID. + * @param {number} [sectionNumber] Section number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(courseId: number, sectionNumber?: number, siteId?: string) : Promise { + let params: any = { + courseid: courseId + }; + if (typeof sectionNumber != 'undefined') { + params.sectionnumber = sectionNumber; + } + + return this.sitesProvider.getSite(siteId).then((site) => { + return site.write('core_course_view_course', params).then((response) => { + if (!response.status) { + return Promise.reject(null); + } + }) + }); + } + + /** + * Change the course status, setting it to the previous status. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the status is changed. Resolve param: new status. + */ + setCoursePreviousStatus(courseId: number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + this.logger.debug(`Set previous status for course ${courseId} in site ${siteId}`); + + return this.sitesProvider.getSite(siteId).then((site) => { + let db = site.getDb(), + newData: any = {}; + + // Get current stored data. + return this.getCourseStatusData(courseId, siteId).then((entry) => { + this.logger.debug(`Set previous status '${entry.status}' for course ${courseId}`); + + newData.status = entry.previous || CoreConstants.NOT_DOWNLOADED; + newData.updated = Date.now(); + if (entry.status == CoreConstants.DOWNLOADING) { + // Going back from downloading to previous status, restore previous download time. + newData.downloadTime = entry.previousDownloadTime; + } + + return db.updateRecords(this.COURSE_STATUS_TABLE, newData, {id: courseId}).then(() => { + // Success updating, trigger event. + this.triggerCourseStatusChanged(courseId, newData.status, siteId); + return newData.status; + }); + }); + }); + } + + /** + * Store course status. + * + * @param {number} courseId Course ID. + * @param {string} status New course status. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the status is stored. + */ + setCourseStatus(courseId: number, status: string, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId() + + this.logger.debug(`Set status '${status}' for course ${courseId} in site ${siteId}`); + + return this.sitesProvider.getSite(siteId).then((site) => { + let downloadTime, + previousDownloadTime; + + if (status == CoreConstants.DOWNLOADING) { + // Set download time if course is now downloading. + downloadTime = this.timeUtils.timestamp(); + } + + // Search current status to set it as previous status. + return this.getCourseStatusData(courseId, siteId).then((entry) => { + if (typeof downloadTime == 'undefined') { + // Keep previous download time. + downloadTime = entry.downloadTime; + previousDownloadTime = entry.previousDownloadTime; + } else { + // downloadTime will be updated, store current time as previous. + previousDownloadTime = entry.downloadTime; + } + + return entry.status; + }).catch(() => { + // No previous status. + }).then((previousStatus) => { + if (previousStatus != status) { + // Status has changed, update it. + let data = { + id: courseId, + status: status, + previous: previousStatus, + updated: new Date().getTime(), + downloadTime: downloadTime, + previousDownloadTime: previousDownloadTime + }; + + return site.getDb().insertOrUpdateRecord(this.COURSE_STATUS_TABLE, data, {id: courseId}); + } + }).then(() => { + // Success inserting, trigger event. + this.triggerCourseStatusChanged(courseId, status, siteId); + }); + }); + } + + /** + * Translate a module name to current language. + * + * @param {string} moduleName The module name. + * @return {string} Translated name. + */ + translateModuleName(moduleName: string) : string { + if (this.CORE_MODULES.indexOf(moduleName) < 0) { + moduleName = 'external-tool'; + } + + const langKey = 'core.mod_' + moduleName, + translated = this.translate.instant(langKey); + + return translated !== langKey ? translated : moduleName; + } + + /** + * Trigger COURSE_STATUS_CHANGED with the right data. + * + * @param {number} courseId Course ID. + * @param {string} status New course status. + * @param {string} [siteId] Site ID. If not defined, current site. + */ + protected triggerCourseStatusChanged(courseId: number, status: string, siteId?: string) : void { + this.eventsProvider.trigger(CoreEventsProvider.COURSE_STATUS_CHANGED, { + courseId: courseId, + status: status + }, siteId); + } +} diff --git a/src/core/course/providers/default-format.ts b/src/core/course/providers/default-format.ts new file mode 100644 index 000000000..6a8b294e4 --- /dev/null +++ b/src/core/course/providers/default-format.ts @@ -0,0 +1,128 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreCoursesProvider } from '../../courses/providers/courses'; +import { CoreCourseFormatHandler } from './format-delegate'; +import { CoreCourseProvider } from './course'; + +/** + * Default handler used when the course format doesn't have a specific implementation. + */ +@Injectable() +export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { + name = 'default'; + + constructor(private coursesProvider: CoreCoursesProvider) {} + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled() : boolean|Promise { + return true; + } + + /** + * Get the title to use in course page. + * + * @param {any} course The course. + * @return {string} Title. + */ + getCourseTitle?(course: any) : string { + return course.fullname || ''; + } + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param {any} course The course to check. + * @type {boolean} Whether it can view all sections. + */ + canViewAllSections(course: any) : boolean { + return true; + } + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @type {boolean} Whether the default section selector should be displayed. + */ + displaySectionSelector(course: any) : boolean { + return true; + } + + /** + * Given a list of sections, get the "current" section that should be displayed first. + * + * @param {any} course The course to get the title. + * @param {any[]} sections List of sections. + * @return {any|Promise} Current section (or promise resolved with current section). + */ + getCurrentSection(course: any, sections: any[]) : any|Promise { + // We need the "marker" to determine the current section. + return this.coursesProvider.getCoursesByField('id', course.id).catch(() => { + // Ignore errors. + }).then((courses) => { + if (courses && courses[0]) { + // Find the marked section. + let course = courses[0]; + for (let i = 0; i < sections.length; i++) { + let section = sections[i]; + if (section.section == course.marker) { + return section; + } + } + } + + // Marked section not found or we couldn't retrieve the marker. Return the first section. + for (let i = 0; i < sections.length; i++) { + let section = sections[i]; + if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + return section; + } + } + + return Promise.reject(null); + }); + } + + /** + * Invalidate the data required to load the course format. + * + * @param {any} course The course to get the title. + * @param {any[]} sections List of sections. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateData(course: any, sections: any[]) : Promise { + return this.coursesProvider.invalidateCoursesByField('id', course.id); + } + + /** + * Open the page to display a course. If not defined, the page CoreCourseSectionPage will be opened. + * Implement it only if you want to create your own page to display the course. In general it's better to use the method + * getCourseFormatComponent because it will display the course handlers at the top. + * Your page should include the course handlers using CoreCoursesDelegate. + * + * @param {NavController} navCtrl The NavController instance to use. + * @param {any} course The course to open. It should contain a "format" attribute. + * @return {Promise} Promise resolved when done. + */ + openCourse(navCtrl: NavController, course: any) : Promise { + return navCtrl.push('CoreCourseSectionPage', {course: course}); + } +} diff --git a/src/core/course/providers/format-delegate.ts b/src/core/course/providers/format-delegate.ts new file mode 100644 index 000000000..36db3a662 --- /dev/null +++ b/src/core/course/providers/format-delegate.ts @@ -0,0 +1,391 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreCourseProvider } from './course'; +import { CoreCourseFormatDefaultHandler } from './default-format'; + +/** + * Interface that all course format handlers must implement. + */ +export interface CoreCourseFormatHandler { + /** + * Name of the format. It should match the "format" returned in core_course_get_courses. + * @type {string} + */ + name: string; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise; + + /** + * Get the title to use in course page. If not defined, course fullname. + * This function will be called without sections first, and then call it again when the sections are retrieved. + * + * @param {any} course The course. + * @param {any[]} [sections] List of sections. + * @return {string} Title. + */ + getCourseTitle?(course: any, sections?: any[]) : string; + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param {any} course The course to check. + * @type {boolean} Whether it can view all sections. + */ + canViewAllSections?(course: any) : boolean; + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @type {boolean} Whether the default section selector should be displayed. + */ + displaySectionSelector?(course: any) : boolean; + + /** + * Given a list of sections, get the "current" section that should be displayed first. Defaults to first section. + * + * @param {any} course The course to get the title. + * @param {any[]} sections List of sections. + * @return {any|Promise} Current section (or promise resolved with current section). If a promise is returned, it should + * never fail. + */ + getCurrentSection?(course: any, sections: any[]) : any|Promise; + + /** + * Open the page to display a course. If not defined, the page CoreCourseSectionPage will be opened. + * Implement it only if you want to create your own page to display the course. In general it's better to use the method + * getCourseFormatComponent because it will display the course handlers at the top. + * Your page should include the course handlers using CoreCoursesDelegate. + * + * @param {NavController} navCtrl The NavController instance to use. + * @param {any} course The course to open. It should contain a "format" attribute. + * @return {Promise} Promise resolved when done. + */ + openCourse?(navCtrl: NavController, course: any) : Promise; + + /** + * Return the Component to use to display the course format instead of using the default one. + * Use it if you want to display a format completely different from the default one. + * If you want to customize the default format there are several methods to customize parts of it. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getCourseFormatComponent?(course: any) : any; + + /** + * Return the Component to use to display the course summary inside the default course format. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getCourseSummaryComponent?(course: any): any; + + /** + * Return the Component to use to display the section selector inside the default course format. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getSectionSelectorComponent?(course: any): any; + + /** + * Return the Component to use to display a single section. This component will only be used if the user is viewing a + * single section. If all the sections are displayed at once then it won't be used. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getSingleSectionComponent?(course: any): any; + + /** + * Return the Component to use to display all sections in a course. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getAllSectionsComponent?(course: any): any; + + /** + * Invalidate the data required to load the course format. + * + * @param {any} course The course to get the title. + * @param {any[]} sections List of sections. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateData?(course: any, sections: any[]) : Promise; +}; + +/** + * Service to interact with course formats. Provides the functions to register and interact with the addons. + */ +@Injectable() +export class CoreCourseFormatDelegate { + protected logger; + protected handlers: {[s: string]: CoreCourseFormatHandler} = {}; // All registered handlers. + protected enabledHandlers: {[s: string]: CoreCourseFormatHandler} = {}; // Handlers enabled for the current site. + protected lastUpdateHandlersStart: number; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + private defaultHandler: CoreCourseFormatDefaultHandler) { + this.logger = logger.getInstance('CoreCoursesCourseFormatDelegate'); + + eventsProvider.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.REMOTE_ADDONS_LOADED, this.updateHandlers.bind(this)); + } + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param {any} course The course to check. + * @return {boolean} Whether it allows seeing all sections at the same time. + */ + canViewAllSections(course: any) : boolean { + return this.executeFunction(course.format, 'canViewAllSections', [course]); + } + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @return {boolean} Whether the section selector should be displayed. + */ + displaySectionSelector(course: any) : boolean { + return this.executeFunction(course.format, 'displaySectionSelector', [course]); + } + + /** + * Execute a certain function in a course format handler. + * If the handler isn't found or function isn't defined, call the same function in the default handler. + * + * @param {string} format The format name. + * @param {string} fnName Name of the function to execute. + * @param {any[]} params Parameters to pass to the function. + * @return {any} Function returned value or default value. + */ + protected executeFunction(format: string, fnName: string, params?: any[]) : any { + let handler = this.enabledHandlers[format]; + if (handler && handler[fnName]) { + return handler[fnName].apply(handler, params); + } else if (this.defaultHandler[fnName]) { + return this.defaultHandler[fnName].apply(this.defaultHandler, params); + } + } + + /** + * Get the component to use to display all sections in a course. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getAllSectionsComponent(course: any) : any { + return this.executeFunction(course.format, 'getAllSectionsComponent', [course]); + } + + /** + * Get the component to use to display a course format. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getCourseFormatComponent(course: any) : any { + return this.executeFunction(course.format, 'getCourseFormatComponent', [course]); + } + + /** + * Get the component to use to display the course summary in the default course format. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getCourseSummaryComponent(course: any) : any { + return this.executeFunction(course.format, 'getCourseSummaryComponent', [course]); + } + + /** + * Given a course, return the title to use in the course page. + * + * @param {any} course The course to get the title. + * @param {any[]} [sections] List of sections. + * @return {string} Course title. + */ + getCourseTitle(course: any, sections?: any[]) : string { + return this.executeFunction(course.format, 'getCourseTitle', [course, sections]); + } + + /** + * Given a course and a list of sections, return the current section that should be displayed first. + * + * @param {any} course The course to get the title. + * @param {any[]} sections List of sections. + * @return {Promise} Promise resolved with current section. + */ + getCurrentSection(course: any, sections: any[]) : Promise { + // Convert the result to a Promise if it isn't. + return Promise.resolve(this.executeFunction(course.format, 'getCurrentSection', [course, sections])).catch(() => { + // This function should never fail. Just return the first section. + if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { + return sections[0]; + } + return sections[1]; + }); + } + + /** + * Get the component to use to display the section selector inside the default course format. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getSectionSelectorComponent(course: any) : any { + return this.executeFunction(course.format, 'getSectionSelectorComponent', [course]); + } + + /** + * Get the component to use to display a single section. This component will only be used if the user is viewing + * a single section. If all the sections are displayed at once then it won't be used. + * + * @param {any} course The course to render. + * @return {any} The component to use, undefined if not found. + */ + getSingleSectionComponent(course: any) : any { + return this.executeFunction(course.format, 'getSingleSectionComponent', [course]); + } + + /** + * Invalidate the data required to load the course format. + * + * @param {any} course The course to get the title. + * @param {any[]} sections List of sections. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateData(course: any, sections: any[]) : Promise { + return this.executeFunction(course.format, 'invalidateData', [course, sections]); + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Open a course. + * + * @param {NavController} navCtrl The NavController instance to use. + * @param {any} course The course to open. It should contain a "format" attribute. + * @return {Promise} Promise resolved when done. + */ + openCourse(navCtrl: NavController, course: any) : Promise { + if (this.enabledHandlers[course.format] && this.enabledHandlers[course.format].openCourse) { + return this.enabledHandlers[course.format].openCourse(navCtrl, course); + } + return navCtrl.push('CoreCourseSectionPage', {course: course}); + } + + /** + * Register a handler. + * + * @param {CoreCourseFormatHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreCourseFormatHandler) : boolean { + if (typeof this.handlers[handler.name] !== 'undefined') { + this.logger.log(`Addon '${handler.name}' already registered`); + return false; + } + this.logger.log(`Registered addon '${handler.name}'`); + this.handlers[handler.name] = handler; + return true; + } + + /** + * Update the handler for the current site. + * + * @param {CoreCourseFormatHandler} handler The handler to check. + * @param {number} time Time this update process started. + * @return {Promise} Resolved when done. + */ + protected updateHandler(handler: CoreCourseFormatHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(), + currentSite = this.sitesProvider.getCurrentSite(); + + if (!this.sitesProvider.isLoggedIn()) { + promise = Promise.reject(null); + } else if (currentSite.isFeatureDisabled('CoreCourseFormatHandler_' + handler.name)) { + promise = Promise.resolve(false); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the handler is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() === siteId) { + if (enabled) { + this.enabledHandlers[handler.name] = handler; + } else { + delete this.enabledHandlers[handler.name]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + protected updateHandlers() : Promise { + let promises = [], + now = Date.now(); + + this.logger.debug('Updating handlers for current site.'); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).catch(() => { + // Never reject. + }); + } +} diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts new file mode 100644 index 000000000..1ba282c95 --- /dev/null +++ b/src/core/course/providers/helper.ts @@ -0,0 +1,780 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreFilepoolProvider } from '../../../providers/filepool'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; +import { CoreTimeUtilsProvider } from '../../../providers/utils/time'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreCoursesDelegate, CoreCoursesHandlerToDisplay } from '../../courses/providers/delegate'; +import { CoreCourseProvider } from './course'; +import { CoreCourseModuleDelegate } from './module-delegate'; +import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from './module-prefetch-delegate'; +import { CoreConstants } from '../../constants'; +import * as moment from 'moment'; + +/** + * Prefetch info of a module. + */ +export type CoreCourseModulePrefetchInfo = { + /** + * Downloaded size. + * @type {number} + */ + size?: number; + + /** + * Downloadable size in a readable format. + * @type {string} + */ + sizeReadable?: string; + + /** + * Module status. + * @type {string} + */ + status?: string; + + /** + * Icon's name of the module status. + * @type {string} + */ + statusIcon?: string; + + /** + * Time when the module was last downloaded. + * @type {number} + */ + downloadTime?: number; + + /** + * Download time in a readable format. + * @type {string} + */ + downloadTimeReadable?: string; +}; + +/** + * Progress of downloading a list of courses. + */ +export type CoreCourseCoursesProgress = { + /** + * Number of courses downloaded so far. + * @type {number} + */ + count: number; + + /** + * Toal of courses to download. + * @type {number} + */ + total: number; + + /** + * Whether the download has been successful so far. + * @type {boolean} + */ + success: boolean; + + /** + * Last downloaded course. + * @type {number} + */ + courseId?: number; +}; + +/** + * Helper to gather some common course functions. + */ +@Injectable() +export class CoreCourseHelperProvider { + + protected courseDwnPromises: {[s: string]: {[id: number]: Promise}} = {}; + + constructor(private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, + private moduleDelegate: CoreCourseModuleDelegate, private prefetchDelegate: CoreCourseModulePrefetchDelegate, + private filepoolProvider: CoreFilepoolProvider, private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, private translate: TranslateService, private coursesDelegate: CoreCoursesDelegate) {} + + /** + * This function treats every module on the sections provided to load the handler data, treat completion + * and navigate to a module page if required. It also returns if sections has content. + * + * @param {any[]} sections List of sections to treat modules. + * @param {number} courseId Course ID of the modules. + * @param {number} [moduleId] Module to navigate to if needed. + * @param {any[]} [completionStatus] List of completion status. + * @return {boolean} Whether the sections have content. + */ + addHandlerDataForModules(sections: any[], courseId: number, moduleId?: number, completionStatus?: any) { + let hasContent = false; + + sections.forEach((section) => { + if (!section || !this.sectionHasContent(section) || !section.modules) { + return; + } + + hasContent = true; + + section.modules.forEach((module) => { + module.handlerData = this.moduleDelegate.getModuleDataFor(module.modname, module, courseId, section.id); + + if (completionStatus && typeof completionStatus[module.id] != 'undefined') { + // Check if activity has completions and if it's marked. + module.completionstatus = completionStatus[module.id]; + module.completionstatus.courseId = courseId; + } + + if (module.id == moduleId) { + // This is the module we're looking for. Open it. + module.handlerData.action(new Event('click'), module, courseId); + } + }); + }); + + return hasContent; + } + + /** + * Calculate the status of a section. + * + * @param {any} section Section to calculate its status. It can't be "All sections". + * @param {number} courseId Course ID the section belongs to. + * @param {boolean} [refresh] True if it shouldn't use module status cache (slower). + * @return {Promise} Promise resolved when the status is calculated. + */ + calculateSectionStatus(section: any, courseId: number, refresh?: boolean) : Promise { + + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return Promise.reject(null); + } + + // Get the status of this section. + return this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id, refresh).then((result) => { + // Check if it's being downloaded. + const downloadId = this.getSectionDownloadId(section); + if (this.prefetchDelegate.isBeingDownloaded(downloadId)) { + result.status = CoreConstants.DOWNLOADING; + } + + // Set this section data. + section.showDownload = result.status === CoreConstants.NOT_DOWNLOADED; + section.showRefresh = result.status === CoreConstants.OUTDATED; + + if (result.status !== CoreConstants.DOWNLOADING || !this.prefetchDelegate.isBeingDownloaded(section.id)) { + section.isDownloading = false; + section.total = 0; + } else { + // Section is being downloaded. + section.isDownloading = true; + this.prefetchDelegate.setOnProgress(downloadId, (data) => { + section.count = data.count; + section.total = data.total; + }); + } + + return result; + }); + } + + /** + * Calculate the status of a list of sections, setting attributes to determine the icons/data to be shown. + * + * @param {any[]} sections Sections to calculate their status. + * @param {number} courseId Course ID the sections belong to. + * @param {boolean} [refresh] True if it shouldn't use module status cache (slower). + * @return {Promise} Promise resolved when the states are calculated. + */ + calculateSectionsStatus(sections: any[], courseId: number, refresh?: boolean) : Promise { + let allSectionsSection, + allSectionsStatus, + promises = []; + + sections.forEach((section) => { + if (section.id === CoreCourseProvider.ALL_SECTIONS_ID) { + // "All sections" section status is calculated using the status of the rest of sections. + allSectionsSection = section; + section.isCalculating = true; + } else { + section.isCalculating = true; + promises.push(this.calculateSectionStatus(section, courseId, refresh).then((result) => { + // Calculate "All sections" status. + allSectionsStatus = this.filepoolProvider.determinePackagesStatus(allSectionsStatus, result.status); + }).finally(() => { + section.isCalculating = false; + })); + } + }); + + return Promise.all(promises).then(() => { + if (allSectionsSection) { + // Set "All sections" data. + allSectionsSection.showDownload = allSectionsStatus === CoreConstants.NOT_DOWNLOADED; + allSectionsSection.showRefresh = allSectionsStatus === CoreConstants.OUTDATED; + allSectionsSection.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + } + }).finally(() => { + if (allSectionsSection) { + allSectionsSection.isCalculating = false; + } + }); + } + + /** + * Show a confirm and prefetch a course. It will retrieve the sections and the course options if not provided. + * This function will set the icon to "spinner" when starting and it will also set it back to the initial icon if the + * user cancels. All the other updates of the icon should be made when CoreEventsProvider.COURSE_STATUS_CHANGED is received. + * + * @param {any} iconData An object where to store the course icon. It will be stored with the name "prefetchCourseIcon". + * @param {any} course Course to prefetch. + * @param {any[]} [sections] List of course sections. + * @param {CoreCoursesHandlerToDisplay[]} courseHandlers List of course handlers. + * @return {Promise} Promise resolved with true when the download finishes, resolved with false if user doesn't + * confirm, rejected if an error occurs. + */ + confirmAndPrefetchCourse(iconData: any, course: any, sections?: any[], courseHandlers?: CoreCoursesHandlerToDisplay[]) + : Promise { + let initialIcon = iconData.prefetchCourseIcon, + promise, + siteId = this.sitesProvider.getCurrentSiteId(); + + iconData.prefetchCourseIcon = 'spinner'; + + // Get the sections first if needed. + if (sections) { + promise = Promise.resolve(sections); + } else { + promise = this.courseProvider.getSections(course.id, false, true); + } + + return promise.then((sections) => { + // Confirm the download. + return this.confirmDownloadSizeSection(course.id, undefined, sections, true).then(() => { + // User confirmed, get the course handlers if needed. + if (courseHandlers) { + promise = Promise.resolve(courseHandlers); + } else { + promise = this.coursesDelegate.getHandlersToDisplay(course); + } + + return promise.then((handlers: CoreCoursesHandlerToDisplay[]) => { + // Now we have all the data, download the course. + return this.prefetchCourse(course, sections, handlers, siteId); + }).then(() => { + // Download successful. + return true; + }); + }, (error) : any => { + // User cancelled or there was an error calculating the size. + iconData.prefetchCourseIcon = initialIcon; + if (error) { + return Promise.reject(error); + } + return false; + }); + }); + }; + + /** + * Confirm and prefetches a list of courses. + * + * @param {any[]} courses List of courses to download. + * @param {Function} [onProgress] Function to call everytime a course is downloaded. + * @return {Promise} Resolved with true when downloaded, resolved with false if user cancels, rejected if error. + */ + confirmAndPrefetchCourses(courses: any[], onProgress?: (data: CoreCourseCoursesProgress) => void) : Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + // Confirm the download without checking size because it could take a while. + return this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => { + let promises = [], + total = courses.length, + count = 0; + + courses.forEach((course) => { + let subPromises = [], + sections, + handlers, + success = true; + + // Get the sections and the handlers. + subPromises.push(this.courseProvider.getSections(course.id, false, true).then((courseSections) => { + sections = courseSections; + })); + subPromises.push(this.coursesDelegate.getHandlersToDisplay(course).then((cHandlers) => { + handlers = cHandlers; + })); + + promises.push(Promise.all(subPromises).then(() => { + return this.prefetchCourse(course, sections, handlers, siteId); + }).catch((error) => { + success = false; + return Promise.reject(error); + }).finally(() => { + // Course downloaded or failed, notify the progress. + count++; + if (onProgress) { + onProgress({count: count, total: total, courseId: course.id, success: success}); + } + })); + }); + + if (onProgress) { + // Notify the start of the download. + onProgress({count: 0, total: total, success: true}); + } + + return this.utils.allPromises(promises).then(() => { + return true; + }); + }, () => { + // User cancelled. + return false; + }); + }; + + /** + * Show confirmation dialog and then remove a module files. + * + * @param {any} module Module to remove the files. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + confirmAndRemoveFiles(module: any, courseId: number) : Promise { + return this.domUtils.showConfirm(this.translate.instant('course.confirmdeletemodulefiles')).then(() => { + return this.prefetchDelegate.removeModuleFiles(module, courseId); + }); + } + + /** + * Calculate the size to download a section and show a confirm modal if needed. + * + * @param {number} courseId Course ID the section belongs to. + * @param {any} [section] Section. If not provided, all sections. + * @param {any[]} [sections] List of sections. Used when downloading all the sections. + * @param {boolean} [alwaysConfirm] True to show a confirm even if the size isn't high, false otherwise. + * @return {Promise} Promise resolved if the user confirms or there's no need to confirm. + */ + confirmDownloadSizeSection(courseId: number, section?: any, sections?: any[], alwaysConfirm?: boolean) : Promise { + let sizePromise; + + // Calculate the size of the download. + if (section && section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + sizePromise = this.prefetchDelegate.getDownloadSize(section.modules, courseId); + } else { + let promises = [], + results = { + size: 0, + total: true + }; + + sections.forEach((s) => { + if (s.id != CoreCourseProvider.ALL_SECTIONS_ID) { + promises.push(this.prefetchDelegate.getDownloadSize(s.modules, courseId).then((sectionSize) => { + results.total = results.total && sectionSize.total; + results.size += sectionSize.size; + })); + } + }); + + sizePromise = Promise.all(promises).then(() => { + return results; + }); + } + + return sizePromise.then((size) => { + // Show confirm modal if needed. + return this.domUtils.confirmDownloadSize(size, undefined, undefined, undefined, undefined, alwaysConfirm); + }); + } + + /** + * Determine the status of a list of courses. + * + * @param {any[]} courses Courses + * @return {Promise} Promise resolved with the status. + */ + determineCoursesStatus(courses: any[]) : Promise { + // Get the status of each course. + const promises = [], + siteId = this.sitesProvider.getCurrentSiteId(); + + courses.forEach((course) => { + promises.push(this.courseProvider.getCourseStatus(course.id, siteId)); + }); + + return Promise.all(promises).then((statuses) => { + // Now determine the status of the whole list. + let status = statuses[0]; + for (let i = 1; i < statuses.length; i++) { + status = this.filepoolProvider.determinePackagesStatus(status, statuses[i]); + } + return status; + }); + } + + /** + * Get a course download promise (if any). + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Download promise, undefined if not found. + */ + getCourseDownloadPromise(courseId: number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][courseId]; + } + + /** + * Get a course status icon. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the icon name. + */ + getCourseStatusIcon(courseId: number, siteId?: string) : Promise { + return this.courseProvider.getCourseStatus(courseId, siteId).then((status) => { + return this.getCourseStatusIconFromStatus(status); + }); + } + + /** + * Get a course status icon from status. + * + * @module mm.core.course + * @ngdoc method + * @name $mmCourseHelper#getCourseStatusIconFromStatus + * @param {String} status Course status. + * @return {String} Icon name. + */ + getCourseStatusIconFromStatus(status: string) : string { + if (status == CoreConstants.DOWNLOADED) { + // Always show refresh icon, we cannot knew if there's anything new in course options. + return 'refresh'; + } else if (status == CoreConstants.DOWNLOADING) { + return 'spinner'; + } else { + return 'cloud-download'; + } + } + + /** + * Get the course ID from a module instance ID, showing an error message if it can't be retrieved. + * + * @param {number} id Instance ID. + * @param {string} module Name of the module. E.g. 'glossary'. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the module's course ID. + */ + getModuleCourseIdByInstance(id: number, module: any, siteId?: string) : Promise { + return this.courseProvider.getModuleBasicInfoByInstance(id, module, siteId).then((cm) => { + return cm.course; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + return Promise.reject(null); + }); + } + + /** + * Get prefetch info for a module. + * + * @param {any} module Module to get the info from. + * @param {number} courseId Course ID the section belongs to. + * @param {boolean} [invalidateCache] Invalidates the cache first. + * @param {string} [component] Component of the module. + * @return {Promise} Promise resolved with the info. + */ + getModulePrefetchInfo(module: any, courseId: number, invalidateCache?: boolean, component?: string) + : Promise { + let moduleInfo: CoreCourseModulePrefetchInfo = {}, + siteId = this.sitesProvider.getCurrentSiteId(), + promises = []; + + if (invalidateCache) { + this.prefetchDelegate.invalidateModuleStatusCache(module); + } + + promises.push(this.prefetchDelegate.getModuleDownloadedSize(module, courseId).then((moduleSize) => { + moduleInfo.size = moduleSize; + moduleInfo.sizeReadable = this.textUtils.bytesToSize(moduleSize, 2); + })); + + // @todo: Decide what to display instead of timemodified. Last check_updates? + // promises.push(this.prefetchDelegate.getModuleTimemodified(module, courseId).then(function(moduleModified) { + // moduleInfo.timemodified = moduleModified; + // if (moduleModified > 0) { + // var now = $mmUtil.timestamp(); + // if (now - moduleModified < 7 * 86400) { + // moduleInfo.timemodifiedReadable = moment(moduleModified * 1000).fromNow(); + // } else { + // moduleInfo.timemodifiedReadable = moment(moduleModified * 1000).calendar(); + // } + // } else { + // moduleInfo.timemodifiedReadable = ""; + // } + // })); + + promises.push(this.prefetchDelegate.getModuleStatus(module, courseId).then((moduleStatus) => { + moduleInfo.status = moduleStatus; + switch (moduleStatus) { + case CoreConstants.NOT_DOWNLOADED: + moduleInfo.statusIcon = 'cloud-download'; + break; + case CoreConstants.DOWNLOADING: + moduleInfo.statusIcon = 'spinner'; + break; + case CoreConstants.OUTDATED: + moduleInfo.statusIcon = 'ion-android-refresh'; + break; + default: + moduleInfo.statusIcon = ''; + break; + } + })); + + // Get the time it was downloaded (if it was downloaded). + promises.push(this.filepoolProvider.getPackageData(siteId, component, module.id).then((data) => { + if (data && data.downloadTime && (data.status == CoreConstants.OUTDATED || data.status == CoreConstants.DOWNLOADED)) { + const now = this.timeUtils.timestamp(); + moduleInfo.downloadTime = data.downloadTime; + if (now - data.downloadTime < 7 * 86400) { + moduleInfo.downloadTimeReadable = moment(data.downloadTime * 1000).fromNow(); + } else { + moduleInfo.downloadTimeReadable = moment(data.downloadTime * 1000).calendar(); + } + } + }).catch(() => { + // Not downloaded. + moduleInfo.downloadTime = 0; + })); + + return Promise.all(promises).then(() => { + return moduleInfo; + }); + } + + /** + * Get the download ID of a section. It's used to interact with CoreCourseModulePrefetchDelegate. + * + * @param {any} section Section. + * @return {string} Section download ID. + */ + getSectionDownloadId(section: any) : string { + return 'Section-' + section.id; + } + + /** + * Prefetch all the activities in a course and also the course addons. + * + * @param {any} course The course to prefetch. + * @param {any[]} sections List of course sections. + * @param {CoreCoursesHandlerToDisplay[]} courseHandlers List of course handlers. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the download finishes. + */ + prefetchCourse(course: any, sections: any[], courseHandlers: CoreCoursesHandlerToDisplay[], siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][course.id]) { + // There's already a download ongoing for this course, return the promise. + return this.courseDwnPromises[siteId][course.id]; + } else if (!this.courseDwnPromises[siteId]) { + this.courseDwnPromises[siteId] = {}; + } + + // First of all, mark the course as being downloaded. + this.courseDwnPromises[siteId][course.id] = this.courseProvider.setCourseStatus(course.id, CoreConstants.DOWNLOADING, + siteId).then(() => { + let promises = [], + allSectionsSection = sections[0]; + + // Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections". + if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { + allSectionsSection = {id: CoreCourseProvider.ALL_SECTIONS_ID}; + } + promises.push(this.prefetchSection(allSectionsSection, course.id, sections)); + + // Prefetch course options. + courseHandlers.forEach((handler) => { + if (handler.prefetch) { + promises.push(handler.prefetch(course)); + } + }); + + return this.utils.allPromises(promises); + }).then(() => { + // Download success, mark the course as downloaded. + return this.courseProvider.setCourseStatus(course.id, CoreConstants.DOWNLOADED, siteId); + }).catch((error) => { + // Error, restore previous status. + return this.courseProvider.setCoursePreviousStatus(course.id, siteId).then(() => { + return Promise.reject(error); + }); + }).finally(() => { + delete this.courseDwnPromises[siteId][course.id]; + }); + + return this.courseDwnPromises[siteId][course.id]; + } + + /** + * Helper function to prefetch a module, showing a confirmation modal if the size is big + * and invalidating contents if refreshing. + * + * @param {handler} handler Prefetch handler to use. Must implement 'prefetch' and 'invalidateContent'. + * @param {any} module Module to download. + * @param {any} size Object containing size to download (in bytes) and a boolean to indicate if its totally calculated. + * @param {number} courseId Course ID of the module. + * @param {boolean} [refresh] True if refreshing, false otherwise. + * @return {Promise} Promise resolved when downloaded. + */ + prefetchModule(handler: any, module: any, size: any, courseId: number, refresh?: boolean) : Promise { + // Show confirmation if needed. + return this.domUtils.confirmDownloadSize(size).then(() => { + // Invalidate content if refreshing and download the data. + let promise = refresh ? handler.invalidateContent(module.id, courseId) : Promise.resolve(); + return promise.catch(() => { + // Ignore errors. + }).then(() => { + return handler.prefetch(module, courseId, true); + }); + }); + } + + /** + * Prefetch one section or all the sections. + * If the section is "All sections" it will prefetch all the sections. + * + * @param {any} section Section. + * @param {number} courseId Course ID the section belongs to. + * @param {any[]} [sections] List of sections. Used when downloading all the sections. + * @return {Promise} Promise resolved when the prefetch is finished. + */ + prefetchSection(section: any, courseId: number, sections?: any[]) : Promise { + if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + // Download only this section. + return this.prefetchSingleSectionIfNeeded(section, courseId).then(() => { + // Calculate the status of the section that finished. + return this.calculateSectionStatus(section, courseId); + }); + } else { + // Download all the sections except "All sections". + let promises = [], + allSectionsStatus; + + section.isDownloading = true; + sections.forEach((section) => { + if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + promises.push(this.prefetchSingleSectionIfNeeded(section, courseId).then(() => { + // Calculate the status of the section that finished. + return this.calculateSectionStatus(section, courseId).then((result) => { + // Calculate "All sections" status. + allSectionsStatus = this.filepoolProvider.determinePackagesStatus(allSectionsStatus, result.status); + }); + })); + } + }); + + return this.utils.allPromises(promises).then(() => { + // Set "All sections" data. + section.showDownload = allSectionsStatus === CoreConstants.NOT_DOWNLOADED; + section.showRefresh = allSectionsStatus === CoreConstants.OUTDATED; + section.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + }).finally(() => { + section.isDownloading = false; + }); + } + } + + /** + * Prefetch a certain section if it needs to be prefetched. + * If the section is "All sections" it will be ignored. + * + * @param {any} section Section to prefetch. + * @param {number} courseId Course ID the section belongs to. + * @return {Promise} Promise resolved when the section is prefetched. + */ + protected prefetchSingleSectionIfNeeded(section: any, courseId: number) : Promise { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return Promise.resolve(); + } + + section.isDownloading = true; + + // Validate the section needs to be downloaded and calculate amount of modules that need to be downloaded. + return this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id).then((result) => { + if (result.status == CoreConstants.DOWNLOADED || result.status == CoreConstants.NOT_DOWNLOADABLE) { + // Section is downloaded or not downloadable, nothing to do. + return; + } + return this.prefetchSingleSection(section, result, courseId); + }, (error) => { + section.isDownloading = false; + return Promise.reject(error); + }); + } + + /** + * Start or restore the prefetch of a section. + * If the section is "All sections" it will be ignored. + * + * @param {any} section Section to download. + * @param {any} result Result of CoreCourseModulePrefetchDelegate.getModulesStatus for this section. + * @param {number} courseId Course ID the section belongs to. + * @return {Promise} Promise resolved when the section has been prefetched. + */ + protected prefetchSingleSection(section: any, result: any, courseId: number) { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return Promise.resolve(); + } + + if (section.total > 0) { + // Already being downloaded. + return Promise.resolve(); + } + + // We only download modules with status notdownloaded, downloading or outdated. + let modules = result[CoreConstants.OUTDATED].concat(result[CoreConstants.NOT_DOWNLOADED]) + .concat(result[CoreConstants.DOWNLOADING]), + downloadId = this.getSectionDownloadId(section); + + section.isDownloading = true; + + // We prefetch all the modules to prevent incoeherences in the download count + // and also to download stale data that might not be marked as outdated. + return this.prefetchDelegate.prefetchModules(downloadId, modules, courseId, (data) => { + section.count = data.count; + section.total = data.total; + }); + } + + /** + * Check if a section has content. + * + * @param {any} section Section to check. + * @return {boolean} Whether the section has content. + */ + sectionHasContent(section: any) : boolean { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID || section.hiddenbynumsections) { + return false; + } + + return (typeof section.availabilityinfo != 'undefined' && section.availabilityinfo != '') || + section.summary != '' || (section.modules && section.modules.length > 0); + } +} diff --git a/src/core/course/providers/module-delegate.ts b/src/core/course/providers/module-delegate.ts new file mode 100644 index 000000000..6dd1a7700 --- /dev/null +++ b/src/core/course/providers/module-delegate.ts @@ -0,0 +1,361 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreCourseProvider } from './course'; +import { CoreSite } from '../../../classes/site'; + +/** + * Interface that all course module handlers must implement. + */ +export interface CoreCourseModuleHandler { + /** + * A name to identify the addon. + * @type {string} + */ + name: string; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + * @type {string} + */ + modname: string; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise; + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number) : CoreCourseModuleHandlerData; + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any) : any; +}; + +/** + * Data needed to render the module in course contents. + */ +export interface CoreCourseModuleHandlerData { + /** + * The title to display in the module. + * @type {string} + */ + title: string; + + /** + * The image to use as icon (path to the image). + * @type {string} + */ + icon?: string; + + /** + * The class to assign to the item. + * @type {string} + */ + class?: string; + + /** + * The buttons to display in the module item. + * @type {CoreCourseModuleHandlerButton[]} + */ + buttons?: CoreCourseModuleHandlerButton[]; + + /** + * Whether to display a spinner in the module item. + * @type {boolean} + */ + spinner?: boolean; + + /** + * Action to perform when the module is clicked. + * + * @param {Event} event The click event. + * @param {NavController} navCtrl NavController instance. + * @param {any} module The module object. + * @param {number} courseId The course ID. + */ + action?(event: Event, navCtrl: NavController, module: any, courseId: number) : void; +}; + +/** + * A button to display in a module item. + */ +export interface CoreCourseModuleHandlerButton { + /** + * The label to add to the button. + * @type {string} + */ + label: string; + + /** + * The name of the button icon. + * @type {string} + */ + icon: string; + + /** + * Whether the button should be hidden. + * @type {boolean} + */ + hidden?: boolean; + + /** + * The name of the button icon to use in iOS instead of "icon". + * @type {string} + */ + iosIcon?: string; + + /** + * The name of the button icon to use in MaterialDesign instead of "icon". + * @type {string} + */ + mdIcon?: string; + + /** + * Action to perform when the button is clicked. + * + * @param {Event} event The click event. + * @param {NavController} navCtrl NavController instance. + * @param {any} module The module object. + * @param {number} courseId The course ID. + */ + action(event: Event, navCtrl: NavController, module: any, courseId: number) : void; +}; + +/** + * Delegate to register module handlers. + */ +@Injectable() +export class CoreCourseModuleDelegate { + protected logger; + protected handlers: {[s: string]: CoreCourseModuleHandler} = {}; // All registered handlers. + protected enabledHandlers: {[s: string]: CoreCourseModuleHandler} = {}; // Handlers enabled for the current site. + protected lastUpdateHandlersStart: number; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + private courseProvider: CoreCourseProvider) { + this.logger = logger.getInstance('CoreCourseModuleDelegate'); + + eventsProvider.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.REMOTE_ADDONS_LOADED, this.updateHandlers.bind(this)); + } + + /** + * Get the component to render the module. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent?(course: any, module: any) : any { + let handler = this.enabledHandlers[module.modname]; + if (handler && handler.getMainComponent) { + let component = handler.getMainComponent(course, module); + if (component) { + return component; + } + } + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {string} modname The name of the module type. + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getModuleDataFor(modname: string, module: any, courseId: number, sectionId: number) : CoreCourseModuleHandlerData { + if (typeof this.enabledHandlers[modname] != 'undefined') { + return this.enabledHandlers[modname].getData(module, courseId, sectionId); + } + + // Return the default data. + let defaultData: CoreCourseModuleHandlerData = { + icon: this.courseProvider.getModuleIconSrc(module.modname), + title: module.name, + class: 'core-course-default-handler core-course-module-' + module.modname + '-handler', + action: (event: Event, navCtrl: NavController, module: any, courseId: number) => { + event.preventDefault(); + event.stopPropagation(); + + navCtrl.push('CoreCourseUnsupportedModulePage', {module: module}); + } + }; + + if (module.url) { + defaultData.buttons = [{ + icon: 'open', + label: 'core.openinbrowser', + action: (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(module.url); + } + }]; + } + + return defaultData; + }; + + /** + * Check if a module has a registered handler (not necessarily enabled). + * + * @param {string} modname The name of the module type. + * @return {boolean} If the controller is installed or not. + */ + hasHandler(modname: string) : boolean { + return typeof this.handlers[modname] !== 'undefined'; + } + + /** + * Check if a certain module type is disabled in a site. + * + * @param {string} modname The name of the module type. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether module is disabled. + */ + isModuleDisabled(modname: string, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isModuleDisabledInSite(modname, site); + }); + } + + /** + * Check if a certain module type is disabled in a site. + * + * @param {string} modname The name of the module type. + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether module is disabled. + */ + isModuleDisabledInSite(modname: string, site?: CoreSite) : boolean { + site = site || this.sitesProvider.getCurrentSite(); + + if (typeof this.handlers[modname] != 'undefined') { + return site.isFeatureDisabled('$mmCourseDelegate_' + this.handlers[modname].name); + } + return false; + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Register a handler. + * + * @param {CoreCourseModuleHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreCourseModuleHandler) : boolean { + if (typeof this.handlers[handler.modname] !== 'undefined') { + this.logger.log('There is an addon named \'' + this.handlers[handler.modname].name + + '\' already registered as handler for ' + handler.modname); + return false; + } + this.logger.log(`Registered addon '${handler.name}' for '${handler.modname}'`); + this.handlers[handler.modname] = handler; + return true; + } + + /** + * Update the handler for the current site. + * + * @param {CoreCourseModuleHandler} handler The handler to check. + * @param {number} time Time this update process started. + * @return {Promise} Resolved when done. + */ + protected updateHandler(handler: CoreCourseModuleHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(), + currentSite = this.sitesProvider.getCurrentSite(); + + if (!this.sitesProvider.isLoggedIn()) { + promise = Promise.reject(null); + } else if (currentSite.isFeatureDisabled('$mmCourseDelegate_' + handler.name)) { + promise = Promise.resolve(false); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the handler is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() === siteId) { + if (enabled) { + this.enabledHandlers[handler.modname] = handler; + } else { + delete this.enabledHandlers[handler.modname]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + protected updateHandlers() : Promise { + let promises = [], + now = Date.now(); + + this.logger.debug('Updating handlers for current site.'); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).catch(() => { + // Never reject. + }); + } +} diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts new file mode 100644 index 000000000..50b44fb64 --- /dev/null +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -0,0 +1,1316 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreFileProvider } from '../../../providers/file'; +import { CoreFilepoolProvider } from '../../../providers/filepool'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreTimeUtilsProvider } from '../../../providers/utils/time'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreCourseProvider } from './course'; +import { CoreCache } from '../../../classes/cache'; +import { CoreSiteWSPreSets } from '../../../classes/site'; +import { CoreConstants } from '../../constants'; +import { Md5 } from 'ts-md5/dist/md5'; +import { Subject, BehaviorSubject, Subscription } from 'rxjs'; + +/** + * Progress of downloading a list of modules. + */ +export type CoreCourseModulesProgress = { + /** + * Number of modules downloaded so far. + * @type {number} + */ + count: number; + + /** + * Toal of modules to download. + * @type {number} + */ + total: number; +}; + +/** + * Progress function for downloading a list of modules. + * + * @param {CoreCourseModulesProgress} data Progress data. + */ +export type CoreCourseModulesProgressFunction = (data: CoreCourseModulesProgress) => void; + +/** + * Interface that all course prefetch handlers must implement. + */ +export interface CoreCourseModulePrefetchHandler { + /** + * A name to identify the addon. + * @type {string} + */ + name: string; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + * @type {string} + */ + modname: string; + + /** + * The handler's component. + * @type {string} + */ + component: string; + + /** + * The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked + * as outdated. This RegExp is ignored if hasUpdates function is defined. + * @type {RegExp} + */ + updatesNames?: RegExp; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled() : boolean|Promise; + + /** + * Get the download size of a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(module: any, courseId: number, single?: boolean) : Promise<{size: number, total: boolean}>; + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean): Promise; + + /** + * Check if a certain module can use core_course_check_updates to check if it has updates. + * If not defined, it will assume all modules can be checked. + * The modules that return false will always be shown as outdated when they're downloaded. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can use check_updates. The promise should never be rejected. + */ + canUseCheckUpdates?(module: any, courseId: number) : boolean|Promise; + + /** + * Return the status to show based on current status. E.g. a module might want to show outdated instead of downloaded. + * If not implemented, the original status will be returned. + * + * @param {any} module Module. + * @param {string} status The current status. + * @param {boolean} canCheck Whether the site allows checking for updates. + * @return {string} Status to display. + */ + determineStatus?(module: any, status: string, canCheck: boolean) : string; + + /** + * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {number|Promise} Size, or promise resolved with the size. + */ + getDownloadedSize?(module: any, courseId: number) : number|Promise; + + /** + * Get the list of files of the module. If not defined, we'll assume they are in module.contents. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {any[]|Promise} List of files, or promise resolved with the files. + */ + getFiles?(module: any, courseId: number) : any[]|Promise; + + /** + * Check if a certain module has updates based on the result of check updates. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {any[]} moduleUpdates List of updates for the module. + * @return {boolean|Promise} Whether the module has updates. The promise should never be rejected. + */ + hasUpdates?(module: any, courseId: number, moduleUpdates: any[]) : boolean|Promise; + + /** + * Invalidate WS calls needed to determine module status. It doesn't need to invalidate check updates. + * It should NOT invalidate files nor all the prefetched data. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule?(module: any, courseId: number) : Promise; + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable?(module: any, courseId: number) : boolean|Promise; + + /** + * Load module contents in module.contents if they aren't loaded already. This is meant for resources. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + loadContents?(module: any, courseId: number) : Promise; + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + removeFiles?(module: any, courseId: number) : Promise; +}; + +/** + * Delegate to register module prefetch handlers. + */ +@Injectable() +export class CoreCourseModulePrefetchDelegate { + // Variables for database. + protected CHECK_UPDATES_TIMES_TABLE = 'check_updates_times'; + protected checkUpdatesTableSchema = { + name: this.CHECK_UPDATES_TIMES_TABLE, + columns: [ + { + name: 'courseId', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'time', + type: 'INTEGER', + notNull: true + } + ] + } + + protected ROOT_CACHE_KEY = 'mmCourse:'; + + protected logger; + protected handlers: {[s: string]: CoreCourseModulePrefetchHandler} = {}; // All registered handlers. + protected enabledHandlers: {[s: string]: CoreCourseModulePrefetchHandler} = {}; // Handlers enabled for the current site. + protected statusCache = new CoreCache(); + protected lastUpdateHandlersStart: number; + + // Promises for check updates, to prevent performing the same request twice at the same time. + protected courseUpdatesPromises: {[s: string]: {[s: string]: Promise}} = {}; + + // Promises and observables for prefetching, to prevent downloading the same section twice at the same time + // and notify the progress of the download. + protected prefetchData: {[s: string]: {[s: string]: { + promise: Promise, + observable: Subject, + subscriptions: Subscription[] + }}} = {}; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider, + private courseProvider: CoreCourseProvider, private filepoolProvider: CoreFilepoolProvider, + private timeUtils: CoreTimeUtilsProvider, private utils: CoreUtilsProvider, private fileProvider: CoreFileProvider) { + this.logger = logger.getInstance('CoreCourseModulePrefetchDelegate'); + + this.sitesProvider.createTableFromSchema(this.checkUpdatesTableSchema); + } + + /** + * Check if current site can check updates using core_course_check_updates. + * + * @return {boolean} True if can check updates, false otherwise. + */ + canCheckUpdates() : boolean { + return this.sitesProvider.getCurrentSite().wsAvailable('core_course_check_updates'); + } + + /** + * Check if a certain module can use core_course_check_updates. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with boolean: whether the module can use check updates WS. + */ + canModuleUseCheckUpdates(module: any, courseId: number) : Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + // Module not supported, cannot use check updates. + return Promise.resolve(false); + } + + if (handler.canUseCheckUpdates) { + return Promise.resolve(handler.canUseCheckUpdates(module, courseId)); + } + + // By default, modules can use check updates. + return Promise.resolve(true); + } + + /** + * Clear the status cache. + */ + clearStatusCache() : void { + this.statusCache.clear(); + } + + /** + * Creates the list of modules to check for get course updates. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID the modules belong to. + * @return {Promise<{toCheck: any[], cannotUse: any[]}>} Promise resolved with the lists. + */ + protected createToCheckList(modules: any[], courseId: number) : Promise<{toCheck: any[], cannotUse: any[]}> { + let result = { + toCheck: [], + cannotUse: [] + }, + promises = []; + + modules.forEach((module) => { + promises.push(this.getModuleStatusAndDownloadTime(module, courseId).then((data) => { + if (data.status == CoreConstants.DOWNLOADED) { + // Module is downloaded and not outdated. Check if it can check updates. + return this.canModuleUseCheckUpdates(module, courseId).then((canUse) => { + if (canUse) { + // Can use check updates, add it to the tocheck list. + result.toCheck.push({ + contextlevel: 'module', + id: module.id, + since: data.downloadTime || 0 + }); + } else { + // Cannot use check updates, add it to the cannotUse array. + result.cannotUse.push(module); + } + }); + } + }).catch(() => { + // Ignore errors. + })); + }); + + return Promise.all(promises).then(() => { + // Sort toCheck list. + result.toCheck.sort((a, b) => { + return a.id >= b.id ? 1 : -1; + }); + + return result; + }); + } + + /** + * Determines a module status based on current status, restoring downloads if needed. + * + * @param {any} module Module. + * @param {string} status Current status. + * @param {boolean} [canCheck] True if updates can be checked using core_course_check_updates. + * @return {string} Module status. + */ + determineModuleStatus(module: any, status: string, canCheck?: boolean) : string { + const handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(); + + if (handler) { + if (status == CoreConstants.DOWNLOADING) { + // Check if the download is being handled. + if (!this.filepoolProvider.getPackageDownloadPromise(siteId, handler.component, module.id)) { + // Not handled, the app was probably restarted or something weird happened. + // Re-start download (files already on queue or already downloaded will be skipped). + handler.prefetch(module); + } + } else if (handler.determineStatus) { + // The handler implements a determineStatus function. Apply it. + return handler.determineStatus(module, status, canCheck); + } + } + return status; + } + + /** + * Check for updates in a course. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID the modules belong to. + * @return {Promise} Promise resolved with the updates. If a module is set to false, it means updates cannot be + * checked for that module in the current site. + */ + getCourseUpdates(modules: any[], courseId: number) : Promise { + if (!this.canCheckUpdates()) { + return Promise.reject(null); + } + + // Check if there's already a getCourseUpdates in progress. + let id = Md5.hashAsciiStr(courseId + '#' + JSON.stringify(modules)), + siteId = this.sitesProvider.getCurrentSiteId(); + + if (this.courseUpdatesPromises[siteId] && this.courseUpdatesPromises[siteId][id]) { + // There's already a get updates ongoing, return the promise. + return this.courseUpdatesPromises[siteId][id]; + } else if (!this.courseUpdatesPromises[siteId]) { + this.courseUpdatesPromises[siteId] = {}; + } + + this.courseUpdatesPromises[siteId][id] = this.createToCheckList(modules, courseId).then((data) => { + let result = {}; + + // Mark as false the modules that cannot use check updates WS. + data.cannotUse.forEach((module) => { + result[module.id] = false; + }); + + if (!data.toCheck.length) { + // Nothing to check, no need to call the WS. + return result; + } + + // Get the site, maybe the user changed site. + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseid: courseId, + tocheck: data.toCheck + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseUpdatesCacheKey(courseId), + emergencyCache: false, // If downloaded data has changed and offline, just fail. See MOBILE-2085. + uniqueCacheKey: true + }; + + return site.read('core_course_check_updates', params, preSets).then((response) => { + if (!response || typeof response.instances == 'undefined') { + return Promise.reject(null); + } + + // Store the last execution of the check updates call. + let entry = { + courseId: courseId, + time: this.timeUtils.timestamp() + }; + site.getDb().insertOrUpdateRecord(this.CHECK_UPDATES_TIMES_TABLE, entry, {courseId: courseId}); + + return this.treatCheckUpdatesResult(data.toCheck, response, result); + }).catch((error) => { + // Cannot get updates. Get the cached entries but discard the modules with a download time higher + // than the last execution of check updates. + return site.getDb().getRecord(this.CHECK_UPDATES_TIMES_TABLE, {courseId: courseId}).then((entry) => { + preSets.getCacheUsingCacheKey = true; + preSets.omitExpires = true; + + return site.read('core_course_check_updates', params, preSets).then((response) => { + if (!response || typeof response.instances == 'undefined') { + return Promise.reject(error); + } + + return this.treatCheckUpdatesResult(data.toCheck, response, result, entry.time); + }); + }, () => { + // No previous executions, return result as it is. + return result; + }); + }); + }); + }).finally(() => { + // Get updates finished, delete the promise. + delete this.courseUpdatesPromises[siteId][id]; + }); + + return this.courseUpdatesPromises[siteId][id]; + } + + + /** + * Check for updates in a course. + * + * @param {number} courseId Course ID the modules belong to. + * @return {Promise} Promise resolved with the updates. + */ + getCourseUpdatesByCourseId(courseId: number) : Promise { + if (!this.canCheckUpdates()) { + return Promise.reject(null); + } + + // Get course sections and all their modules. + return this.courseProvider.getSections(courseId, false, true, {omitExpires: true}).then((sections) => { + return this.getCourseUpdates(this.courseProvider.getSectionsModules(sections), courseId); + }); + } + + /** + * Get cache key for course updates WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getCourseUpdatesCacheKey(courseId: number) : string { + return this.ROOT_CACHE_KEY + 'courseUpdates:' + courseId; + } + + /** + * Get modules download size. Only treat the modules with status not downloaded or outdated. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID the modules belong to. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(modules: any[], courseId: number) : Promise<{size: number, total: boolean}> { + // Get the status of each module. + return this.getModulesStatus(modules, courseId).then((data) => { + const downloadableModules = data[CoreConstants.NOT_DOWNLOADED].concat(data[CoreConstants.OUTDATED]), + promises = [], + result = { + size: 0, + total: true + }; + + downloadableModules.forEach((module) => { + promises.push(this.getModuleDownloadSize(module, courseId).then((size) => { + result.total = result.total && size.total; + result.size += size.size; + })); + }); + + return Promise.all(promises).then(() => { + return result; + }); + }); + } + + /** + * Get the download size of a module. + * + * @param {any} module Module to get size. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getModuleDownloadSize(module: any, courseId: number, single?: boolean) : Promise<{size: number, total: boolean}> { + let downloadSize, + packageId, + handler = this.getPrefetchHandlerFor(module); + + // Check if the module has a prefetch handler. + if (handler) { + return this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return {size: 0, total: true}; + } + + packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + downloadSize = this.statusCache.getValue(packageId, 'downloadSize'); + if (typeof downloadSize != 'undefined') { + return downloadSize; + } + + return Promise.resolve(handler.getDownloadSize(module, courseId, single)).then((size) => { + return this.statusCache.setValue(packageId, 'downloadSize', size); + }).catch((error) => { + const cachedSize = this.statusCache.getValue(packageId, 'downloadSize', true); + if (cachedSize) { + return cachedSize; + } + return Promise.reject(error); + }); + }); + } + + return Promise.resolve({size: 0, total: false}); + } + + /** + * Get the download size of a module. + * + * @param {any} module Module to get size. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with the size. + */ + getModuleDownloadedSize(module: any, courseId: number) : Promise { + let downloadedSize, + packageId, + promise, + handler = this.getPrefetchHandlerFor(module); + + // Check if the module has a prefetch handler. + if (handler) { + return this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return 0; + } + + packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + downloadedSize = this.statusCache.getValue(packageId, 'downloadedSize'); + if (typeof downloadedSize != 'undefined') { + return downloadedSize; + } + + if (handler.getDownloadedSize) { + // Handler implements a method to calculate the downloaded size, use it. + promise = Promise.resolve(handler.getDownloadedSize(module, courseId)); + } else { + // Handler doesn't implement it, get the module files and check if they're downloaded. + promise = this.getModuleFiles(module, courseId).then((files) => { + let siteId = this.sitesProvider.getCurrentSiteId(), + promises = [], + size = 0; + + // Retrieve file size if it's downloaded. + files.forEach((file) => { + const fileUrl = file.url || file.fileurl; + promises.push(this.filepoolProvider.getFilePathByUrl(siteId, fileUrl).then((path) => { + return this.fileProvider.getFileSize(path).catch(() => { + // Error getting size. Check if the file is being downloaded. + return this.filepoolProvider.isFileDownloadingByUrl(siteId, fileUrl).then(() => { + // If downloading, count as downloaded. + return file.filesize; + }).catch(() => { + // Not downloading and not found in disk. + return 0; + }); + }).then((fs) => { + size += fs; + }); + })); + }); + + return Promise.all(promises).then(() => { + return size; + }); + }); + } + + return promise.then((size) => { + return this.statusCache.setValue(packageId, 'downloadedSize', size); + }).catch(() => { + return this.statusCache.getValue(packageId, 'downloadedSize', true); + }); + }); + } + + return Promise.resolve(0); + } + + /** + * Get module files. + * + * @param {any} module Module to get the files. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with the list of files. + */ + getModuleFiles(module: any, courseId: number) : Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (handler.getFiles) { + // The handler defines a function to get files, use it. + return Promise.resolve(handler.getFiles(module, courseId)); + } else if (handler.loadContents) { + // The handler defines a function to load contents, use it before returning module contents. + return handler.loadContents(module, courseId).then(() => { + return module.contents; + }); + } else { + return Promise.resolve(module.contents || []); + } + } + + /** + * Get the module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {any} [updates] Result of getCourseUpdates for all modules in the course. If not provided, it will be + * calculated (slower). If it's false it means the site doesn't support check updates. + * @param {boolean} [refresh] True if it should ignore the cache. + * @param {number} [sectionId] ID of the section the module belongs to. + * @return {Promise} Promise resolved with the status. + */ + getModuleStatus(module: any, courseId: number, updates?: any, refresh?: boolean, sectionId?: number) : Promise { + let handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(), + canCheck = this.canCheckUpdates(); + + if (handler) { + // Check if the status is cached. + let component = handler.component, + packageId = this.filepoolProvider.getPackageId(component, module.id), + status = this.statusCache.getValue(packageId, 'status'), + updateStatus = true, + promise; + + if (!refresh && typeof status != 'undefined') { + return Promise.resolve(this.determineModuleStatus(module, status, canCheck)); + } + + // Check if the module is downloadable. + return this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return CoreConstants.NOT_DOWNLOADABLE; + } + + // Get the saved package status. + return this.filepoolProvider.getPackageStatus(siteId, component, module.id).then((currentStatus) => { + status = handler.determineStatus ? handler.determineStatus(module, status, canCheck) : status; + if (status != CoreConstants.DOWNLOADED) { + return status; + } + + // Module is downloaded. Determine if there are updated in the module to show them outdated. + if (typeof updates == 'undefined') { + // We don't have course updates, calculate them. + promise = this.getCourseUpdatesByCourseId(courseId); + } else if (updates === false) { + // Cannot check updates. + return Promise.resolve(); + } else { + promise = Promise.resolve(updates); + } + + return promise.then((updates) => { + if (!updates || updates[module.id] === false) { + // Cannot check updates, always show outdated. + return CoreConstants.OUTDATED; + } + + // Check if the module has any update. + return this.moduleHasUpdates(module, courseId, updates).then((hasUpdates) => { + if (!hasUpdates) { + // No updates, keep current status. + return status; + } + + // Has updates, mark the module as outdated. + status = CoreConstants.OUTDATED; + return this.filepoolProvider.storePackageStatus(siteId, component, module.id, status).catch(() => { + // Ignore errors. + }).then(() => { + return status; + }); + }).catch(() => { + // Error checking if module has updates. + const status = this.statusCache.getValue(packageId, 'status', true); + return this.determineModuleStatus(module, status, canCheck); + }); + }, () => { + // Error getting updates, show the stored status. + updateStatus = false; + return currentStatus; + }); + }); + }).then((status) => { + if (updateStatus) { + this.updateStatusCache(status, courseId, component, module.id, sectionId); + } + return this.determineModuleStatus(module, status, canCheck); + }); + } + + // No handler found, module not downloadable. + return Promise.resolve(CoreConstants.NOT_DOWNLOADABLE); + } + + /** + * Get the status of a list of modules, along with the lists of modules for each status. + * @see {@link CoreFilepoolProvider.determinePackagesStatus} + * + * @param {any[]} modules List of modules to prefetch. + * @param {number} courseId Course ID the modules belong to. + * @param {number} [sectionId] ID of the section the modules belong to. + * @param {boolean} [refresh] True if it should always check the DB (slower). + * @return {Promise} Promise resolved with an object with the following properties: + * - status (string) Status of the module. + * - total (number) Number of modules. + * - CoreConstants.NOT_DOWNLOADED (any[]) Modules with state NOT_DOWNLOADED. + * - CoreConstants.DOWNLOADED (any[]) Modules with state DOWNLOADED. + * - CoreConstants.DOWNLOADING (any[]) Modules with state DOWNLOADING. + * - CoreConstants.OUTDATED (any[]) Modules with state OUTDATED. + */ + getModulesStatus(modules: any[], courseId: number, sectionId?: number, refresh?: boolean) : any { + let promises = [], + status = CoreConstants.NOT_DOWNLOADABLE, + result: any = { + total: 0 + }; + + // Init result. + result[CoreConstants.NOT_DOWNLOADED] = []; + result[CoreConstants.DOWNLOADED] = []; + result[CoreConstants.DOWNLOADING] = []; + result[CoreConstants.OUTDATED] = []; + + // Check updates in course. Don't use getCourseUpdates because the list of modules might not be the whole course list. + return this.getCourseUpdatesByCourseId(courseId).catch(() => { + // Cannot get updates. + return false; + }).then((updates) => { + + modules.forEach((module) => { + // Check if the module has a prefetch handler. + let handler = this.getPrefetchHandlerFor(module); + if (handler) { + let packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + + promises.push(this.getModuleStatus(module, courseId, updates, refresh).then((modStatus) => { + if (modStatus != CoreConstants.NOT_DOWNLOADABLE) { + if (sectionId && sectionId > 0) { + // Store the section ID. + this.statusCache.setValue(packageId, 'sectionId', sectionId); + } + + status = this.filepoolProvider.determinePackagesStatus(status, modStatus); + result[modStatus].push(module); + result.total++; + } + }).catch((error) => { + let cacheStatus = this.statusCache.getValue(packageId, 'status', true); + if (typeof cacheStatus == 'undefined') { + return Promise.reject(error); + } + + if (cacheStatus != CoreConstants.NOT_DOWNLOADABLE) { + cacheStatus = this.filepoolProvider.determinePackagesStatus(status, cacheStatus); + result[cacheStatus].push(module); + result.total++; + } + })); + } + }); + + return Promise.all(promises).then(() => { + result.status = status; + return result; + }); + }); + } + + /** + * Get a module status and download time. It will only return the download time if the module is downloaded and not outdated. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise<{status: string, downloadTime?: number}>} Promise resolved with the data. + */ + protected getModuleStatusAndDownloadTime(module: any, courseId: number) : Promise<{status: string, downloadTime?: number}> { + let handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(); + + if (handler) { + // Get the status from the cache. + let packageId = this.filepoolProvider.getPackageId(handler.component, module.id), + status = this.statusCache.getValue(packageId, 'status'); + + if (typeof status != 'undefined' && status != CoreConstants.DOWNLOADED) { + // Status is different than downloaded, just return the status. + return Promise.resolve({ + status: status + }); + } + + // Check if the module is downloadable. + return this.isModuleDownloadable(module, courseId).then((downloadable: boolean) : any => { + if (!downloadable) { + return { + status: CoreConstants.NOT_DOWNLOADABLE + }; + } + + // Get the stored data to get the status and downloadTime. + return this.filepoolProvider.getPackageData(siteId, handler.component, module.id).then((data) => { + return { + status: data.status, + downloadTime: data.downloadTime || 0 + }; + }); + }); + } + + // No handler found, module not downloadable. + return Promise.resolve({ + status: CoreConstants.NOT_DOWNLOADABLE + }); + } + + /** + * Get a prefetch handler. + * + * @param {any} module The module to work on. + * @return {CoreCourseModulePrefetchHandler} Prefetch handler. + */ + getPrefetchHandlerFor(module: any) : CoreCourseModulePrefetchHandler { + return this.enabledHandlers[module.modname]; + } + + /** + * Invalidate check updates WS call. + * + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateCourseUpdates(courseId: number) : Promise { + return this.sitesProvider.getCurrentSite().invalidateWsCacheForKey(this.getCourseUpdatesCacheKey(courseId)); + } + + /** + * Invalidate a list of modules in a course. This should only invalidate WS calls, not downloaded files. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when modules are invalidated. + */ + invalidateModules(modules: any[], courseId: number) : Promise { + let promises = []; + + modules.forEach((module) => { + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + if (handler.invalidateModule) { + promises.push(handler.invalidateModule(module, courseId).catch(() => { + // Ignore errors. + })); + } + + // Invalidate cache. + this.invalidateModuleStatusCache(module); + } + }); + + promises.push(this.invalidateCourseUpdates(courseId)); + + return Promise.all(promises); + } + + /** + * Invalidates the cache for a given module. + * + * @param {any} module Module to be invalidated. + */ + invalidateModuleStatusCache(module: any) : void { + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + this.statusCache.invalidate(this.filepoolProvider.getPackageId(handler.component, module.id)); + } + } + + /** + * Check if a list of modules is being downloaded. + * + * @param {string} id An ID to identify the download. + * @return {boolean} True if it's being downloaded, false otherwise. + */ + isBeingDownloaded(id: string) : boolean { + const siteId = this.sitesProvider.getCurrentSiteId(); + return !!(this.prefetchData[siteId] && this.prefetchData[siteId][id]); + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Check if a module is downloadable. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with true if downloadable, false otherwise. + */ + isModuleDownloadable(module: any, courseId: number) : Promise { + let handler = this.getPrefetchHandlerFor(module), + promise; + + if (handler) { + if (typeof handler.isDownloadable == 'function') { + let packageId = this.filepoolProvider.getPackageId(handler.component, module.id), + downloadable = this.statusCache.getValue(packageId, 'downloadable'); + + if (typeof downloadable != 'undefined') { + return Promise.resolve(downloadable); + } else { + return Promise.resolve(handler.isDownloadable(module, courseId)).then((downloadable) => { + return this.statusCache.setValue(packageId, 'downloadable', downloadable); + }).catch(() => { + // Something went wrong, assume it's not downloadable. + return false; + }); + } + } else { + // Function not defined, assume it's not downloadable. + return Promise.resolve(true); + } + } else { + // No handler for module, so it's not downloadable. + return Promise.resolve(false); + } + } + + /** + * Check if a module has updates based on the result of getCourseUpdates. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {any} updates Result of getCourseUpdates. + * @return {Promise} Promise resolved with boolean: whether the module has updates. + */ + moduleHasUpdates(module: any, courseId: number, updates: any) : Promise { + let handler = this.getPrefetchHandlerFor(module), + moduleUpdates = updates[module.id]; + + if (handler && handler.hasUpdates) { + // Handler implements its own function to check the updates, use it. + return Promise.resolve(handler.hasUpdates(module, courseId, moduleUpdates)); + } else if (!moduleUpdates || !moduleUpdates.updates || !moduleUpdates.updates.length) { + // Module doesn't have any update. + return Promise.resolve(false); + } else if (handler && handler.updatesNames && handler.updatesNames.test) { + // Check the update names defined by the handler. + for (let i = 0, len = moduleUpdates.updates.length; i < len; i++) { + if (handler.updatesNames.test(moduleUpdates.updates[i].name)) { + return Promise.resolve(true); + } + } + + return Promise.resolve(false); + } + + // Handler doesn't define hasUpdates or updatesNames and there is at least 1 update. Assume it has updates. + return Promise.resolve(true); + } + + /** + * Prefetch a module. + * + * @param {any} module Module to prefetch. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved when finished. + */ + prefetchModule(module: any, courseId: number, single?: boolean) : Promise { + const handler = this.getPrefetchHandlerFor(module); + + // Check if the module has a prefetch handler. + if (handler) { + return handler.prefetch(module, courseId, single); + } + return Promise.resolve(); + } + + /** + * Prefetches a list of modules using their prefetch handlers. + * If a prefetch already exists for this site and id, returns the current promise. + * + * @param {string} id An ID to identify the download. It can be used to retrieve the download promise. + * @param {any[]} modules List of modules to prefetch. + * @param {number} courseId Course ID the modules belong to. + * @param {CoreCourseModulesProgressFunction} [onProgress] Function to call everytime a module is downloaded. + * @return {Promise} Promise resolved when all modules have been prefetched. + */ + prefetchModules(id: string, modules: any[], courseId: number, onProgress?: CoreCourseModulesProgressFunction) : Promise { + + const siteId = this.sitesProvider.getCurrentSiteId(), + currentData = this.prefetchData[siteId] && this.prefetchData[siteId][id]; + + if (currentData) { + // There's a prefetch ongoing, return the current promise. + if (onProgress) { + currentData.subscriptions.push(currentData.observable.subscribe(onProgress)); + } + return currentData.promise; + } + + let promises = [], + count = 0, + total = modules.length, + moduleIds = modules.map((module) => { + return module.id; + }); + + // Initialize the prefetch data. + const prefetchData = { + observable: new BehaviorSubject({count: count, total: total}), + promise: undefined, + subscriptions: [] + }; + + if (onProgress) { + prefetchData.observable.subscribe(onProgress); + } + + modules.forEach((module) => { + // Check if the module has a prefetch handler. + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + promises.push(this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return; + } + + return handler.prefetch(module, courseId).then(() => { + let index = moduleIds.indexOf(id); + if (index > -1) { + // It's one of the modules we were expecting to download. + moduleIds.splice(index, 1); + count++; + prefetchData.observable.next({count: count, total: total}); + } + }); + })); + } + }); + + // Set the promise. + prefetchData.promise = Promise.all(promises).finally(() => { + // Unsubscribe all observers. + prefetchData.subscriptions.forEach((subscription: Subscription) => { + subscription.unsubscribe(); + }); + delete this.prefetchData[siteId][id]; + }); + + // Store the prefetch data in the list. + if (!this.prefetchData[siteId]) { + this.prefetchData[siteId] = {}; + } + this.prefetchData[siteId][id] = prefetchData; + + return prefetchData.promise; + } + + /** + * Register a handler. + * + * @param {CoreCourseModulePrefetchHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreCourseModulePrefetchHandler) : boolean { + if (typeof this.handlers[handler.modname] !== 'undefined') { + this.logger.log('There is an addon named \'' + this.handlers[handler.modname].name + + '\' already registered as a prefetch handler for ' + handler.modname); + return false; + } + this.logger.log(`Registered addon '${handler.name}' as a prefetch handler for '${handler.modname}'`); + this.handlers[handler.modname] = handler; + return true; + } + + /** + * Remove module Files from handler. + * + * @param {any} module Module to remove the files. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + removeModuleFiles(module: any, courseId: number) : Promise { + let handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(), + promise; + + if (handler && handler.removeFiles) { + // Handler implements a method to remove the files, use it. + promise = handler.removeFiles(module, courseId); + } else { + // No method to remove files, use get files to try to remove the files. + promise = this.getModuleFiles(module, courseId).then((files) => { + let promises = []; + files.forEach((file) => { + promises.push(this.filepoolProvider.removeFileByUrl(siteId, file.url || file.fileurl).catch(() => { + // Ignore errors. + })); + }); + return Promise.all(promises); + }); + } + + return promise.then(() => { + if (handler) { + // Update status of the module. + const packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + this.statusCache.setValue(packageId, 'downloadedSize', 0); + this.filepoolProvider.storePackageStatus(siteId, handler.component, module.id, CoreConstants.NOT_DOWNLOADED); + } + }); + } + + /** + * Set an on progress function for the download of a list of modules. + * + * @param {string} id An ID to identify the download. + * @param {CoreCourseModulesProgressFunction} onProgress Function to call everytime a module is downloaded. + */ + setOnProgress(id: string, onProgress: CoreCourseModulesProgressFunction) : void { + const siteId = this.sitesProvider.getCurrentSiteId(), + currentData = this.prefetchData[siteId] && this.prefetchData[siteId][id]; + + if (currentData) { + // There's a prefetch ongoing, return the current promise. + currentData.subscriptions.push(currentData.observable.subscribe(onProgress)); + } + } + + /** + * Treat the result of the check updates WS call. + * + * @param {any[]} toCheckList List of modules to check (from createToCheckList). + * @param {any} response WS call response. + * @param {any} result Object where to store the result. + * @param {number} [previousTime] Time of the previous check updates execution. If set, modules downloaded + * after this time will be ignored. + * @return {any} Result. + */ + protected treatCheckUpdatesResult(toCheckList: any[], response: any, result: any, previousTime?: number) : any { + // Format the response to index it by module ID. + this.utils.arrayToObject(response.instances, 'id', result); + + // Treat warnings, adding the not supported modules. + response.warnings.forEach((warning) => { + if (warning.warningcode == 'missingcallback') { + result[warning.itemid] = false; + } + }); + + if (previousTime) { + // Remove from the list the modules downloaded after previousTime. + toCheckList.forEach((entry) => { + if (result[entry.id] && entry.since > previousTime) { + delete result[entry.id]; + } + }); + } + + return result; + } + + /** + * Update the enabled handlers for the current site. + * + * @param {CoreCourseModulePrefetchHandler} handler The handler to treat. + * @param {number} time Time this update process started. + * @return {Promise} Resolved when done. + */ + updateHandler(handler: CoreCourseModulePrefetchHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(); + + if (!siteId) { + promise = Promise.reject(null); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the prefetch is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() == siteId) { + if (enabled) { + this.enabledHandlers[handler.modname] = handler; + } else { + delete this.enabledHandlers[handler.modname]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + updateHandlers() : Promise { + const promises = [], + now = Date.now(); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).catch(() => { + // Never reject. + }); + } + + /** + * Update the status of a module in the "cache". + * + * @param {string} status New status. + * @param {number} courseId Course ID of the module. + * @param {string} component Package's component. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {number} [sectionId] Section ID of the module. + */ + updateStatusCache(status: string, courseId: number, component: string, componentId?: string|number, sectionId?: number) : void { + let notify, + packageId = this.filepoolProvider.getPackageId(component, componentId), + cachedStatus = this.statusCache.getValue(packageId, 'status', true); + + // If the status has changed, notify that the section has changed. + notify = typeof cachedStatus != 'undefined' && cachedStatus !== status; + + if (notify) { + if (!sectionId) { + sectionId = this.statusCache.getValue(packageId, 'sectionId', true); + } + + // Invalidate and set again. + this.statusCache.invalidate(packageId); + this.statusCache.setValue(packageId, 'status', status); + + if (sectionId) { + this.statusCache.setValue(packageId, 'sectionId', sectionId); + + this.eventsProvider.trigger(CoreEventsProvider.SECTION_STATUS_CHANGED, { + sectionId: sectionId, + courseId: courseId + }, this.sitesProvider.getCurrentSiteId()); + } + } else { + this.statusCache.setValue(packageId, 'status', status); + } + } +} diff --git a/src/core/courses/components/course-progress/course-progress.html b/src/core/courses/components/course-progress/course-progress.html index 08b954147..6eb3c2cd5 100644 --- a/src/core/courses/components/course-progress/course-progress.html +++ b/src/core/courses/components/course-progress/course-progress.html @@ -1,13 +1,18 @@ - -

- - - - -
+ + +

diff --git a/src/core/courses/components/course-progress/course-progress.scss b/src/core/courses/components/course-progress/course-progress.scss index 4587fe8ca..d115cdd3b 100644 --- a/src/core/courses/components/course-progress/course-progress.scss +++ b/src/core/courses/components/course-progress/course-progress.scss @@ -1,12 +1,34 @@ -core-courses-course-progress.core-courseoverview { - @media (max-width: 576px) { +core-courses-course-progress { + &.core-courseoverview { + @media (max-width: 576px) { + ion-card.card { + margin: 0; + border-radius: 0; + box-shadow: none; + border-bottom: 1px solid $list-border-color; + width: 100%; + height: 100% !important; + } + } + ion-card.card { - margin: 0; - border-radius: 0; - box-shadow: none; - border-bottom: 1px solid $list-border-color; - width: 100%; - height: 100% !important; + display: flex; + flex-direction: column; + justify-content: space-between; + } + } + + button { + z-index: 1; + } + + .core-course-link { + display: flex; + align-items: center; + justify-content: space-between; + + h2 { + flex-grow: 1; } } } diff --git a/src/core/courses/components/course-progress/course-progress.ts b/src/core/courses/components/course-progress/course-progress.ts index e7a2fb49c..2084e6113 100644 --- a/src/core/courses/components/course-progress/course-progress.ts +++ b/src/core/courses/components/course-progress/course-progress.ts @@ -12,9 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, OnDestroy } from '@angular/core'; import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCourseFormatDelegate } from '../../../course/providers/format-delegate'; +import { CoreCourseProvider } from '../../../course/providers/course'; +import { CoreCourseHelperProvider } from '../../../course/providers/helper'; /** * This component is meant to display a course for a list of courses with progress. @@ -28,38 +34,85 @@ import { TranslateService } from '@ngx-translate/core'; selector: 'core-courses-course-progress', templateUrl: 'course-progress.html' }) -export class CoreCoursesCourseProgressComponent implements OnInit { +export class CoreCoursesCourseProgressComponent implements OnInit, OnDestroy { @Input() course: any; // The course to render. isDownloading: boolean; - - protected obsStatus; - protected downloadText; - protected downloadingText; - protected downloadButton = { - isDownload: true, - className: 'core-download-course', - priority: 1000 + prefetchCourseData = { + prefetchCourseIcon: 'spinner' }; - protected buttons; - constructor(private navCtrl: NavController, private translate: TranslateService) { - this.downloadText = this.translate.instant('core.course.downloadcourse'); - this.downloadingText = this.translate.instant('core.downloading'); + protected isDestroyed = false; + protected courseStatusObserver; + + constructor(private navCtrl: NavController, private translate: TranslateService, private courseHelper: CoreCourseHelperProvider, + private courseFormatDelegate: CoreCourseFormatDelegate, private domUtils: CoreDomUtilsProvider, + private courseProvider: CoreCourseProvider, eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider) { + // Listen for status change in course. + this.courseStatusObserver = eventsProvider.on(CoreEventsProvider.COURSE_STATUS_CHANGED, (data) => { + if (data.courseId == this.course.id) { + this.prefetchCourseData.prefetchCourseIcon = this.courseHelper.getCourseStatusIconFromStatus(data.status); + } + }, sitesProvider.getCurrentSiteId()); } /** * Component being initialized. */ ngOnInit() { - // @todo: Handle course prefetch. + // Determine course prefetch icon. + this.courseHelper.getCourseStatusIcon(this.course.id).then((icon) => { + this.prefetchCourseData.prefetchCourseIcon = icon; + + if (icon == 'spinner') { + // Course is being downloaded. Get the download promise. + const promise = this.courseHelper.getCourseDownloadPromise(this.course.id); + if (promise) { + // There is a download promise. If it fails, show an error. + promise.catch((error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }); + } else { + // No download, this probably means that the app was closed while downloading. Set previous status. + this.courseProvider.setCoursePreviousStatus(this.course.id); + } + } + }); } /** * Open a course. */ openCourse(course) { - this.navCtrl.push('CoreCourseSectionPage', {course: course}); + this.courseFormatDelegate.openCourse(this.navCtrl, course); } + /** + * Prefetch the course. + * + * @param {Event} e Click event. + */ + prefetchCourse(e: Event) { + e.preventDefault(); + e.stopPropagation(); + + this.courseHelper.confirmAndPrefetchCourse(this.prefetchCourseData, this.course).catch((error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }) + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + this.isDestroyed = true; + + if (this.courseStatusObserver) { + this.courseStatusObserver.off(); + } + } } diff --git a/src/core/courses/components/overview-events/overview-events.html b/src/core/courses/components/overview-events/overview-events.html index e39c479b2..75abaa680 100644 --- a/src/core/courses/components/overview-events/overview-events.html +++ b/src/core/courses/components/overview-events/overview-events.html @@ -1,7 +1,7 @@ - -

+ +

{{event.timesort * 1000 | coreFormatDate:"dfmediumdate" }}

- +

{{ 'core.courses.paypalaccepted' | translate }}

{{ 'core.paymentinstant' | translate }}

@@ -40,12 +40,11 @@

{{ 'core.courses.notenrollable' | translate }}

- +

{{ 'core.course.contents' | translate }}

diff --git a/src/core/courses/pages/course-preview/course-preview.ts b/src/core/courses/pages/course-preview/course-preview.ts index a1b10ed7c..2f99a0282 100644 --- a/src/core/courses/pages/course-preview/course-preview.ts +++ b/src/core/courses/pages/course-preview/course-preview.ts @@ -22,6 +22,8 @@ import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; import { CoreCoursesProvider } from '../../providers/courses'; import { CoreCoursesDelegate } from '../../providers/delegate'; +import { CoreCourseProvider } from '../../../course/providers/course'; +import { CoreCourseHelperProvider } from '../../../course/providers/helper'; /** * Page that allows "previewing" a course and enrolling in it if enabled and not enrolled. @@ -40,7 +42,9 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { selfEnrolInstances: any[] = []; paypalEnabled: boolean; dataLoaded: boolean; - prefetchCourseIcon: string; + prefetchCourseData = { + prefetchCourseIcon: 'spinner' + }; protected guestWSAvailable: boolean; protected isGuestEnabled: boolean = false; @@ -55,15 +59,24 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { protected selfEnrolModal: Modal; protected pageDestroyed = false; protected currentInstanceId: number; + protected courseStatusObserver; constructor(private navCtrl: NavController, navParams: NavParams, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, appProvider: CoreAppProvider, private coursesProvider: CoreCoursesProvider, private platform: Platform, private modalCtrl: ModalController, private translate: TranslateService, private eventsProvider: CoreEventsProvider, - private coursesDelegate: CoreCoursesDelegate) { + private coursesDelegate: CoreCoursesDelegate, private courseHelper: CoreCourseHelperProvider, + private courseProvider: CoreCourseProvider) { this.course = navParams.get('course'); this.isMobile = appProvider.isMobile(); this.isDesktop = appProvider.isDesktop(); + + // Listen for status change in course. + this.courseStatusObserver = eventsProvider.on(CoreEventsProvider.COURSE_STATUS_CHANGED, (data) => { + if (data.courseId == this.course.id) { + this.prefetchCourseData.prefetchCourseIcon = this.courseHelper.getCourseStatusIconFromStatus(data.status); + } + }, sitesProvider.getCurrentSiteId()); } /** @@ -88,7 +101,26 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { }); this.getCourse().finally(() => { - // @todo: Prefetch course. + // Determine course prefetch icon. + this.courseHelper.getCourseStatusIcon(this.course.id).then((icon) => { + this.prefetchCourseData.prefetchCourseIcon = icon; + + if (icon == 'spinner') { + // Course is being downloaded. Get the download promise. + let promise = this.courseHelper.getCourseDownloadPromise(this.course.id); + if (promise) { + // There is a download promise. If it fails, show an error. + promise.catch((error) => { + if (!this.pageDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }); + } else { + // No download, this probably means that the app was closed while downloading. Set previous status. + this.courseProvider.setCoursePreviousStatus(this.course.id); + } + } + }); }); } @@ -97,6 +129,10 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { */ ngOnDestroy() { this.pageDestroyed = true; + + if (this.courseStatusObserver) { + this.courseStatusObserver.off(); + } } /** @@ -387,4 +423,16 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { }); }); } + + /** + * Prefetch the course. + */ + prefetchCourse() { + this.courseHelper.confirmAndPrefetchCourse(this.prefetchCourseData, this.course, undefined, this.course._handlers) + .catch((error) => { + if (!this.pageDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }) + } } diff --git a/src/core/courses/pages/my-courses/my-courses.html b/src/core/courses/pages/my-courses/my-courses.html index 7a2eed8d4..b93f51ee9 100644 --- a/src/core/courses/pages/my-courses/my-courses.html +++ b/src/core/courses/pages/my-courses/my-courses.html @@ -7,6 +7,7 @@ + diff --git a/src/core/courses/pages/my-courses/my-courses.ts b/src/core/courses/pages/my-courses/my-courses.ts index 639302b69..1c19435d0 100644 --- a/src/core/courses/pages/my-courses/my-courses.ts +++ b/src/core/courses/pages/my-courses/my-courses.ts @@ -18,6 +18,7 @@ import { CoreEventsProvider } from '../../../../providers/events'; import { CoreSitesProvider } from '../../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; import { CoreCoursesProvider } from '../../providers/courses'; +import { CoreCourseHelperProvider } from '../../../course/providers/helper'; /** * Page that displays the list of courses the user is enrolled in. @@ -34,14 +35,16 @@ export class CoreCoursesMyCoursesPage implements OnDestroy { filter = ''; showFilter = false; coursesLoaded = false; + prefetchCoursesData: any = {}; protected prefetchIconInitialized = false; protected myCoursesObserver; protected siteUpdatedObserver; + protected isDestroyed = false; constructor(private navCtrl: NavController, private coursesProvider: CoreCoursesProvider, private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, - private sitesProvider: CoreSitesProvider) {} + private sitesProvider: CoreSitesProvider, private courseHelper: CoreCourseHelperProvider) {} /** * View loaded. @@ -81,7 +84,7 @@ export class CoreCoursesMyCoursesPage implements OnDestroy { this.filteredCourses = this.courses; this.filter = ''; - // this.initPrefetchCoursesIcon(); + this.initPrefetchCoursesIcon(); }); }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); @@ -139,10 +142,60 @@ export class CoreCoursesMyCoursesPage implements OnDestroy { } } + /** + * Prefetch all the courses. + */ + prefetchCourses() { + let initialIcon = this.prefetchCoursesData.icon; + + this.prefetchCoursesData.icon = 'spinner'; + this.prefetchCoursesData.badge = ''; + return this.courseHelper.confirmAndPrefetchCourses(this.courses, (progress) => { + this.prefetchCoursesData.badge = progress.count + ' / ' + progress.total; + }).then((downloaded) => { + this.prefetchCoursesData.icon = downloaded ? 'ion-android-refresh' : initialIcon; + }, (error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + this.prefetchCoursesData.icon = initialIcon; + } + }).finally(() => { + this.prefetchCoursesData.badge = ''; + }); + } + + /** + * Initialize the prefetch icon for the list of courses. + */ + protected initPrefetchCoursesIcon() { + if (this.prefetchIconInitialized) { + // Already initialized. + return; + } + + this.prefetchIconInitialized = true; + + if (!this.courses || this.courses.length < 2) { + // Not enough courses. + this.prefetchCoursesData.icon = ''; + return; + } + + this.courseHelper.determineCoursesStatus(this.courses).then((status) => { + let icon = this.courseHelper.getCourseStatusIconFromStatus(status); + if (icon == 'spinner') { + // It seems all courses are being downloaded, show a download button instead. + icon = 'cloud-download'; + } + this.prefetchCoursesData.icon = icon; + }); + } + /** * Page destroyed. */ ngOnDestroy() { + this.isDestroyed = true; this.myCoursesObserver && this.myCoursesObserver.off(); this.siteUpdatedObserver && this.siteUpdatedObserver.off(); } diff --git a/src/core/courses/pages/my-overview/my-overview.html b/src/core/courses/pages/my-overview/my-overview.html index 976a68284..4744a669e 100644 --- a/src/core/courses/pages/my-overview/my-overview.html +++ b/src/core/courses/pages/my-overview/my-overview.html @@ -17,64 +17,74 @@ - -
+ + + +
+ + {{ 'core.courses.sortbydates' | translate }} + {{ 'core.courses.sortbycourses' | translate }} + +
+ + + + + + + + + + + + + + + +
-
-
- - {{ 'core.courses.sortbydates' | translate }} - {{ 'core.courses.sortbycourses' | translate }} - -
- - - - - - - - - - - - - - - -
- -
- - {{ 'core.courses.inprogress' | translate }} - {{ 'core.courses.future' | translate }} - {{ 'core.courses.past' | translate }} - - -
-
- - - - -
-
- - - - - - - + + + + +
+ + {{ 'core.courses.inprogress' | translate }} + {{ 'core.courses.future' | translate }} + {{ 'core.courses.past' | translate }} + + +
+ + + {{prefetchCoursesData[courses.selected].badge}} +
+
+ +
+ + + + +
+ +
+ + + + + + + + + + + +
+
+
+ - - - -
-
diff --git a/src/core/courses/pages/my-overview/my-overview.ts b/src/core/courses/pages/my-overview/my-overview.ts index 21a7c423d..fb5183d40 100644 --- a/src/core/courses/pages/my-overview/my-overview.ts +++ b/src/core/courses/pages/my-overview/my-overview.ts @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { IonicPage, NavController } from 'ionic-angular'; import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; import { CoreCoursesProvider } from '../../providers/courses'; import { CoreCoursesMyOverviewProvider } from '../../providers/my-overview'; +import { CoreCourseHelperProvider } from '../../../course/providers/helper'; import * as moment from 'moment'; /** @@ -27,7 +28,7 @@ import * as moment from 'moment'; selector: 'page-core-courses-my-overview', templateUrl: 'my-overview.html', }) -export class CoreCoursesMyOverviewPage { +export class CoreCoursesMyOverviewPage implements OnDestroy { tabShown = 'courses'; timeline = { sort: 'sortbydates', @@ -51,23 +52,25 @@ export class CoreCoursesMyOverviewPage { showFilter = false; searchEnabled: boolean; filteredCourses: any[]; + tabs = []; + prefetchCoursesData = { + inprogress: {}, + past: {}, + future: {} + }; - protected prefetchIconInitialized = false; - protected myCoursesObserver; - protected siteUpdatedObserver; + protected prefetchIconsInitialized = false; + protected isDestroyed; constructor(private navCtrl: NavController, private coursesProvider: CoreCoursesProvider, - private domUtils: CoreDomUtilsProvider, private myOverviewProvider: CoreCoursesMyOverviewProvider) {} + private domUtils: CoreDomUtilsProvider, private myOverviewProvider: CoreCoursesMyOverviewProvider, + private courseHelper: CoreCourseHelperProvider) {} /** * View loaded. */ ionViewDidLoad() { this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); - - this.switchTab(this.tabShown); - - // @todo: Course download. } /** @@ -145,6 +148,8 @@ export class CoreCoursesMyOverviewPage { this.courses.filter = ''; this.showFilter = false; this.filteredCourses = this.courses[this.courses.selected]; + + this.initPrefetchCoursesIcons(); }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'Error getting my overview data.'); }); @@ -230,6 +235,7 @@ export class CoreCoursesMyOverviewPage { } break; case 'courses': + this.prefetchIconsInitialized = false; return this.fetchMyOverviewCourses(); } }).finally(() => { @@ -260,11 +266,11 @@ export class CoreCoursesMyOverviewPage { } /** - * Change tab being viewed. + * The tab has changed. * - * @param {string} tab Tab to display. + * @param {string} tab Name of the new tab. */ - switchTab(tab: string) { + tabChanged(tab: string) { this.tabShown = tab; switch (this.tabShown) { case 'timeline': @@ -316,4 +322,65 @@ export class CoreCoursesMyOverviewPage { selectedChanged() { this.filteredCourses = this.courses[this.courses.selected]; } + + /** + * Prefetch all the shown courses. + */ + prefetchCourses() { + let selected = this.courses.selected, + selectedData = this.prefetchCoursesData[selected], + initialIcon = selectedData.icon; + + selectedData.icon = 'spinner'; + selectedData.badge = ''; + return this.courseHelper.confirmAndPrefetchCourses(this.courses[selected], (progress) => { + selectedData.badge = progress.count + ' / ' + progress.total; + }).then((downloaded) => { + selectedData.icon = downloaded ? 'refresh' : initialIcon; + }, (error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + selectedData.icon = initialIcon; + } + }).finally(() => { + selectedData.badge = ''; + }); + } + + /** + * Initialize the prefetch icon for selected courses. + */ + protected initPrefetchCoursesIcons() { + if (this.prefetchIconsInitialized) { + // Already initialized. + return; + } + + this.prefetchIconsInitialized = true; + + Object.keys(this.prefetchCoursesData).forEach((filter) => { + if (!this.courses[filter] || this.courses[filter].length < 2) { + // Not enough courses. + this.prefetchCoursesData[filter].icon = ''; + return; + } + + this.courseHelper.determineCoursesStatus(this.courses[filter]).then((status) => { + let icon = this.courseHelper.getCourseStatusIconFromStatus(status); + if (icon == 'spinner') { + // It seems all courses are being downloaded, show a download button instead. + icon = 'cloud-download'; + } + this.prefetchCoursesData[filter].icon = icon; + }); + + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy() { + this.isDestroyed = true; + } } diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts index 9e8e0623b..ebb15e8d8 100644 --- a/src/core/courses/providers/courses.ts +++ b/src/core/courses/providers/courses.ts @@ -28,6 +28,7 @@ export class CoreCoursesProvider { public static EVENT_MY_COURSES_REFRESHED = 'courses_my_courses_refreshed'; public static ACCESS_GUEST = 'courses_access_guest'; public static ACCESS_DEFAULT = 'courses_access_default'; + protected ROOT_CACHE_KEY = 'mmCourses:'; protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { @@ -68,7 +69,7 @@ export class CoreCoursesProvider { * @return {string} Cache key. */ protected getCategoriesCacheKey(categoryId: number, addSubcategories?: boolean) : string { - return this.getRootCacheKey() + 'categories:' + categoryId + ':' + !!addSubcategories; + return this.ROOT_CACHE_KEY + 'categories:' + categoryId + ':' + !!addSubcategories; } /** @@ -121,15 +122,6 @@ export class CoreCoursesProvider { }); } - /** - * Get the root cache key for the WS calls related to courses. - * - * @return {string} Root cache key. - */ - protected getRootCacheKey() : string { - return 'mmCourses:'; - } - /** * Check if My Courses is disabled in a certain site. * @@ -219,7 +211,7 @@ export class CoreCoursesProvider { * @return {string} Cache key. */ protected getCourseEnrolmentMethodsCacheKey(id: number) : string { - return this.getRootCacheKey() + 'enrolmentmethods:' + id; + return this.ROOT_CACHE_KEY + 'enrolmentmethods:' + id; } /** @@ -251,7 +243,7 @@ export class CoreCoursesProvider { * @return {string} Cache key. */ protected getCourseGuestEnrolmentInfoCacheKey(instanceId: number) : string { - return this.getRootCacheKey() + 'guestinfo:' + instanceId; + return this.ROOT_CACHE_KEY + 'guestinfo:' + instanceId; } /** @@ -291,7 +283,7 @@ export class CoreCoursesProvider { * @return {string} Cache key. */ protected getCoursesCacheKey(ids: number[]) : string { - return this.getRootCacheKey() + 'course:' + JSON.stringify(ids); + return this.ROOT_CACHE_KEY + 'course:' + JSON.stringify(ids); } /** @@ -352,7 +344,7 @@ export class CoreCoursesProvider { protected getCoursesByFieldCacheKey(field?: string, value?: any) : string { field = field || ''; value = field ? value : ''; - return this.getRootCacheKey() + 'coursesbyfield:' + field + ':' + value; + return this.ROOT_CACHE_KEY + 'coursesbyfield:' + field + ':' + value; } /** @@ -408,7 +400,7 @@ export class CoreCoursesProvider { * @return {string} Cache key. */ protected getUserAdministrationOptionsCommonCacheKey() : string { - return this.getRootCacheKey() + 'administrationOptions:'; + return this.ROOT_CACHE_KEY + 'administrationOptions:'; } /** @@ -451,7 +443,7 @@ export class CoreCoursesProvider { * @return {string} Cache key. */ protected getUserNavigationOptionsCommonCacheKey() : string { - return this.getRootCacheKey() + 'navigationOptions:'; + return this.ROOT_CACHE_KEY + 'navigationOptions:'; } /** @@ -566,7 +558,7 @@ export class CoreCoursesProvider { * @return {string} Cache key. */ protected getUserCoursesCacheKey() : string { - return this.getRootCacheKey() + 'usercourses'; + return this.ROOT_CACHE_KEY + 'usercourses'; } /** diff --git a/src/core/courses/providers/delegate.ts b/src/core/courses/providers/delegate.ts index 68c11e4dd..577047f8e 100644 --- a/src/core/courses/providers/delegate.ts +++ b/src/core/courses/providers/delegate.ts @@ -19,33 +19,132 @@ import { CoreSitesProvider } from '../../../providers/sites'; import { CoreUtilsProvider, PromiseDefer } from '../../../providers/utils/utils'; import { CoreCoursesProvider } from './courses'; +/** + * Interface that all courses handlers must implement. + */ export interface CoreCoursesHandler { - name: string; // Name of the handler. - priority: number; // The highest priority is displayed first. - isEnabled(): boolean|Promise; // Whether or not the handler is enabled on a site level. - isEnabledForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : - boolean|Promise; // Whether the handler is enabled on a course level. For perfomance reasons, do NOT call - // WebServices in here, call them in shouldDisplayForCourse. - shouldDisplayForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : - boolean|Promise; // Whether the handler should be displayed in a course. If not implemented, assume it's true. - getDisplayData?(courseId: number): CoreCoursesHandlerData; // Returns the data needed to render the handler. - invalidateEnabledForCourse?(courseId: number, navOptions?: any, admOptions?: any) : Promise; // Should invalidate data - // to determine if handler is enabled for a certain course. - prefetch?(course: any) : Promise; // Will be called when a course is downloaded, and it should prefetch all the data - // to be able to see the addon in offline. + /** + * Name of the handler. + * @type {string} + */ + name: string; + + /** + * The highest priority is displayed first. + * @type {number} + */ + priority: number; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise; + + /** + * Whether or not the handler is enabled for a certain course. + * For perfomance reasons, do NOT call WebServices in here, call them in shouldDisplayForCourse. + * + * @param {number} courseId The course ID. + * @param {any} accessData Access type and data. Default, guest, ... + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabledForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : boolean|Promise; + + /** + * Whether or not the handler should be displayed for a course. If not implemented, assume it's true. + * + * @param {number} courseId The course ID. + * @param {any} accessData Access type and data. Default, guest, ... + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + shouldDisplayForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : boolean|Promise; + + /** + * Returns the data needed to render the handler. + * + * @param {number} courseId The course ID. + * @return {CoreCoursesHandlerData} Data. + */ + getDisplayData?(courseId: number): CoreCoursesHandlerData; + + /** + * Should invalidate the data to determine if the handler is enabled for a certain course. + * + * @param {number} courseId The course ID. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved when done. + */ + invalidateEnabledForCourse?(courseId: number, navOptions?: any, admOptions?: any) : Promise; + + /** + * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. + * + * @param {any} course The course. + * @return {Promise} Promise resolved when done. + */ + prefetch?(course: any) : Promise; }; +/** + * Data needed to render a course handler. It's returned by the handler. + */ export interface CoreCoursesHandlerData { - title: string; // Title to display for the handler. - icon: string; // Name of the icon to display for the handler. - class?: string; // Class to add to the displayed handler. - action(course: any): void; // Action to perform when the handler is clicked. + /** + * Title to display for the handler. + * @type {string} + */ + title: string; + + /** + * Name of the icon to display for the handler. + * @type {string} + */ + icon: string; + + /** + * Class to add to the displayed handler. + * @type {string} + */ + class?: string; + + /** + * Action to perform when the handler is clicked. + * + * @param {any} course The course. + */ + action(course: any): void; }; +/** + * Data returned by the delegate for each handler. + */ export interface CoreCoursesHandlerToDisplay { - data: CoreCoursesHandlerData; // Data to display. - priority?: number; // Handler's priority. - prefetch?(course: any) : Promise; // Function to prefetch the handler. + /** + * Data to display. + * @type {CoreCoursesHandlerData} + */ + data: CoreCoursesHandlerData; + + /** + * The highest priority is displayed first. + * @type {number} + */ + priority?: number; + + /** + * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. + * + * @param {any} course The course. + * @return {Promise} Promise resolved when done. + */ + prefetch?(course: any) : Promise; }; /** diff --git a/src/core/courses/providers/my-overview.ts b/src/core/courses/providers/my-overview.ts index aab67f913..a6b785fde 100644 --- a/src/core/courses/providers/my-overview.ts +++ b/src/core/courses/providers/my-overview.ts @@ -24,6 +24,7 @@ import * as moment from 'moment'; export class CoreCoursesMyOverviewProvider { public static EVENTS_LIMIT = 20; public static EVENTS_LIMIT_PER_COURSE = 10; + protected ROOT_CACHE_KEY = 'myoverview:'; constructor(private sitesProvider: CoreSitesProvider) {} @@ -113,7 +114,7 @@ export class CoreCoursesMyOverviewProvider { * @return {string} Cache key. */ protected getActionEventsByCoursesCacheKey() : string { - return this.getRootCacheKey() + 'bycourse'; + return this.ROOT_CACHE_KEY + 'bycourse'; } /** @@ -165,7 +166,7 @@ export class CoreCoursesMyOverviewProvider { * @return {string} Cache key. */ protected getActionEventsByTimesortPrefixCacheKey() : string { - return this.getRootCacheKey() + 'bytimesort:'; + return this.ROOT_CACHE_KEY + 'bytimesort:'; } /** @@ -181,15 +182,6 @@ export class CoreCoursesMyOverviewProvider { return this.getActionEventsByTimesortPrefixCacheKey() + afterEventId + ':' + limit; } - /** - * Get the root cache key for the WS calls related to overview. - * - * @return {string} Root cache key. - */ - protected getRootCacheKey() : string { - return 'myoverview:'; - } - /** * Invalidates get calendar action events for a given list of courses WS call. * diff --git a/src/core/emulator/pages/capture-media/capture-media.ts b/src/core/emulator/pages/capture-media/capture-media.ts index e94d15be5..353648b3e 100644 --- a/src/core/emulator/pages/capture-media/capture-media.ts +++ b/src/core/emulator/pages/capture-media/capture-media.ts @@ -354,7 +354,7 @@ export class CoreEmulatorCaptureMediaPage implements OnInit, OnDestroy { // Create the file and return it. let fileName = this.type + '_' + this.timeUtils.readableTimestamp() + '.' + this.extension, - path = this.textUtils.concatenatePaths(this.fileProvider.TMPFOLDER, 'media/' + fileName); + path = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, 'media/' + fileName); let loadingModal = this.domUtils.showModalLoading(); diff --git a/src/core/emulator/providers/capture-helper.ts b/src/core/emulator/providers/capture-helper.ts index 3d03605ba..d71a44690 100644 --- a/src/core/emulator/providers/capture-helper.ts +++ b/src/core/emulator/providers/capture-helper.ts @@ -44,11 +44,9 @@ export class CoreEmulatorCaptureHelperProvider { /** * Capture media (image, audio, video). * - * @param {String} type Type of media: image, audio, video. - * @param {Function} successCallback Function called when media taken. - * @param {Function} errorCallback Function called when error or cancel. - * @param {Object} [options] Optional options. - * @return {Void} + * @param {string} type Type of media: image, audio, video. + * @param {any} [options] Optional options. + * @return {Promise} Promise resolved when captured, rejected if error. */ captureMedia(type: string, options: any) : Promise { options = options || {}; diff --git a/src/core/emulator/providers/local-notifications.ts b/src/core/emulator/providers/local-notifications.ts index d7eef13db..2d8e27af1 100644 --- a/src/core/emulator/providers/local-notifications.ts +++ b/src/core/emulator/providers/local-notifications.ts @@ -466,7 +466,7 @@ export class LocalNotificationsMock extends LocalNotifications { // Schedule the notification again unless it should have been triggered more than an hour ago. delete notification.triggered; notification.at = notification.at * 1000; - if (notification.at - Date.now() > - CoreConstants.secondsHour * 1000) { + if (notification.at - Date.now() > - CoreConstants.SECONDS_HOUR * 1000) { this.schedule(notification); } } @@ -551,19 +551,19 @@ export class LocalNotificationsMock extends LocalNotifications { } else if (every == 'second') { interval = 1000; } else if (every == 'minute') { - interval = CoreConstants.secondsMinute * 1000; + interval = CoreConstants.SECONDS_MINUTE * 1000; } else if (every == 'hour') { - interval = CoreConstants.secondsHour * 1000; + interval = CoreConstants.SECONDS_HOUR * 1000; } else if (every == 'day') { - interval = CoreConstants.secondsDay * 1000; + interval = CoreConstants.SECONDS_DAY * 1000; } else if (every == 'week') { - interval = CoreConstants.secondsDay * 7 * 1000; + interval = CoreConstants.SECONDS_DAY * 7 * 1000; } else if (every == 'month') { - interval = CoreConstants.secondsDay * 31 * 1000; + interval = CoreConstants.SECONDS_DAY * 31 * 1000; } else if (every == 'quarter') { - interval = CoreConstants.secondsHour * 2190 * 1000; + interval = CoreConstants.SECONDS_HOUR * 2190 * 1000; } else if (every == 'year') { - interval = CoreConstants.secondsYear * 1000; + interval = CoreConstants.SECONDS_YEAR * 1000; } else { interval = parseInt(every, 10); if (isNaN(interval)) { @@ -713,7 +713,7 @@ export class LocalNotificationsMock extends LocalNotifications { tag: notification.id + '', template: this.tileTemplate, strings: [notification.title, notification.text, notification.title, notification.text, notification.title, notification.text], - expirationTime: new Date(Date.now() + CoreConstants.secondsHour * 1000) // Expire in 1 hour. + expirationTime: new Date(Date.now() + CoreConstants.SECONDS_HOUR * 1000) // Expire in 1 hour. }) tileNotif.show() diff --git a/src/core/fileuploader/providers/delegate.ts b/src/core/fileuploader/providers/delegate.ts index 7cedfcb50..f266ea6e1 100644 --- a/src/core/fileuploader/providers/delegate.ts +++ b/src/core/fileuploader/providers/delegate.ts @@ -165,7 +165,7 @@ export class CoreFileUploaderDelegate { protected lastUpdateHandlersStart: number; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider) { - this.logger = logger.getInstance('CoreCourseModuleDelegate'); + this.logger = logger.getInstance('CoreFileUploaderDelegate'); eventsProvider.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); diff --git a/src/core/fileuploader/providers/fileuploader.ts b/src/core/fileuploader/providers/fileuploader.ts index 06c3716ba..0b1ba9093 100644 --- a/src/core/fileuploader/providers/fileuploader.ts +++ b/src/core/fileuploader/providers/fileuploader.ts @@ -27,10 +27,14 @@ import { CoreUtilsProvider } from '../../../providers/utils/utils'; import { CoreWSFileUploadOptions } from '../../../providers/ws'; /** - * Interface for file upload options. + * File upload options. */ export interface CoreFileUploaderOptions extends CoreWSFileUploadOptions { - deleteAfterUpload?: boolean; // Whether the file should be deleted after the upload (if success). + /** + * Whether the file should be deleted after the upload (if success). + * @type {boolean} + */ + deleteAfterUpload?: boolean; }; /** diff --git a/src/core/fileuploader/providers/helper.ts b/src/core/fileuploader/providers/helper.ts index 07936bb1d..438ee93c9 100644 --- a/src/core/fileuploader/providers/helper.ts +++ b/src/core/fileuploader/providers/helper.ts @@ -94,13 +94,13 @@ export class CoreFileUploaderHelperProvider { fileData; // We have the data of the file to be uploaded, but not its URL (needed). Create a copy of the file to upload it. - return this.fileProvider.readFileData(file, this.fileProvider.FORMATARRAYBUFFER).then((data) => { + return this.fileProvider.readFileData(file, CoreFileProvider.FORMATARRAYBUFFER).then((data) => { fileData = data; // Get unique name for the copy. - return this.fileProvider.getUniqueNameInFolder(this.fileProvider.TMPFOLDER, name); + return this.fileProvider.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, name); }).then((newName) => { - let filePath = this.textUtils.concatenatePaths(this.fileProvider.TMPFOLDER, newName); + let filePath = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); return this.fileProvider.writeFile(filePath, fileData); }).catch((error) => { @@ -158,10 +158,10 @@ export class CoreFileUploaderHelperProvider { fileName = fileName.replace(/(\.[^\.]*)\?[^\.]*$/, '$1'); // Get a unique name in the folder to prevent overriding another file. - return this.fileProvider.getUniqueNameInFolder(this.fileProvider.TMPFOLDER, fileName, defaultExt); + return this.fileProvider.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, fileName, defaultExt); }).then((newName) => { // Now move or copy the file. - const destPath = this.textUtils.concatenatePaths(this.fileProvider.TMPFOLDER, newName); + const destPath = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, newName); if (shouldDelete) { return this.fileProvider.moveExternalFile(path, destPath); } else { diff --git a/src/core/login/pages/email-signup/email-signup.ts b/src/core/login/pages/email-signup/email-signup.ts index 5311a4a47..86bca81d8 100644 --- a/src/core/login/pages/email-signup/email-signup.ts +++ b/src/core/login/pages/email-signup/email-signup.ts @@ -263,6 +263,6 @@ export class CoreLoginEmailSignupPage { * Show authentication instructions. */ protected showAuthInstructions() { - this.textUtils.expandText(this.translate.instant('core.login.instructions'), this.authInstructions, true); + this.textUtils.expandText(this.translate.instant('core.login.instructions'), this.authInstructions); } } diff --git a/src/core/login/pages/init/init.ts b/src/core/login/pages/init/init.ts index 7be53c668..7926f43f1 100644 --- a/src/core/login/pages/init/init.ts +++ b/src/core/login/pages/init/init.ts @@ -47,7 +47,7 @@ export class CoreLoginInitPage { // Only accept the redirect if it was stored less than 20 seconds ago. if (Date.now() - redirectData.timemodified < 20000) { - if (redirectData.siteId != CoreConstants.noSiteId) { + if (redirectData.siteId != CoreConstants.NO_SITE_ID) { // The redirect is pointing to a site, load it. return this.sitesProvider.loadSite(redirectData.siteId).then(() => { if (!this.loginHelper.isSiteLoggedOut(redirectData.page, redirectData.params)) { diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index 08caa097c..77faf9dbc 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -30,11 +30,38 @@ import { CoreConfigConstants } from '../../../configconstants'; import { CoreConstants } from '../../constants'; import { Md5 } from 'ts-md5/dist/md5'; +/** + * Data related to a SSO authentication. + */ export interface CoreLoginSSOData { + /** + * The site's URL. + * @type {string} + */ siteUrl?: string; + + /** + * User's token. + * @type {string} + */ token?: string; + + /** + * User's private token. + * @type {string} + */ privateToken?: string; + + /** + * Name of the page to go after authenticated. + * @type {string} + */ pageName?: string; + + /** + * Params to page to the page. + * @type {string} + */ pageParams?: any }; @@ -187,7 +214,7 @@ export class CoreLoginHelperProvider { * Show a confirm modal if needed and open a browser to perform SSO login. * * @param {string} siteurl URL of the site where the SSO login will be performed. - * @param {number} typeOfLogin CoreConstants.loginSSOCode or CoreConstants.loginSSOInAppCode. + * @param {number} typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. * @param {string} [service] The service to use. If not defined, external service will be used. * @param {string} [launchUrl] The URL to open for SSO. If not defined, local_mobile launch URL will be used. * @return {Void} @@ -594,7 +621,7 @@ export class CoreLoginHelperProvider { return true; } - return code == CoreConstants.loginSSOInAppCode; + return code == CoreConstants.LOGIN_SSO_INAPP_CODE; } /** @@ -604,7 +631,7 @@ export class CoreLoginHelperProvider { * @return {boolean} True if SSO login is needed, false othwerise. */ isSSOLoginNeeded(code: number) : boolean { - return code == CoreConstants.loginSSOCode || code == CoreConstants.loginSSOInAppCode; + return code == CoreConstants.LOGIN_SSO_CODE || code == CoreConstants.LOGIN_SSO_INAPP_CODE; } /** @@ -615,7 +642,7 @@ export class CoreLoginHelperProvider { * @param {string} siteId Site to load. */ protected loadSiteAndPage(page: string, params: any, siteId: string) : void { - if (siteId == CoreConstants.noSiteId) { + if (siteId == CoreConstants.NO_SITE_ID) { // Page doesn't belong to a site, just load the page. this.appProvider.getRootNavController().setRoot(page, params); } else { @@ -687,7 +714,7 @@ export class CoreLoginHelperProvider { * Open a browser to perform SSO login. * * @param {string} siteurl URL of the site where the SSO login will be performed. - * @param {number} typeOfLogin CoreConstants.loginSSOCode or CoreConstants.loginSSOInAppCode. + * @param {number} typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. * @param {string} [service] The service to use. If not defined, external service will be used. * @param {string} [launchUrl] The URL to open for SSO. If not defined, local_mobile launch URL will be used. * @param {string} [pageName] Name of the page to go once authenticated. If not defined, site initial page. @@ -791,7 +818,7 @@ export class CoreLoginHelperProvider { // Store the siteurl and passport in $mmConfig for persistence. We are "configuring" // the app to wait for an SSO. $mmConfig shouldn't be used as a temporary storage. - this.configProvider.set(CoreConstants.loginLaunchData, JSON.stringify({ + this.configProvider.set(CoreConstants.LOGIN_LAUNCH_DATA, JSON.stringify({ siteUrl: siteUrl, passport: passport, pageName: pageName || '', @@ -934,7 +961,7 @@ export class CoreLoginHelperProvider { /** * Check if a confirm should be shown to open a SSO authentication. * - * @param {number} typeOfLogin CoreConstants.loginSSOCode or CoreConstants.loginSSOInAppCode. + * @param {number} typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. * @return {boolean} True if confirm modal should be shown, false otherwise. */ shouldShowSSOConfirm(typeOfLogin: number) : boolean { @@ -988,7 +1015,7 @@ export class CoreLoginHelperProvider { // Split signature:::token const params = url.split(":::"); - return this.configProvider.get(CoreConstants.loginLaunchData).then((data): any => { + return this.configProvider.get(CoreConstants.LOGIN_LAUNCH_DATA).then((data): any => { try { data = JSON.parse(data); } catch(ex) { @@ -999,7 +1026,7 @@ export class CoreLoginHelperProvider { passport = data.passport; // Reset temporary values. - this.configProvider.delete(CoreConstants.loginLaunchData); + this.configProvider.delete(CoreConstants.LOGIN_LAUNCH_DATA); // Validate the signature. // We need to check both http and https. diff --git a/src/core/mainmenu/providers/delegate.ts b/src/core/mainmenu/providers/delegate.ts index 5872e45f0..773615f58 100644 --- a/src/core/mainmenu/providers/delegate.ts +++ b/src/core/mainmenu/providers/delegate.ts @@ -18,18 +18,65 @@ import { CoreLoggerProvider } from '../../../providers/logger'; import { CoreSitesProvider } from '../../../providers/sites'; import { Subject, BehaviorSubject } from 'rxjs'; +/** + * Interface that all main menu handlers must implement. + */ export interface CoreMainMenuHandler { - name: string; // Name of the handler. - priority: number; // The highest priority is displayed first. - isEnabled(): boolean|Promise; // Whether or not the handler is enabled on a site level. - getDisplayData(): CoreMainMenuHandlerData; // Returns the data needed to render the handler. + /** + * Name of the handler. + * @type {string} + */ + name: string; + + /** + * The highest priority is displayed first. + * @type {number} + */ + priority: number; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean|Promise; + + /** + * Returns the data needed to render the handler. + * + * @param {number} courseId The course ID. + * @return {CoreMainMenuHandlerData} Data. + */ + getDisplayData(): CoreMainMenuHandlerData; }; +/** + * Data needed to render a main menu handler. It's returned by the handler. + */ export interface CoreMainMenuHandlerData { - page: string; // Name of the page. - title: string; // Title to display in the tab. + /** + * Name of the page to load for the handler. + * @type {string} + */ + page: string; + + /** + * Title to display for the handler. + * @type {string} + */ + title: string; + + /** + * Name of the icon to display for the handler. + * @type {string} + */ icon: string; // Name of the icon to display in the tab. - class?: string; // Class to add to the displayed handler. + + /** + * Class to add to the displayed handler. + * @type {string} + */ + class?: string; }; /** diff --git a/src/core/mainmenu/providers/mainmenu.ts b/src/core/mainmenu/providers/mainmenu.ts index 148e2473f..0f3bd8e71 100644 --- a/src/core/mainmenu/providers/mainmenu.ts +++ b/src/core/mainmenu/providers/mainmenu.ts @@ -17,10 +17,32 @@ import { CoreLangProvider } from '../../../providers/lang'; import { CoreSitesProvider } from '../../../providers/sites'; import { CoreConfigConstants } from '../../../configconstants'; +/** + * Custom main menu item. + */ export interface CoreMainMenuCustomItem { + /** + * Type of the item: app, inappbrowser, browser or embedded. + * @type {string} + */ type: string; + + /** + * Url of the item. + * @type {string} + */ url: string; + + /** + * Label to display for the item. + * @type {string} + */ label: string; + + /** + * Name of the icon to display for the item. + * @type {string} + */ icon: string; }; diff --git a/src/core/viewer/pages/image/image.html b/src/core/viewer/pages/image/image.html new file mode 100644 index 000000000..d1eb33363 --- /dev/null +++ b/src/core/viewer/pages/image/image.html @@ -0,0 +1,14 @@ + + + {{ title }} + + + + + + + + {{ title }} + diff --git a/src/core/viewer/pages/image/image.module.ts b/src/core/viewer/pages/image/image.module.ts new file mode 100644 index 000000000..62cd6dff0 --- /dev/null +++ b/src/core/viewer/pages/image/image.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreViewerImagePage } from './image'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; + +@NgModule({ + declarations: [ + CoreViewerImagePage + ], + imports: [ + CoreDirectivesModule, + IonicPageModule.forChild(CoreViewerImagePage), + TranslateModule.forChild() + ] +}) +export class CoreViewerImagePageModule {} diff --git a/src/core/viewer/pages/image/image.ts b/src/core/viewer/pages/image/image.ts new file mode 100644 index 000000000..97eab737e --- /dev/null +++ b/src/core/viewer/pages/image/image.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; + +/** + * Page to view an image. If opened as a modal, it will have a button to close the modal. + */ +@IonicPage({segment: 'core-viewer-image'}) +@Component({ + selector: 'page-core-viewer-image', + templateUrl: 'image.html', +}) +export class CoreViewerImagePage { + title: string; // Page title. + image: string; // Image URL. + component: string; // Component to use in external-content. + componentId: string|number; // Component ID to use in external-content. + + constructor(private viewCtrl: ViewController, params: NavParams, translate: TranslateService) { + this.title = params.get('title') || translate.instant('core.imageviewer'); + this.image = params.get('image'); + this.component = params.get('component'); + this.componentId = params.get('componentId'); + } + + /** + * Close modal. + */ + closeModal() : void { + this.viewCtrl.dismiss(); + } +} \ No newline at end of file diff --git a/src/core/viewer/pages/text/text.html b/src/core/viewer/pages/text/text.html index dfb15aacb..b00ee1f27 100644 --- a/src/core/viewer/pages/text/text.html +++ b/src/core/viewer/pages/text/text.html @@ -2,7 +2,7 @@ {{ title }} - + diff --git a/src/core/viewer/pages/text/text.ts b/src/core/viewer/pages/text/text.ts index dd0d6281d..e0647bf30 100644 --- a/src/core/viewer/pages/text/text.ts +++ b/src/core/viewer/pages/text/text.ts @@ -27,17 +27,14 @@ import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; export class CoreViewerTextPage { title: string; // Page title. content: string; // Page content. - isModal: boolean; // Whether it should be opened in a modal or in a page. component: string; // Component to use in format-text. componentId: string|number; // Component ID to use in format-text. constructor(private viewCtrl: ViewController, params: NavParams, textUtils: CoreTextUtilsProvider) { this.title = params.get('title'); this.content = params.get('content'); - this.isModal = params.get('isModal'); this.component = params.get('component'); this.componentId = params.get('componentId'); - // @todo: Use replacelinebreaks param like in Ionic 1? format-text should do it by default. } /** diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index d71e2141b..da089bc25 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -51,7 +51,6 @@ export class CoreFormatTextDirective implements OnChanges { // avoid this use class="inline" at the same time to use display: inline-block. @Input() fullOnClick?: boolean|string; // Whether it should open a new page with the full contents on click. Only if // "max-height" is set and the content has been collapsed. - @Input() brOnFull?: boolean|string; // Whether new lines should be replaced by
on full view. @Input() fullTitle?: string; // Title to use in full view. Defaults to "Description". @Output() afterRender?: EventEmitter; // Called when the data is rendered. @@ -103,16 +102,14 @@ export class CoreFormatTextDirective implements OnChanges { } /** - * Create a container for an image to adapt its width. + * Wrap an image with a container to adapt its width and, if needed, add an anchor to view it in full size. * * @param {number} elWidth Width of the directive's element. * @param {HTMLElement} img Image to adapt. - * @return {HTMLElement} Container. */ - protected createMagnifyingGlassContainer(elWidth: number, img: HTMLElement) : HTMLElement { - // Check if image width has been adapted. If so, add an icon to view the image at full size. + protected adaptImage(elWidth: number, img: HTMLElement) : void { let imgWidth = this.getElementWidth(img), - // Wrap the image in a new div with position relative. + // Element to wrap the image. container = document.createElement('span'); container.classList.add('core-adapted-img-container'); @@ -122,18 +119,38 @@ export class CoreFormatTextDirective implements OnChanges { } else if (img.classList.contains('atto_image_button_left')) { container.classList.add('atto_image_button_left'); } - container.appendChild(img); + + this.domUtils.wrapElement(img, container); if (imgWidth > elWidth) { - let imgSrc = this.textUtils.escapeHTML(img.getAttribute('src')), - label = this.textUtils.escapeHTML(this.translate.instant('core.openfullimage')); - - // @todo: Implement image viewer. Maybe we can add the listener here directly? - container.innerHTML += ''; + // The image has been adapted, add an anchor to view it in full size. + this.addMagnifyingGlass(container, img); } + } - return container; + /** + * Add a magnifying glass icon to view an image at full size. + * + * @param {HTMLElement} container The container of the image. + * @param {HTMLElement} img The image. + */ + addMagnifyingGlass(container: HTMLElement, img: HTMLElement) : void { + let imgSrc = this.textUtils.escapeHTML(img.getAttribute('src')), + label = this.textUtils.escapeHTML(this.translate.instant('core.openfullimage')), + anchor = document.createElement('a'); + + anchor.classList.add('core-image-viewer-icon'); + anchor.setAttribute('aria-label', label); + // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed. + anchor.innerHTML = ''; + + anchor.addEventListener('click', (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + this.domUtils.viewImage(imgSrc, img.getAttribute('alt'), this.component, this.componentId); + }); + + container.appendChild(anchor); } /** @@ -159,8 +176,10 @@ export class CoreFormatTextDirective implements OnChanges { this.text = this.text.trim(); this.formatContents().then((div: HTMLElement) => { - this.element.innerHTML = ''; // Remove current contents. + // Disable media adapt to correctly calculate the height. + this.element.classList.add('core-disable-media-adapt'); + this.element.innerHTML = ''; // Remove current contents. if (this.maxHeight && div.innerHTML != "") { // Move the children to the current element to be able to calculate the height. // @todo: Display the element? @@ -173,9 +192,12 @@ export class CoreFormatTextDirective implements OnChanges { // If cannot calculate height, shorten always. if (!height || height > this.maxHeight) { - let expandInFullview = this.utils.isTrueOrOne(this.fullOnClick) || false; + let expandInFullview = this.utils.isTrueOrOne(this.fullOnClick) || false, + showMoreDiv = document.createElement('div'); - this.element.innerHTML += '
' + this.translate.instant('core.showmore') + '
'; + showMoreDiv.classList.add('core-show-more'); + showMoreDiv.innerHTML = this.translate.instant('core.showmore'); + this.element.appendChild(showMoreDiv); if (expandInFullview) { this.element.classList.add('core-expand-in-fullview'); @@ -200,17 +222,15 @@ export class CoreFormatTextDirective implements OnChanges { } // Open a new state with the contents. - // @todo: brOnFull is needed? this.textUtils.expandText(this.fullTitle || this.translate.instant('core.description'), this.text, - false, this.component, this.componentId); + this.component, this.componentId); }); } } else { this.domUtils.moveChildren(div, this.element); } - this.element.classList.add('core-enabled-media-adapt'); - + this.element.classList.remove('core-disable-media-adapt'); this.finishRender(); }); } @@ -233,7 +253,6 @@ export class CoreFormatTextDirective implements OnChanges { // Apply format text function. return this.textUtils.formatText(this.text, this.utils.isTrueOrOne(this.clean), this.utils.isTrueOrOne(this.singleLine)); }).then((formatted) => { - let div = document.createElement('div'), canTreatVimeo = site && site.isVersionGreaterEqualThan(['3.3.4', '3.4']), images, @@ -271,9 +290,7 @@ export class CoreFormatTextDirective implements OnChanges { this.addMediaAdaptClass(img); this.addExternalContent(img); if (this.utils.isTrueOrOne(this.adaptImg)) { - // Create a container for the image and use it instead of the image. - let container = this.createMagnifyingGlassContainer(elWidth, img); - div.replaceChild(container, img); + this.adaptImage(elWidth, img); } }); } @@ -345,16 +362,7 @@ export class CoreFormatTextDirective implements OnChanges { * @return {number} The height of the element in pixels. When 0 is returned it means the element is not visible. */ protected getElementHeight(element: HTMLElement) : number { - let height; - - // Disable media adapt to correctly calculate the height. - element.classList.remove('core-enabled-media-adapt'); - - height = this.domUtils.getElementHeight(element); - - element.classList.add('core-enabled-media-adapt'); - - return height || 0; + return this.domUtils.getElementHeight(element) || 0; } /** diff --git a/src/pipes/seconds-to-hms.ts b/src/pipes/seconds-to-hms.ts index 8160522a2..34bd4d3cf 100644 --- a/src/pipes/seconds-to-hms.ts +++ b/src/pipes/seconds-to-hms.ts @@ -57,10 +57,10 @@ export class CoreSecondsToHMSPipe implements PipeTransform { // Don't allow decimals. seconds = Math.floor(seconds); - hours = Math.floor(seconds / CoreConstants.secondsHour); - seconds -= hours * CoreConstants.secondsHour; - minutes = Math.floor(seconds / CoreConstants.secondsMinute); - seconds -= minutes * CoreConstants.secondsMinute; + hours = Math.floor(seconds / CoreConstants.SECONDS_HOUR); + seconds -= hours * CoreConstants.SECONDS_HOUR; + minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE); + seconds -= minutes * CoreConstants.SECONDS_MINUTE; return this.textUtils.twoDigits(hours) + ':' + this.textUtils.twoDigits(minutes) + ':' + this.textUtils.twoDigits(seconds); } diff --git a/src/providers/app.ts b/src/providers/app.ts index 7bc1f36be..3b74ca164 100644 --- a/src/providers/app.ts +++ b/src/providers/app.ts @@ -21,10 +21,32 @@ import { CoreDbProvider } from './db'; import { CoreLoggerProvider } from './logger'; import { SQLiteDB } from '../classes/sqlitedb'; +/** + * Data stored for a redirect to another page/site. + */ export interface CoreRedirectData { + /** + * ID of the site to load. + * @type {string} + */ siteId?: string; - page?: string; // Name of the page to redirect. - params?: any; // Params to pass to the page. + + /** + * Name of the page to redirect to. + * @type {string} + */ + page?: string; + + /** + * Params to pass to the page. + * @type {any} + */ + params?: any; + + /** + * Timestamp when this redirect was last modified. + * @type {number} + */ timemodified?: number; }; @@ -40,11 +62,11 @@ export interface CoreRedirectData { */ @Injectable() export class CoreAppProvider { - DBNAME = 'MoodleMobile'; - db: SQLiteDB; - logger; - ssoAuthenticationPromise : Promise; - isKeyboardShown: boolean = false; + protected DBNAME = 'MoodleMobile'; + protected db: SQLiteDB; + protected logger; + protected ssoAuthenticationPromise : Promise; + protected isKeyboardShown: boolean = false; constructor(dbProvider: CoreDbProvider, private platform: Platform, private keyboard: Keyboard, private appCtrl: App, private network: Network, logger: CoreLoggerProvider) { diff --git a/src/providers/config.ts b/src/providers/config.ts index 88cf788c9..7b9f4970e 100644 --- a/src/providers/config.ts +++ b/src/providers/config.ts @@ -22,9 +22,9 @@ import { SQLiteDB } from '../classes/sqlitedb'; */ @Injectable() export class CoreConfigProvider { - appDB: SQLiteDB; - TABLE_NAME = 'core_config'; - tableSchema = { + protected appDB: SQLiteDB; + protected TABLE_NAME = 'core_config'; + protected tableSchema = { name: this.TABLE_NAME, columns: [ { diff --git a/src/providers/cron.ts b/src/providers/cron.ts index c6605700f..53cb47273 100644 --- a/src/providers/cron.ts +++ b/src/providers/cron.ts @@ -21,17 +21,64 @@ import { CoreUtilsProvider } from './utils/utils'; import { CoreConstants } from '../core/constants'; import { SQLiteDB } from '../classes/sqlitedb'; +/** + * Interface that all cron handlers must implement. + */ export interface CoreCronHandler { - name: string; // Handler's name. - getInterval?(): number; // Returns handler's interval in milliseconds. Defaults to CoreCronDelegate.DEFAULT_INTERVAL. - usesNetwork?(): boolean; // Whether the process uses network or not. True if not defined. - isSync?(): boolean; // Whether it's a synchronization process or not. True if not defined. - canManualSync?(): boolean; // Whether the sync can be executed manually. Call isSync if not defined. - execute?(siteId?: string): Promise; // Execute the process. Receives ID of site affected, undefined for all sites. - // Important: If the promise is rejected then this function will be called again - // often, it shouldn't be abused. - running: boolean; // Whether the handler is running. Used internally by the provider, there's no need to set it. - timeout: number; // Timeout ID for the handler scheduling. Used internally by the provider, there's no need to set it. + /** + * A name to identify the handler. + * @type {string} + */ + name: string; + + /** + * Returns handler's interval in milliseconds. Defaults to CoreCronDelegate.DEFAULT_INTERVAL. + * + * @return {number} Interval time (in milliseconds). + */ + getInterval?(): number; + + /** + * Check whether the process uses network or not. True if not defined. + * + * @return {boolean} Whether the process uses network or not + */ + usesNetwork?(): boolean; + + /** + * Check whether it's a synchronization process or not. True if not defined. + * + * @return {boolean} Whether it's a synchronization process or not. + */ + isSync?(): boolean; + + /** + * Check whether the sync can be executed manually. Call isSync if not defined. + * + * @return {boolean} Whether the sync can be executed manually. + */ + canManualSync?(): boolean; + + /** + * Execute the process. + * + * @param {string} [siteId] ID of the site affected. If not defined, all sites. + * @return {Promise} Promise resolved when done. If the promise is rejected, this function will be called again often, + * it shouldn't be abused. + */ + execute?(siteId?: string): Promise; + + /** + * Whether the handler is running. Used internally by the provider, there's no need to set it. + * @type {boolean} + */ + running: boolean; + + /** + * Timeout ID for the handler scheduling. Used internally by the provider, there's no need to set it. + * @type {number} + */ + timeout: number; }; /* @@ -109,7 +156,7 @@ export class CoreCronDelegate { if (isSync) { // Check network connection. - promise = this.configProvider.get(CoreConstants.settingsSyncOnlyOnWifi, false).then((syncOnlyOnWifi) => { + promise = this.configProvider.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, false).then((syncOnlyOnWifi) => { return !syncOnlyOnWifi || !this.appProvider.isNetworkAccessLimited(); }); } else { diff --git a/src/providers/db.ts b/src/providers/db.ts index 0a463143e..a3c1f4aab 100644 --- a/src/providers/db.ts +++ b/src/providers/db.ts @@ -24,7 +24,7 @@ import { SQLiteDBMock } from '../core/emulator/classes/sqlitedb'; @Injectable() export class CoreDbProvider { - dbInstances = {}; + protected dbInstances = {}; constructor(private sqlite: SQLite, private platform: Platform) {} diff --git a/src/providers/events.ts b/src/providers/events.ts index 198cb6a68..a9d538a25 100644 --- a/src/providers/events.ts +++ b/src/providers/events.ts @@ -16,8 +16,14 @@ import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; import { CoreLoggerProvider } from '../providers/logger'; +/** + * Observer instance to stop listening to an event. + */ export interface CoreEventObserver { - off: () => void; // Unsubscribe. + /** + * Stop the observer. + */ + off: () => void; }; /* @@ -39,6 +45,7 @@ export class CoreEventsProvider { public static COMPLETION_MODULE_VIEWED = 'completion_module_viewed'; public static USER_DELETED = 'user_deleted'; public static PACKAGE_STATUS_CHANGED = 'package_status_changed'; + public static COURSE_STATUS_CHANGED = 'course_status_changed'; public static SECTION_STATUS_CHANGED = 'section_status_changed'; public static REMOTE_ADDONS_LOADED = 'remote_addons_loaded'; public static LOGIN_SITE_CHECKED = 'login_site_checked'; @@ -48,9 +55,9 @@ export class CoreEventsProvider { public static APP_LAUNCHED_URL = 'app_launched_url'; // App opened with a certain URL (custom URL scheme). public static FILE_SHARED = 'file_shared'; - logger; - observables: {[s: string] : Subject} = {}; - uniqueEvents = {}; + protected logger; + protected observables: {[s: string] : Subject} = {}; + protected uniqueEvents = {}; constructor(logger: CoreLoggerProvider) { this.logger = logger.getInstance('CoreEventsProvider'); diff --git a/src/providers/file.ts b/src/providers/file.ts index e8c693f8a..53d8d0d0a 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -27,20 +27,20 @@ import { Zip } from '@ionic-native/zip'; */ @Injectable() export class CoreFileProvider { - logger; - initialized = false; - basePath = ''; - isHTMLAPI = false; + protected logger; + protected initialized = false; + protected basePath = ''; + protected isHTMLAPI = false; // Formats to read a file. - FORMATTEXT = 0; - FORMATDATAURL = 1; - FORMATBINARYSTRING = 2; - FORMATARRAYBUFFER = 3; + public static FORMATTEXT = 0; + public static FORMATDATAURL = 1; + public static FORMATBINARYSTRING = 2; + public static FORMATARRAYBUFFER = 3; // Folders. - SITESFOLDER = 'sites'; - TMPFOLDER = 'tmp'; + public static SITESFOLDER = 'sites'; + public static TMPFOLDER = 'tmp'; constructor(logger: CoreLoggerProvider, private platform: Platform, private file: File, private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider, private zip: Zip, private mimeUtils: CoreMimetypeUtilsProvider) { @@ -136,7 +136,7 @@ export class CoreFileProvider { * @return {string} Site folder path. */ getSiteFolder(siteId: string) : string { - return this.SITESFOLDER + '/' + siteId; + return CoreFileProvider.SITESFOLDER + '/' + siteId; } /** @@ -380,17 +380,17 @@ export class CoreFileProvider { * FORMATARRAYBUFFER * @return {Promise} Promise to be resolved when the file is read. */ - readFile(path: string, format = this.FORMATTEXT) : Promise { + readFile(path: string, format = CoreFileProvider.FORMATTEXT) : Promise { // Remove basePath if it's in the path. path = this.removeStartingSlash(path.replace(this.basePath, '')); this.logger.debug('Read file ' + path + ' with format ' + format); switch (format) { - case this.FORMATDATAURL: + case CoreFileProvider.FORMATDATAURL: return this.file.readAsDataURL(this.basePath, path); - case this.FORMATBINARYSTRING: + case CoreFileProvider.FORMATBINARYSTRING: return this.file.readAsBinaryString(this.basePath, path); - case this.FORMATARRAYBUFFER: + case CoreFileProvider.FORMATARRAYBUFFER: return this.file.readAsArrayBuffer(this.basePath, path); default: return this.file.readAsText(this.basePath, path); @@ -408,8 +408,8 @@ export class CoreFileProvider { * FORMATARRAYBUFFER * @return {Promise} Promise to be resolved when the file is read. */ - readFileData(fileData: any, format = this.FORMATTEXT) : Promise { - format = format || this.FORMATTEXT; + readFileData(fileData: any, format = CoreFileProvider.FORMATTEXT) : Promise { + format = format || CoreFileProvider.FORMATTEXT; this.logger.debug('Read file from file data with format ' + format); return new Promise((resolve, reject) => { @@ -426,13 +426,13 @@ export class CoreFileProvider { } switch (format) { - case this.FORMATDATAURL: + case CoreFileProvider.FORMATDATAURL: reader.readAsDataURL(fileData); break; - case this.FORMATBINARYSTRING: + case CoreFileProvider.FORMATBINARYSTRING: reader.readAsBinaryString(fileData); break; - case this.FORMATARRAYBUFFER: + case CoreFileProvider.FORMATARRAYBUFFER: reader.readAsArrayBuffer(fileData); break; default: @@ -883,7 +883,7 @@ export class CoreFileProvider { * @return {Promise} Promise resolved when done. */ clearTmpFolder() : Promise { - return this.removeDir(this.TMPFOLDER); + return this.removeDir(CoreFileProvider.TMPFOLDER); } /** diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index a576880ee..38f2ecb49 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -31,47 +31,199 @@ import { SQLiteDB } from '../classes/sqlitedb'; import { CoreConstants } from '../core/constants'; import { Md5 } from 'ts-md5/dist/md5'; -// Entry from filepool. +/** + * Entry from filepool. + */ export interface CoreFilepoolFileEntry { + /** + * The fileId to identify the file. + * @type {string} + */ fileId?: string; + + /** + * File's URL. + * @type {string} + */ url?: string; + + /** + * File's revision. + * @type {number} + */ revision?: number; + + /** + * File's timemodified. + * @type {number} + */ timemodified?: number; - stale?: number; // 1 if stale, 0 otherwise. + + /** + * 1 if file is stale (needs to be updated), 0 otherwise. + * @type {number} + */ + stale?: number; + + /** + * Timestamp when this file was downloaded. + * @type {number} + */ downloadTime?: number; - isexternalfile?: number; // 1 if external, 0 otherwise. + + /** + * 1 if it's a external file (from an external repository), 0 otherwise. + * @type {number} + */ + isexternalfile?: number; + + /** + * Type of the repository this file belongs to. + * @type {string} + */ repositorytype?: string; + + /** + * File's path. + * @type {string} + */ path?: string; + + /** + * File's extension. + * @type {string} + */ extension?: string; }; -// Entry from files queue. +/** + * Entry from the file's queue. + */ export interface CoreFilepoolQueueEntry { + /** + * The site the file belongs to. + * @type {string} + */ siteId?: string; + + /** + * The fileId to identify the file. + * @type {string} + */ fileId?: string; + + /** + * Timestamp when the file was added to the queue. + * @type {number} + */ added?: number; + + /** + * The priority of the file. + * @type {number} + */ priority?: number; + + /** + * File's URL. + * @type {string} + */ url?: string; + + /** + * File's revision. + * @type {number} + */ revision?: number; + + /** + * File's timemodified. + * @type {number} + */ timemodified?: number; - isexternalfile?: number; // 1 if external, 0 otherwise. + + /** + * 1 if it's a external file (from an external repository), 0 otherwise. + * @type {number} + */ + isexternalfile?: number; + + /** + * Type of the repository this file belongs to. + * @type {string} + */ repositorytype?: string; + + /** + * File's path. + * @type {string} + */ path?: string; + + /** + * File links (to link the file to components and componentIds). + * @type {any[]} + */ links?: any[]; }; -// Entry from packages table. +/** + * Entry from packages table. + */ export interface CoreFilepoolPackageEntry { + /** + * Package id. + * @type {string} + */ id?: string; + + /** + * The component to link the files to. + * @type {string} + */ component?: string; + + /** + * An ID to use in conjunction with the component. + * @type {string|number} + */ componentId?: string|number; + + /** + * Package status. + * @type {string} + */ status?: string; + + /** + * Package previous status. + * @type {string} + */ previous?: string; - revision?: string; - timemodified?: number; + + /** + * Timestamp when this package was updated. + * @type {number} + */ updated?: number; + + /** + * Timestamp when this package was downloaded. + * @type {number} + */ downloadTime?: number; + + /** + * Previous download time. + * @type {number} + */ previousDownloadTime?: number; + + /** + * Extra data stored by the package. + * @type {string} + */ + extra?: string; }; /* @@ -243,14 +395,6 @@ export class CoreFilepoolProvider { name: 'previous', type: 'TEXT' }, - { - name: 'revision', - type: 'TEXT' - }, - { - name: 'timemodified', - type: 'INTEGER' - }, { name: 'updated', type: 'INTEGER' @@ -262,6 +406,10 @@ export class CoreFilepoolProvider { { name: 'previousDownloadTime', type: 'INTEGER' + }, + { + name: 'extra', + type: 'TEXT' } ] }, @@ -384,7 +532,7 @@ export class CoreFilepoolProvider { * * @param {string} siteId The site ID. * @param {string} fileId The file ID. - * @param {Object} data Additional information to store about the file (timemodified, url, ...). See mmFilepoolStore schema. + * @param {any} data Additional information to store about the file (timemodified, url, ...). See FILES_TABLE schema. * @return {Promise} Promise resolved on success. */ protected addFileToPool(siteId: string, fileId: string, data: any) : Promise { @@ -566,7 +714,7 @@ export class CoreFilepoolProvider { * @param {boolean} [checkSize=true] True if we shouldn't download files if their size is big, false otherwise. * @param {boolean} [downloadUnknown] True to download file in WiFi if their size is unknown, false otherwise. * Ignored if checkSize=false. - * @param {Object} [options] Extra options (isexternalfile, repositorytype). + * @param {any} [options] Extra options (isexternalfile, repositorytype). * @return {Promise} Promise resolved when the file is downloaded. */ protected addToQueueIfNeeded(siteId: string, fileUrl: string, component: string, componentId?: string|number, timemodified = 0, @@ -648,7 +796,7 @@ export class CoreFilepoolProvider { return site.getDb().deleteRecords(this.PACKAGES_TABLE).then(() => { entries.forEach((entry) => { // Trigger module status changed, setting it as not downloaded. - this.triggerPackageStatusChanged(siteId, CoreConstants.notDownloaded, entry.component, entry.componentId); + this.triggerPackageStatusChanged(siteId, CoreConstants.NOT_DOWNLOADED, entry.component, entry.componentId); }); }); }); @@ -693,13 +841,13 @@ export class CoreFilepoolProvider { /** * Given the current status of a list of packages and the status of one of the packages, * determine the new status for the list of packages. The status of a list of packages is: - * - CoreConstants.nowDownloadable if there are no downloadable packages. - * - CoreConstants.notDownloaded if at least 1 package has status CoreConstants.notDownloaded. - * - CoreConstants.downloaded if ALL the downloadable packages have status CoreConstants.downloaded. - * - CoreConstants.downloading if ALL the downloadable packages have status CoreConstants.downloading or - * CoreConstants.downloaded, with at least 1 package with CoreConstants.downloading. - * - CoreConstants.outdated if ALL the downloadable packages have status CoreConstants.outdated or CoreConstants.downloaded - * or CoreConstants.downloading, with at least 1 package with CoreConstants.outdated. + * - CoreConstants.NOT_DOWNLOADABLE if there are no downloadable packages. + * - CoreConstants.NOT_DOWNLOADED if at least 1 package has status CoreConstants.NOT_DOWNLOADED. + * - CoreConstants.DOWNLOADED if ALL the downloadable packages have status CoreConstants.DOWNLOADED. + * - CoreConstants.DOWNLOADING if ALL the downloadable packages have status CoreConstants.DOWNLOADING or + * CoreConstants.DOWNLOADED, with at least 1 package with CoreConstants.DOWNLOADING. + * - CoreConstants.OUTDATED if ALL the downloadable packages have status CoreConstants.OUTDATED or CoreConstants.DOWNLOADED + * or CoreConstants.DOWNLOADING, with at least 1 package with CoreConstants.OUTDATED. * * @param {string} current Current status of the list of packages. * @param {string} packagestatus Status of one of the packages. @@ -707,22 +855,22 @@ export class CoreFilepoolProvider { */ determinePackagesStatus(current: string, packageStatus: string) : string { if (!current) { - current = CoreConstants.notDownloadable; + current = CoreConstants.NOT_DOWNLOADABLE; } - if (packageStatus === CoreConstants.notDownloaded) { + if (packageStatus === CoreConstants.NOT_DOWNLOADED) { // If 1 package is not downloaded the status of the whole list will always be not downloaded. - return CoreConstants.notDownloaded; - } else if (packageStatus === CoreConstants.downloaded && current === CoreConstants.notDownloadable) { + return CoreConstants.NOT_DOWNLOADED; + } else if (packageStatus === CoreConstants.DOWNLOADED && current === CoreConstants.NOT_DOWNLOADABLE) { // If all packages are downloaded or not downloadable with at least 1 downloaded, status will be downloaded. - return CoreConstants.downloaded; - } else if (packageStatus === CoreConstants.downloading && - (current === CoreConstants.notDownloadable || current === CoreConstants.downloaded)) { + return CoreConstants.DOWNLOADED; + } else if (packageStatus === CoreConstants.DOWNLOADING && + (current === CoreConstants.NOT_DOWNLOADABLE || current === CoreConstants.DOWNLOADED)) { // If all packages are downloading/downloaded/notdownloadable with at least 1 downloading, status will be downloading. - return CoreConstants.downloading; - } else if (packageStatus === CoreConstants.outdated && current !== CoreConstants.notDownloaded) { + return CoreConstants.DOWNLOADING; + } else if (packageStatus === CoreConstants.OUTDATED && current !== CoreConstants.NOT_DOWNLOADED) { // If there are no packages notdownloaded and there is at least 1 outdated, status will be outdated. - return CoreConstants.outdated; + return CoreConstants.OUTDATED; } // Status remains the same. @@ -833,23 +981,21 @@ export class CoreFilepoolProvider { } /** - * Downloads or prefetches a list of files. + * Downloads or prefetches a list of files as a "package". * * @param {string} siteId The site ID. * @param {any[]} fileList List of files to download. * @param {boolean} [prefetch] True if should prefetch the contents (queue), false if they should be downloaded right now. * @param {string} [component] The component to link the file to. * @param {string|number} [componentId] An ID to use in conjunction with the component. - * @param {string} [revision] Package's revision. If not defined, it will be calculated using the list of files. - * @param {number} [timemod] Package's timemodified. If not defined, it will be calculated using the list of files. + * @param {string} [extra] Extra data to store for the package. * @param {string} [dirPath] Name of the directory where to store the files (inside filepool dir). If not defined, store * the files directly inside the filepool folder. * @param {Function} [onProgress] Function to call on progress. * @return {Promise} Promise resolved when the package is downloaded. */ protected downloadOrPrefetchPackage(siteId: string, fileList: any[], prefetch?: boolean, component?: string, - componentId?: string|number, revision?: string, timemodified?: number, dirPath?: string, - onProgress?: (event: any) => any) : Promise { + componentId?: string|number, extra?: string, dirPath?: string, onProgress?: (event: any) => any) : Promise { let packageId = this.getPackageId(component, componentId), promise; @@ -861,11 +1007,8 @@ export class CoreFilepoolProvider { this.packagesPromises[siteId] = {}; } - revision = revision || String(this.getRevisionFromFileList(fileList)); - timemodified = timemodified || this.getTimemodifiedFromFileList(fileList); - // Set package as downloading. - promise = this.storePackageStatus(siteId, CoreConstants.downloading, component, componentId).then(() => { + promise = this.storePackageStatus(siteId, CoreConstants.DOWNLOADING, component, componentId).then(() => { let promises = [], packageLoaded = 0; @@ -919,7 +1062,7 @@ export class CoreFilepoolProvider { return Promise.all(promises).then(() => { // Success prefetching, store package as downloaded. - return this.storePackageStatus(siteId, CoreConstants.downloaded, component, componentId, revision, timemodified); + return this.storePackageStatus(siteId, CoreConstants.DOWNLOADED, component, componentId, extra); }).catch(() => { // Error downloading, go back to previous status and reject the promise. return this.setPackagePreviousStatus(siteId, component, componentId).then(() => { @@ -943,17 +1086,15 @@ export class CoreFilepoolProvider { * @param {any[]} fileList List of files to download. * @param {string} component The component to link the file to. * @param {string|number} [componentId] An ID to identify the download. - * @param {string} [revision] Package's revision. If not defined, it will be calculated using the list of files. - * @param {number} [timemodified] Package's timemodified. If not defined, it will be calculated using the list of files. + * @param {string} [extra] Extra data to store for the package. * @param {string} [dirPath] Name of the directory where to store the files (inside filepool dir). If not defined, store * the files directly inside the filepool folder. * @param {Function} [onProgress] Function to call on progress. * @return {Promise} Promise resolved when all files are downloaded. */ - downloadPackage(siteId: string, fileList: any[], component: string, componentId?: string|number, revision?: string, - timemodified?: number, dirPath?: string, onProgress?: (event: any) => any) : Promise { - return this.downloadOrPrefetchPackage( - siteId, fileList, false, component, componentId, revision, timemodified, dirPath, onProgress); + downloadPackage(siteId: string, fileList: any[], component: string, componentId?: string|number, extra?: string, + dirPath?: string, onProgress?: (event: any) => any) : Promise { + return this.downloadOrPrefetchPackage(siteId, fileList, false, component, componentId, extra, dirPath, onProgress); } /** @@ -1391,7 +1532,7 @@ export class CoreFilepoolProvider { // Check if the file is in queue (waiting to be downloaded). return this.hasFileInQueue(siteId, fileId).then(() => { - return CoreConstants.downloading; + return CoreConstants.DOWNLOADING; }).catch(() => { // Check if the file is being downloaded right now. let extension = this.mimeUtils.guessExtensionFromUrl(fileUrl), @@ -1400,18 +1541,18 @@ export class CoreFilepoolProvider { return Promise.resolve(path).then((filePath) => { const downloadId = this.getFileDownloadId(fileUrl, filePath); if (this.filePromises[siteId] && this.filePromises[siteId][downloadId]) { - return CoreConstants.downloading; + return CoreConstants.DOWNLOADING; } // File is not being downloaded. Check if it's downloaded and if it's outdated. return this.hasFileInPool(siteId, fileId).then((entry) => { if (this.isFileOutdated(entry, revision, timemodified)) { - return CoreConstants.outdated; + return CoreConstants.OUTDATED; } else { - return CoreConstants.downloaded; + return CoreConstants.DOWNLOADED; } }).catch(() => { - return CoreConstants.notDownloaded; + return CoreConstants.NOT_DOWNLOADED; }); }); }); @@ -1430,7 +1571,7 @@ export class CoreFilepoolProvider { * @param {boolean} [checkSize=true] True if we shouldn't download files if their size is big, false otherwise. * @param {boolean} [downloadUnknown] True to download file in WiFi if their size is unknown, false otherwise. * Ignored if checkSize=false. - * @param {Object} [options] Extra options (isexternalfile, repositorytype). + * @param {any} [options] Extra options (isexternalfile, repositorytype). * @return {Promise} Resolved with the URL to use. * @description * This will return a URL pointing to the content of the requested URL. @@ -1574,22 +1715,6 @@ export class CoreFilepoolProvider { return Promise.reject(null); } - /** - * Get a package current status. - * - * @param {string} siteId Site ID. - * @param {string} component Package's component. - * @param {string|number} [componentId] An ID to use in conjunction with the component. - * @return {Promise} Promise resolved with the status. - */ - getPackageCurrentStatus(siteId: string, component: string, componentId?: string|number) : Promise { - return this.getPackageData(siteId, component, componentId).then((entry) => { - return entry.status || CoreConstants.notDownloaded; - }).catch(() => { - return CoreConstants.notDownloaded; - }); - } - /** * Get the data stored for a package. * @@ -1683,6 +1808,19 @@ export class CoreFilepoolProvider { return this.packagesPromises[siteId][packageId]; } } + /** + * Get a package extra data. + * + * @param {string} siteId Site ID. + * @param {string} component Package's component. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @return {Promise} Promise resolved with the extra data. + */ + getPackageExtra(siteId: string, component: string, componentId?: string|number) : Promise { + return this.getPackageData(siteId, component, componentId).then((entry) => { + return entry.extra; + }); + } /** * Get the ID of a package. @@ -1705,23 +1843,9 @@ export class CoreFilepoolProvider { */ getPackagePreviousStatus(siteId: string, component: string, componentId?: string|number) : Promise { return this.getPackageData(siteId, component, componentId).then((entry) => { - return entry.previous || CoreConstants.notDownloaded; + return entry.previous || CoreConstants.NOT_DOWNLOADED; }).catch(() => { - return CoreConstants.notDownloaded; - }); - } - - /** - * Get a package revision. - * - * @param {string} siteId Site ID. - * @param {string} component Package's component. - * @param {string|number} [componentId] An ID to use in conjunction with the component. - * @return {Promise} Promise resolved with the revision. - */ - getPackageRevision(siteId: string, component: string, componentId?: string|number) : Promise { - return this.getPackageData(siteId, component, componentId).then((entry) => { - return entry.revision; + return CoreConstants.NOT_DOWNLOADED; }); } @@ -1731,65 +1855,13 @@ export class CoreFilepoolProvider { * @param {string} siteId Site ID. * @param {string} component Package's component. * @param {string|number} [componentId] An ID to use in conjunction with the component. - * @param {string} [revision='0'] Package's revision. - * @param {number} [timemodified=0] Package's time modified. * @return {Promise} Promise resolved with the status. */ - getPackageStatus(siteId: string, component: string, componentId?: string|number, revision = '0', timemodified = 0) - : Promise { - componentId = this.fixComponentId(componentId); - - return this.sitesProvider.getSite(siteId).then((site) => { - const packageId = this.getPackageId(component, componentId), - conditions = {id: packageId}; - - // Get status. - return site.getDb().getRecord(this.PACKAGES_TABLE, conditions).then((entry: CoreFilepoolPackageEntry) => { - if (entry.status === CoreConstants.downloaded) { - if (revision != entry.revision || timemodified > entry.timemodified) { - // File is outdated. Let's change its status. - let newData: CoreFilepoolPackageEntry = { - status: CoreConstants.outdated, - updated: Date.now() - }; - site.getDb().updateRecords(this.PACKAGES_TABLE, newData, conditions).then(() => { - // Success inserting, trigger event. - this.triggerPackageStatusChanged(siteId, CoreConstants.outdated, component, componentId); - }); - } - } else if (entry.status === CoreConstants.outdated) { - if (revision === entry.revision && timemodified === entry.timemodified) { - // File isn't outdated anymore. Let's change its status. - let newData: CoreFilepoolPackageEntry = { - status: CoreConstants.downloaded, - updated: Date.now() - }; - site.getDb().updateRecords(this.PACKAGES_TABLE, newData, conditions).then(() => { - // Success inserting, trigger event. - this.triggerPackageStatusChanged(siteId, CoreConstants.downloaded, component, componentId); - }); - } - } - return entry.status; - }, () => { - return CoreConstants.notDownloaded; - }); - }); - } - - /** - * Get a package timemodified. - * - * @param {string} siteId Site ID. - * @param {string} component Package's component. - * @param {string|number} [componentId] An ID to use in conjunction with the component. - * @return {Promise} Promise resolved with the time modified. - */ - getPackageTimemodified(siteId: string, component: string, componentId?: string|number) : Promise { + getPackageStatus(siteId: string, component: string, componentId?: string|number) : Promise { return this.getPackageData(siteId, component, componentId).then((entry) => { - return entry.timemodified; + return entry.status || CoreConstants.NOT_DOWNLOADED; }).catch(() => { - return -1; + return CoreConstants.NOT_DOWNLOADED; }); } @@ -1874,10 +1946,10 @@ export class CoreFilepoolProvider { } /** - * Get package revision number from a list of files. + * Get a revision number from a list of files (highest revision). * * @param {any[]} files Package files. - * @return {number} Package revision. + * @return {number} Highest revision. */ getRevisionFromFileList(files: any[]) : number { let revision = 0; @@ -1931,7 +2003,7 @@ export class CoreFilepoolProvider { * @param {boolean} [checkSize=true] True if we shouldn't download files if their size is big, false otherwise. * @param {boolean} [downloadUnknown] True to download file in WiFi if their size is unknown, false otherwise. * Ignored if checkSize=false. - * @param {Object} [options] Extra options (isexternalfile, repositorytype). + * @param {any} [options] Extra options (isexternalfile, repositorytype). * @return {Promise} Resolved with the URL to use. * @description * This will return a URL pointing to the content of the requested URL. @@ -1947,7 +2019,7 @@ export class CoreFilepoolProvider { * Get time modified from a list of files. * * @param {any[]} files List of files. - * @return {number} Rime modified. + * @return {number} Time modified. */ getTimemodifiedFromFileList(files: any[]) : number { let timemodified = 0; @@ -1973,7 +2045,7 @@ export class CoreFilepoolProvider { * @param {boolean} [checkSize=true] True if we shouldn't download files if their size is big, false otherwise. * @param {boolean} [downloadUnknown] True to download file in WiFi if their size is unknown, false otherwise. * Ignored if checkSize=false. - * @param {Object} [options] Extra options (isexternalfile, repositorytype). + * @param {any} [options] Extra options (isexternalfile, repositorytype). * @return {Promise} Resolved with the URL to use. * @description * This will return a URL pointing to the content of the requested URL. @@ -2225,17 +2297,15 @@ export class CoreFilepoolProvider { * @param {any[]} fileList List of files to download. * @param {string} component The component to link the file to. * @param {string|number} [componentId] An ID to identify the download. - * @param {string} [revision] Package's revision. If not defined, it will be calculated using the list of files. - * @param {number} [timemodified] Package's time modified. If not defined, it will be calculated using the list of files. + * @param {string} [extra] Extra data to store for the package. * @param {string} [dirPath] Name of the directory where to store the files (inside filepool dir). If not defined, store * the files directly inside the filepool folder. * @param {Function} [onProgress] Function to call on progress. * @return {Promise} Promise resolved when all files are downloaded. */ - prefetchPackage(siteId: string, fileList: any[], component: string, componentId?: string|number, revision?: string, - timemodified?: number, dirPath?: string, onProgress?: (event: any) => any) : Promise { - return this.downloadOrPrefetchPackage( - siteId, fileList, true, component, componentId, revision, timemodified, dirPath, onProgress); + prefetchPackage(siteId: string, fileList: any[], component: string, componentId?: string|number, extra?: string, + dirPath?: string, onProgress?: (event: any) => any) : Promise { + return this.downloadOrPrefetchPackage(siteId, fileList, true, component, componentId, extra, dirPath, onProgress); } /** @@ -2511,11 +2581,11 @@ export class CoreFilepoolProvider { // Get current stored data, we'll only update 'status' and 'updated' fields. return site.getDb().getRecord(this.PACKAGES_TABLE, {id: packageId}).then((entry: CoreFilepoolPackageEntry) => { let newData: CoreFilepoolPackageEntry = {}; - if (entry.status == CoreConstants.downloading) { + if (entry.status == CoreConstants.DOWNLOADING) { // Going back from downloading to previous status, restore previous download time. newData.downloadTime = entry.previousDownloadTime; } - newData.status = entry.previous || CoreConstants.downloaded; + newData.status = entry.previous || CoreConstants.DOWNLOADED; newData.updated = Date.now(); this.logger.debug(`Set previous status '${entry.status}' for package ${component} ${componentId}`); @@ -2538,7 +2608,7 @@ export class CoreFilepoolProvider { * Convenience function to check if a file should be downloaded before opening it. * * The default behaviour in the app is to download first and then open the local file in the following cases: - * - The file is small (less than mmFilepoolDownloadThreshold). + * - The file is small (less than DOWNLOAD_THRESHOLD). * - The file cannot be streamed. * If the file is big and can be streamed, the promise returned by this function will be rejected. */ @@ -2568,12 +2638,11 @@ export class CoreFilepoolProvider { * @param {string} status New package status. * @param {string} component Package's component. * @param {string|number} [componentId] An ID to use in conjunction with the component. - * @param {string} [revision] Package's revision. If not provided, try to use the current value. - * @param {number} [timemodified] Package's time modified. If not provided, try to use the current value. + * @param {string} [extra] Extra data to store for the package. If you want to store more than 1 value, use JSON.stringify. * @return {Promise} Promise resolved when status is stored. */ - storePackageStatus(siteId: string, status: string, component: string, componentId?: string|number, revision?: string, - timemodified?: number) : Promise { + storePackageStatus(siteId: string, status: string, component: string, componentId?: string|number, extra?: string) + : Promise { this.logger.debug(`Set status '${status}'' for package ${component} ${componentId}`); componentId = this.fixComponentId(componentId); @@ -2582,18 +2651,15 @@ export class CoreFilepoolProvider { downloadTime, previousDownloadTime; - if (status == CoreConstants.downloading) { + if (status == CoreConstants.DOWNLOADING) { // Set download time if package is now downloading. downloadTime = this.timeUtils.timestamp(); } // Search current status to set it as previous status. return site.getDb().getRecord(this.PACKAGES_TABLE, {id: packageId}).then((entry: CoreFilepoolPackageEntry) => { - if (typeof revision == 'undefined' || revision === null) { - revision = entry.revision; - } - if (typeof timemodified == 'undefined' || timemodified === null) { - timemodified = entry.timemodified; + if (typeof extra == 'undefined' || extra === null) { + extra = entry.extra; } if (typeof downloadTime == 'undefined') { // Keep previous download time. @@ -2614,11 +2680,10 @@ export class CoreFilepoolProvider { componentId: componentId, status: status, previous: previousStatus, - revision: revision || '0', - timemodified: timemodified || 0, updated: Date.now(), downloadTime: downloadTime, - previousDownloadTime: previousDownloadTime + previousDownloadTime: previousDownloadTime, + extra: extra }, promise; diff --git a/src/providers/groups.ts b/src/providers/groups.ts index 0762719dc..72c2ba57e 100644 --- a/src/providers/groups.ts +++ b/src/providers/groups.ts @@ -16,10 +16,27 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from './sites'; +/** + * Group info for an activity. + */ export interface CoreGroupInfo { - groups?: any[]; // List of groups. - separateGroups?: boolean; // Whether it's separate groups. - visibleGroups?: boolean; // Whether it's visible groups. + /** + * List of groups. + * @type {any[]} + */ + groups?: any[]; + + /** + * Whether it's separate groups. + * @type {boolean} + */ + separateGroups?: boolean; + + /** + * Whether it's visible groups. + * @type {boolean} + */ + visibleGroups?: boolean; }; /* @@ -31,6 +48,7 @@ export class CoreGroupsProvider { public static NOGROUPS = 0; public static SEPARATEGROUPS = 1; public static VISIBLEGROUPS = 2; + protected ROOT_CACHE_KEY = 'mmGroups:'; constructor(private sitesProvider: CoreSitesProvider, private translate: TranslateService) {} @@ -86,7 +104,7 @@ export class CoreGroupsProvider { * @return {string} Cache key. */ protected getActivityAllowedGroupsCacheKey(cmId: number, userId: number) : string { - return this.getRootCacheKey() + 'allowedgroups:' + cmId + ':' + userId; + return this.ROOT_CACHE_KEY + 'allowedgroups:' + cmId + ':' + userId; } /** @@ -178,16 +196,7 @@ export class CoreGroupsProvider { * @return {string} Cache key. */ protected getActivityGroupModeCacheKey(cmId: number) : string { - return this.getRootCacheKey() + 'groupmode:' + cmId; - } - - /** - * Get the "root" cache key for WS calls. - * - * @return {string} Cache key. - */ - protected getRootCacheKey() : string { - return 'mmGroups:'; + return this.ROOT_CACHE_KEY + 'groupmode:' + cmId; } /** @@ -249,7 +258,7 @@ export class CoreGroupsProvider { * @return {string} Cache key. */ protected getUserGroupsInCourseCacheKey(courseId: number, userId: number) : string { - return this.getRootCacheKey() + 'courseGroups:' + courseId + ':' + userId; + return this.ROOT_CACHE_KEY + 'courseGroups:' + courseId + ':' + userId; } /** diff --git a/src/providers/init.ts b/src/providers/init.ts index 37dd18ab2..2b9d6c535 100644 --- a/src/providers/init.ts +++ b/src/providers/init.ts @@ -17,11 +17,34 @@ import { Platform } from 'ionic-angular'; import { CoreLoggerProvider } from './logger'; import { CoreUtilsProvider } from './utils/utils'; +/** + * Interface that all init handlers must implement. + */ export interface CoreInitHandler { - name: string; // Name of the handler. - load(): Promise; // Function to execute during the init process. - priority?: number; // The highest priority is executed first. You should use values lower than MAX_RECOMMENDED_PRIORITY. - blocking?: boolean; // Set this to true when this process should be resolved before any following one. + /** + * A name to identify the handler. + * @type {string} + */ + name: string; + + /** + * Function to execute during the init process. + * + * @return {Promise} Promise resolved when done. + */ + load(): Promise; + + /** + * The highest priority is executed first. You should use values lower than MAX_RECOMMENDED_PRIORITY. + * @type {number} + */ + priority?: number; + + /** + * Set this to true when this process should be resolved before any following one. + * @type {boolean} + */ + blocking?: boolean; }; /* @@ -32,9 +55,9 @@ export class CoreInitDelegate { public static DEFAULT_PRIORITY = 100; // Default priority for init processes. public static MAX_RECOMMENDED_PRIORITY = 600; - initProcesses = {}; - logger; - readiness; + protected initProcesses = {}; + protected logger; + protected readiness; constructor(logger: CoreLoggerProvider, platform: Platform, private utils: CoreUtilsProvider) { this.logger = logger.getInstance('CoreInitDelegate'); diff --git a/src/providers/lang.ts b/src/providers/lang.ts index 86b241c9a..af62d6268 100644 --- a/src/providers/lang.ts +++ b/src/providers/lang.ts @@ -26,10 +26,10 @@ import { CoreConfigConstants } from '../configconstants'; */ @Injectable() export class CoreLangProvider { - fallbackLanguage:string = 'en'; // mmCoreConfigConstants.default_lang || 'en', - currentLanguage: string; // Save current language in a variable to speed up the get function. - customStrings = {}; - customStringsRaw: string; + protected fallbackLanguage:string = 'en'; // mmCoreConfigConstants.default_lang || 'en', + protected currentLanguage: string; // Save current language in a variable to speed up the get function. + protected customStrings = {}; + protected customStringsRaw: string; constructor(private translate: TranslateService, private configProvider: CoreConfigProvider, platform: Platform, private globalization: Globalization) { diff --git a/src/providers/local-notifications.ts b/src/providers/local-notifications.ts index c95ef78c4..37c9f7bce 100644 --- a/src/providers/local-notifications.ts +++ b/src/providers/local-notifications.ts @@ -24,10 +24,22 @@ import { SQLiteDB } from '../classes/sqlitedb'; import { CoreConstants } from '../core/constants'; import { Subject } from 'rxjs'; +/** + * Local notification. + */ export interface CoreILocalNotification extends ILocalNotification { + /** + * Number of milliseconds to turn the led on (Android only). + * @type {number} + */ ledOnTime?: number; + + /** + * Number of milliseconds to turn the led off (Android only). + * @type {number} + */ ledOffTime?: number; -} +}; /* Generated class for the LocalNotificationsProvider provider. @@ -432,7 +444,7 @@ export class CoreLocalNotificationsProvider { return this.isTriggered(notification).then((triggered) => { if (!triggered) { // Check if sound is enabled for notifications. - return this.configProvider.get(CoreConstants.settingsNotificationSound, true).then((soundEnabled) => { + return this.configProvider.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true).then((soundEnabled) => { if (!soundEnabled) { notification.sound = null; } else { diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index 71af4e9a7..3c6af75ec 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -15,10 +15,31 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from './logger'; +/** + * Interface that all plugin file handlers must implement. + */ export interface CorePluginFileHandler { - name: string; // Name of the handler. - getComponentRevisionRegExp?(args: string[]): RegExp; // Should return the RegExp to match revision on pluginfile url. - getComponentRevisionReplace?(args: string[]): string; // Should return the String to remove the revision on pluginfile url. + /** + * A name to identify the handler. It should match the "component" of pluginfile URLs. + * @type {string} + */ + name: string; + + /** + * Return the RegExp to match the revision on pluginfile URLs. + * + * @param {string[]} args Arguments of the pluginfile URL defining component and filearea at least. + * @return {RegExp} RegExp to match the revision on pluginfile URLs. + */ + getComponentRevisionRegExp?(args: string[]): RegExp; + + /** + * Should return the string to remove the revision on pluginfile url. + * + * @param {string[]} args Arguments of the pluginfile URL defining component and filearea at least. + * @return {string} String to remove the revision on pluginfile url. + */ + getComponentRevisionReplace?(args: string[]): string; }; /** diff --git a/src/providers/sites.ts b/src/providers/sites.ts index e912ef2bf..d49652049 100644 --- a/src/providers/sites.ts +++ b/src/providers/sites.ts @@ -27,26 +27,102 @@ import { CoreSite } from '../classes/site'; import { SQLiteDB } from '../classes/sqlitedb'; import { Md5 } from 'ts-md5/dist/md5'; +/** + * Response of checking if a site exists and its configuration. + */ export interface CoreSiteCheckResponse { - code: number; // Code to identify the authentication method to use. - siteUrl: string; // Site url to use (might have changed during the process). - service: string; // Service used. - warning?: string; // Code of the warning message to show to the user. - config?: any; // Site public config (if available). -}; + /** + * Code to identify the authentication method to use. + * @type {number} + */ + code: number; -export interface CoreSiteUserTokenResponse { - token: string; // User token. - siteUrl: string; // Site URL to use. - privateToken?: string; // User private token. -}; - -export interface CoreSiteBasicInfo { - id: string; + /** + * Site url to use (might have changed during the process). + * @type {string} + */ siteUrl: string; + + /** + * Service used. + * @type {string} + */ + service: string; + + /** + * Code of the warning message to show to the user. + * @type {string} + */ + warning?: string; + + /** + * Site public config (if available). + * @type {any} + */ + config?: any; +}; + +/** + * Response of getting user token. + */ +export interface CoreSiteUserTokenResponse { + /** + * User token. + * @type {string} + */ + token: string; + + /** + * Site URL to use. + * @type {string} + */ + siteUrl: string; + + /** + * User private token. + * @type {string} + */ + privateToken?: string; +}; + +/** + * Site's basic info. + */ +export interface CoreSiteBasicInfo { + /** + * Site ID. + * @type {string} + */ + id: string; + + /** + * Site URL. + * @type {string} + */ + siteUrl: string; + + /** + * User's full name. + * @type {string} + */ fullName: string; + + /** + * Site's name. + * @type {string} + */ siteName: string; + + /** + * User's avatar. + * @type {string} + */ avatar: string; + + /** + * Badge to display in the site. + * @type {number} + */ badge?: number; }; @@ -234,7 +310,7 @@ export class CoreSitesProvider { this.services[siteUrl] = data.service; // No need to store it in DB. if (data.coreSupported || - (data.code != CoreConstants.loginSSOCode && data.code != CoreConstants.loginSSOInAppCode)) { + (data.code != CoreConstants.LOGIN_SSO_CODE && data.code != CoreConstants.LOGIN_SSO_INAPP_CODE)) { // SSO using local_mobile not needed, try to get the site public config. return temporarySite.getPublicConfig().then((config) : any => { publicConfig = config; @@ -301,7 +377,7 @@ export class CoreSitesProvider { data.service = 'c'; } - const observable = this.http.post(siteUrl + '/login/token.php', data).timeout(CoreConstants.wsTimeout); + const observable = this.http.post(siteUrl + '/login/token.php', data).timeout(CoreConstants.WS_TIMEOUT); return this.utils.observableToPromise(observable).catch((error) => { return Promise.reject(error.message); }).then((data: any) => { @@ -339,7 +415,7 @@ export class CoreSitesProvider { password: password, service: service }, - observable = this.http.post(siteUrl + '/login/token.php', params).timeout(CoreConstants.wsTimeout); + observable = this.http.post(siteUrl + '/login/token.php', params).timeout(CoreConstants.WS_TIMEOUT); return this.utils.observableToPromise(observable).then((data: any) : any => { if (typeof data == 'undefined') { diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 5ace19ffb..5e4df4f10 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -13,7 +13,8 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { LoadingController, Loading, ToastController, Toast, AlertController, Alert, Platform, Content } from 'ionic-angular'; +import { LoadingController, Loading, ToastController, Toast, AlertController, Alert, Platform, Content, + NavController, ModalController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreTextUtilsProvider } from './text'; import { CoreAppProvider } from '../app'; @@ -26,14 +27,17 @@ import { CoreConstants } from '../../core/constants'; */ @Injectable() export class CoreDomUtilsProvider { - element = document.createElement('div'); // Fake element to use in some functions, to prevent re-creating it each time. - matchesFn: string; // Name of the "matches" function to use when simulating a closest call. - inputSupportKeyboard = ['date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', + // List of input types that support keyboard. + protected INPUT_SUPPORT_KEYBOARD = ['date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', 'search', 'tel', 'text', 'time', 'url', 'week']; + protected element = document.createElement('div'); // Fake element to use in some functions, to prevent creating it each time. + protected matchesFn: string; // Name of the "matches" function to use when simulating a closest call. + constructor(private translate: TranslateService, private loadingCtrl: LoadingController, private toastCtrl: ToastController, private alertCtrl: AlertController, private textUtils: CoreTextUtilsProvider, private appProvider: CoreAppProvider, - private platform: Platform, private configProvider: CoreConfigProvider, private urlUtils: CoreUrlUtilsProvider) {} + private platform: Platform, private configProvider: CoreConfigProvider, private urlUtils: CoreUrlUtilsProvider, + private modalCtrl: ModalController) {} /** * Wraps a message with core-format-text if the message contains HTML tags. @@ -43,6 +47,7 @@ export class CoreDomUtilsProvider { * @return {string} Result message. */ private addFormatTextIfNeeded(message: string) : string { + // @todo if (this.textUtils.hasHTMLTags(message)) { return '' + message + ''; } @@ -96,12 +101,13 @@ export class CoreDomUtilsProvider { * @param {string} [unknownMessage] ID of the message to show if size is unknown. * @param {number} [wifiThreshold] Threshold to show confirm in WiFi connection. Default: CoreWifiDownloadThreshold. * @param {number} [limitedThreshold] Threshold to show confirm in limited connection. Default: CoreDownloadThreshold. + * @param {boolean} [alwaysConfirm] True to show a confirm even if the size isn't high, false otherwise. * @return {Promise} Promise resolved when the user confirms or if no confirm needed. */ - confirmDownloadSize(size: any, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number) - : Promise { - wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.wifiDownloadThreshold : wifiThreshold; - limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.downloadThreshold : limitedThreshold; + confirmDownloadSize(size: any, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number, + alwaysConfirm?: boolean) : Promise { + wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; + limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; if (size.size < 0 || (size.size == 0 && !size.total)) { // Seems size was unable to be calculated. Show a warning. @@ -115,6 +121,8 @@ export class CoreDomUtilsProvider { message = message || 'core.course.confirmdownload'; let readableSize = this.textUtils.bytesToSize(size.size, 2); return this.showConfirm(this.translate.instant(message, {size: readableSize})); + } else if (alwaysConfirm) { + return this.showConfirm(this.translate.instant('core.areyousure')); } return Promise.resolve(); } @@ -261,7 +269,7 @@ export class CoreDomUtilsProvider { * Returns height or width of an element. * * @param {any} element DOM element to measure. - * @param {boolean} [isWidth] Whether to get width or height. + * @param {boolean} [getWidth] Whether to get width or height. * @param {boolean} [usePadding] Whether to use padding to calculate the measure. * @param {boolean} [useMargin] Whether to use margin to calculate the measure. * @param {boolean} [useBorder] Whether to use borders to calculate the measure. @@ -421,7 +429,7 @@ export class CoreDomUtilsProvider { */ isRichTextEditorEnabled() : Promise { if (this.isRichTextEditorSupported()) { - return this.configProvider.get(CoreConstants.settingsRichTextEditor, true); + return this.configProvider.get(CoreConstants.SETTINGS_RICH_TEXT_EDITOR, true); } return Promise.resolve(false); @@ -726,7 +734,7 @@ export class CoreDomUtilsProvider { * @return {Alert} The alert modal. */ showErrorModalDefault(error: any, defaultError: any, needsTranslate?: boolean, autocloseTime?: number) : Alert { - if (error != CoreConstants.dontShowError) { + if (error != CoreConstants.DONT_SHOW_ERROR) { if (error && typeof error != 'string') { error = error.message || error.error; } @@ -836,6 +844,42 @@ export class CoreDomUtilsProvider { */ supportsInputKeyboard(el: any) : boolean { return el && !el.disabled && (el.tagName.toLowerCase() == 'textarea' || - (el.tagName.toLowerCase() == 'input' && this.inputSupportKeyboard.indexOf(el.type) != -1)); + (el.tagName.toLowerCase() == 'input' && this.INPUT_SUPPORT_KEYBOARD.indexOf(el.type) != -1)); + } + + /** + * View an image in a new page or modal. + * + * @param {string} image URL of the image. + * @param {string} title Title of the page or modal. + * @param {string} [component] Component to link the image to if needed. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + */ + viewImage(image: string, title?: string, component?: string, componentId?: string|number) : void { + if (image) { + let params: any = { + title: title, + image: image, + component: component, + componentId: componentId + }; + + let modal = this.modalCtrl.create('CoreViewerImagePage', params); + modal.present(); + } + + } + + /** + * Wrap an HTMLElement with another element. + * + * @param {HTMLElement} el The element to wrap. + * @param {HTMLElement} wrapper Wrapper. + */ + wrapElement(el: HTMLElement, wrapper: HTMLElement) : void { + // Insert the wrapper before the element. + el.parentNode.insertBefore(wrapper, el); + // Now move the element into the wrapper. + wrapper.appendChild(el); } } diff --git a/src/providers/utils/mimetype.ts b/src/providers/utils/mimetype.ts index 096365aaf..fce9e3cd0 100644 --- a/src/providers/utils/mimetype.ts +++ b/src/providers/utils/mimetype.ts @@ -23,12 +23,12 @@ import { CoreTextUtilsProvider } from './text'; */ @Injectable() export class CoreMimetypeUtilsProvider { - logger; - extToMime = {}; // Object to map extensions -> mimetypes. - mimeToExt = {}; // Object to map mimetypes -> extensions. - groupsMimeInfo = {}; // Object to hold extensions and mimetypes that belong to a certain "group" (audio, video, ...). - extensionRegex = /^[a-z0-9]+$/; - wsProvider: any = {}; // @todo + protected logger; + protected extToMime = {}; // Object to map extensions -> mimetypes. + protected mimeToExt = {}; // Object to map mimetypes -> extensions. + protected groupsMimeInfo = {}; // Object to hold extensions and mimetypes that belong to a certain "group" (audio, video, ...). + protected extensionRegex = /^[a-z0-9]+$/; + protected wsProvider: any = {}; // @todo constructor(http: HttpClient, logger: CoreLoggerProvider, private translate: TranslateService, private textUtils: CoreTextUtilsProvider) { diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index e045adc01..60ce12502 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -22,7 +22,7 @@ import { CoreLangProvider } from '../lang'; */ @Injectable() export class CoreTextUtilsProvider { - element = document.createElement('div'); // Fake element to use in some functions, to prevent re-creating it each time. + protected element = document.createElement('div'); // Fake element to use in some functions, to prevent creating it each time. constructor(private translate: TranslateService, private langProvider: CoreLangProvider, private modalCtrl: ModalController) {} @@ -248,13 +248,10 @@ export class CoreTextUtilsProvider { * * @param {string} title Title of the new state. * @param {string} text Content of the text to be expanded. - * @param {boolean} [isModal] Whether it should be opened in a modal (true) or in a new page (false). * @param {string} [component] Component to link the embedded files to. * @param {string|number} [componentId] An ID to use in conjunction with the component. - * @param {NavController} [navCtrl] The NavController instance to use. */ - expandText(title: string, text: string, isModal?: boolean, component?: string, componentId?: string|number, - navCtrl?: NavController) : void { + expandText(title: string, text: string, component?: string, componentId?: string|number) : void { if (text.length > 0) { let params: any = { title: title, @@ -263,18 +260,12 @@ export class CoreTextUtilsProvider { componentId: componentId }; - if (isModal) { - // Open a modal with the contents. - params.isModal = true; + // Open a modal with the contents. + params.isModal = true; - let modal = this.modalCtrl.create('CoreViewerTextPage', params); - modal.present(); - } else if (navCtrl) { - // Open a new page with the contents. - navCtrl.push('CoreViewerTextPage', params); - } + let modal = this.modalCtrl.create('CoreViewerTextPage', params); + modal.present(); } - } /** diff --git a/src/providers/utils/time.ts b/src/providers/utils/time.ts index f4deaa138..3382e77eb 100644 --- a/src/providers/utils/time.ts +++ b/src/providers/utils/time.ts @@ -33,17 +33,17 @@ export class CoreTimeUtilsProvider { */ formatTime(seconds: number) : string { let totalSecs = Math.abs(seconds), - years = Math.floor(totalSecs / CoreConstants.secondsYear), - remainder = totalSecs - (years * CoreConstants.secondsYear), - days = Math.floor(remainder / CoreConstants.secondsDay); + years = Math.floor(totalSecs / CoreConstants.SECONDS_YEAR), + remainder = totalSecs - (years * CoreConstants.SECONDS_YEAR), + days = Math.floor(remainder / CoreConstants.SECONDS_DAY); - remainder = totalSecs - (days * CoreConstants.secondsDay); + remainder = totalSecs - (days * CoreConstants.SECONDS_DAY); - let hours = Math.floor(remainder / CoreConstants.secondsHour); - remainder = remainder - (hours * CoreConstants.secondsHour); + let hours = Math.floor(remainder / CoreConstants.SECONDS_HOUR); + remainder = remainder - (hours * CoreConstants.SECONDS_HOUR); - let mins = Math.floor(remainder / CoreConstants.secondsMinute), - secs = remainder - (mins * CoreConstants.secondsMinute), + let mins = Math.floor(remainder / CoreConstants.SECONDS_MINUTE), + secs = remainder - (mins * CoreConstants.SECONDS_MINUTE), ss = this.translate.instant('core.' + (secs == 1 ? 'sec' : 'secs')), sm = this.translate.instant('core.' + (mins == 1 ? 'min' : 'mins')), sh = this.translate.instant('core.' + (hours == 1 ? 'hour' : 'hours')), diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 4160cbca8..b96d29995 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -24,10 +24,29 @@ import { CoreLoggerProvider } from '../logger'; import { TranslateService } from '@ngx-translate/core'; import { CoreLangProvider } from '../lang'; +/** + * Deferred promise. It's similar to the result of $q.defer() in AngularJS. + */ export interface PromiseDefer { - promise?: Promise; // Promise created. - resolve?: (value?: any) => any; // Function to resolve the promise. - reject?: (reason?: any) => any; // Function to reject the promise. + /** + * The promise. + * @type {Promise} + */ + promise?: Promise; + + /** + * Function to resolve the promise. + * + * @param {any} [value] The resolve value. + */ + resolve?: (value?: any) => void; // Function to resolve the promise. + + /** + * Function to reject the promise. + * + * @param {any} [reason] The reject param. + */ + reject?: (reason?: any) => void; } /* @@ -35,8 +54,8 @@ export interface PromiseDefer { */ @Injectable() export class CoreUtilsProvider { - logger; - iabInstance: InAppBrowserObject; + protected logger; + protected iabInstance: InAppBrowserObject; constructor(private iab: InAppBrowser, private appProvider: CoreAppProvider, private clipboard: Clipboard, private domUtils: CoreDomUtilsProvider, logger: CoreLoggerProvider, private translate: TranslateService, @@ -79,6 +98,23 @@ export class CoreUtilsProvider { }); } + /** + * Converts an array of objects to an object, using a property of each entry as the key. + * E.g. [{id: 10, name: 'A'}, {id: 11, name: 'B'}] => {10: {id: 10, name: 'A'}, 11: {id: 11, name: 'B'}} + * + * @param {any[]} array The array to convert. + * @param {string} propertyName The name of the property to use as the key. + * @param {any} [result] Object where to put the properties. If not defined, a new object will be created. + * @return {any} The object. + */ + arrayToObject(array: any[], propertyName: string, result?: any) : any { + result = result || {}; + array.forEach((entry) => { + result[entry[propertyName]] = entry; + }); + return result; + } + /** * Compare two objects. This function won't compare functions and proto properties, it's a basic compare. * Also, this will only check if itemA's properties are in itemB with same value. This function will still @@ -134,11 +170,11 @@ export class CoreUtilsProvider { * Blocks leaving a view. This function should be used in views that want to perform a certain action before * leaving (usually, ask the user if he wants to leave because some data isn't saved). * - * @param {Object} scope View's scope. + * @param {object} scope View's scope. * @param {Function} canLeaveFn Function called when the user wants to leave the view. Must return a promise * resolved if the view should be left, rejected if the user should stay in the view. - * @param {Object} [currentView] Current view. Defaults to $ionicHistory.currentView(). - * @return {Object} Object with: + * @param {object} [currentView] Current view. Defaults to $ionicHistory.currentView(). + * @return {object} Object with: * -back: Original back function. * -unblock: Function to unblock. It is called automatically when scope is destroyed. */ @@ -291,15 +327,14 @@ export class CoreUtilsProvider { * * @param {any} from Object to copy the properties from. * @param {any} to Object where to store the properties. + * @param {boolean} [clone=true] Whether the properties should be cloned (so they are different instances). */ - copyProperties(from: any, to: any) : void { + copyProperties(from: any, to: any, clone = true) : void { for (let name in from) { - let value = from[name]; - if (typeof value == 'object') { - // Clone the object. - to[name] = Object.assign({}, value); + if (clone) { + to[name] = this.clone(from[name]); } else { - to[name] = value; + to[name] = from[name]; } } } @@ -397,15 +432,16 @@ export class CoreUtilsProvider { for (let name in obj) { if (!obj.hasOwnProperty(name)) continue; - if (typeof obj[name] == 'object') { - let flatObject = this.flattenObject(obj[name]); + let value = obj[name]; + if (typeof value == 'object' && !Array.isArray(value)) { + let flatObject = this.flattenObject(value); for (let subName in flatObject) { if (!flatObject.hasOwnProperty(subName)) continue; toReturn[name + '.' + subName] = flatObject[subName]; } } else { - toReturn[name] = obj[name]; + toReturn[name] = value; } } @@ -1093,16 +1129,35 @@ export class CoreUtilsProvider { * @return {string} Stringified object. */ sortAndStringify(obj: object) : string { - return JSON.stringify(obj, Object.keys(this.flattenObject(obj)).sort()); + return JSON.stringify(this.sortProperties(obj)); + } + + /** + * Given an object, sort its properties and the properties of all the nested objects. + * + * @param {object} obj The object to sort. If it isn't an object, the original value will be returned. + * @return {object} Sorted object. + */ + sortProperties(obj: object) : object { + if (typeof obj == 'object' && !Array.isArray(obj)) { + // It's an object, sort it. + return Object.keys(obj).sort().reduce((accumulator, key) => { + // Always call sort with the value. If it isn't an object, the original value will be returned. + accumulator[key] = this.sortProperties(obj[key]); + return accumulator; + }, {}); + } else { + return obj; + } } /** * Sum the filesizes from a list of files checking if the size will be partial or totally calculated. * * @param {any[]} files List of files to sum its filesize. - * @return {object} Object with the file size and a boolean to indicate if it is the total size or only partial. + * @return {{size: number, total: boolean}} File size and a boolean to indicate if it is the total size or only partial. */ - sumFileSizes(files: any[]) : object { + sumFileSizes(files: any[]) : {size: number, total: boolean} { let result = { size: 0, total: true diff --git a/src/providers/ws.ts b/src/providers/ws.ts index d5fb825f4..aa1d5c5c7 100644 --- a/src/providers/ws.ts +++ b/src/providers/ws.ts @@ -28,39 +28,95 @@ import { Md5 } from 'ts-md5/dist/md5'; import { CoreInterceptor } from '../classes/interceptor'; /** - * Interface of the presets accepted by the WS call. + * PreSets accepted by the WS call. */ export interface CoreWSPreSets { - siteUrl: string; // The site URL. - wsToken: string; // The Webservice token. - responseExpected?: boolean; // Defaults to true. Set to false when the expected response is null. - typeExpected?: string; // Defaults to 'object'. Use it when you expect a type that's not an object|array. - cleanUnicode?: boolean; // Defaults to false. Clean multibyte Unicode chars from data. + /** + * The site URL. + * @type {string} + */ + siteUrl: string; + + /** + * The Webservice token. + * @type {string} + */ + wsToken: string; + + /** + * Defaults to true. Set to false when the expected response is null. + * @type {boolean} + */ + responseExpected?: boolean; + + /** + * Defaults to 'object'. Use it when you expect a type that's not an object|array. + * @type {string} + */ + typeExpected?: string; + + /** + * Defaults to false. Clean multibyte Unicode chars from data. + * @type {string} + */ + cleanUnicode?: boolean; }; /** - * Interface of the presets accepted by AJAX WS calls. + * PreSets accepted by AJAX WS calls. */ export interface CoreWSAjaxPreSets { - siteUrl: string; // The site URL. - responseExpected?: boolean; // Defaults to true. Set to false when the expected response is null. + /** + * The site URL. + * @type {string} + */ + siteUrl: string; + + /** + * Defaults to true. Set to false when the expected response is null. + * @type {boolean} + */ + responseExpected?: boolean; }; /** - * Interface for WS Errors. + * Error returned by a WS call. */ export interface CoreWSError { - message: string; // The error message. - exception?: string; // Name of the exception. Undefined for local errors (fake WS errors). - errorcode?: string; // The error code. Undefined for local errors (fake WS errors). + /** + * The error message. + * @type {string} + */ + message: string; + + /** + * Name of the exception. Undefined for local errors (fake WS errors). + * @type {string} + */ + exception?: string; + + /** + * The error code. Undefined for local errors (fake WS errors). + * @type {string} + */ + errorcode?: string; }; /** - * Interface for file upload options. + * File upload options. */ export interface CoreWSFileUploadOptions extends FileUploadOptions { - fileArea?: string; // The file area where to put the file. By default, 'draft'. - itemId?: number; // Item ID of the area where to put the file. By default, 0. + /** + * The file area where to put the file. By default, 'draft'. + * @type {string} + */ + fileArea?: string; + + /** + * Item ID of the area where to put the file. By default, 0. + * @type {number} + */ + itemId?: number; }; /** @@ -68,11 +124,11 @@ export interface CoreWSFileUploadOptions extends FileUploadOptions { */ @Injectable() export class CoreWSProvider { - logger; - mimeTypeCache = {}; // A "cache" to store file mimetypes to prevent performing too many HEAD requests. - ongoingCalls = {}; - retryCalls = []; - retryTimeout = 0; + protected logger; + protected mimeTypeCache = {}; // A "cache" to store file mimetypes to prevent performing too many HEAD requests. + protected ongoingCalls = {}; + protected retryCalls = []; + protected retryTimeout = 0; constructor(private http: HttpClient, private translate: TranslateService, private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider, logger: CoreLoggerProvider, private utils: CoreUtilsProvider, @@ -126,6 +182,8 @@ export class CoreWSProvider { preSets.responseExpected = true; } + data = data || {}; + data = this.utils.clone(data); // Clone the data so the changes don't affect the original data. data.wsfunction = method; data.wstoken = preSets.wsToken; siteUrl = preSets.siteUrl + '/webservice/rest/server.php?moodlewsrestformat=json'; @@ -178,7 +236,7 @@ export class CoreWSProvider { siteUrl = preSets.siteUrl + '/lib/ajax/service.php'; - let observable = this.http.post(siteUrl, JSON.stringify(ajaxData)).timeout(CoreConstants.wsTimeout); + let observable = this.http.post(siteUrl, JSON.stringify(ajaxData)).timeout(CoreConstants.WS_TIMEOUT); return this.utils.observableToPromise(observable).then((data: any) => { // Some moodle web services return null. If the responseExpected value is set then so long as no data // is returned, we create a blank object. @@ -434,7 +492,7 @@ export class CoreWSProvider { let promise = this.getPromiseHttp('head', url); if (!promise) { - promise = this.utils.observableToPromise(this.commonHttp.head(url).timeout(CoreConstants.wsTimeout)); + promise = this.utils.observableToPromise(this.commonHttp.head(url).timeout(CoreConstants.WS_TIMEOUT)); promise = this.setPromiseHttp(promise, 'head', url); } @@ -452,7 +510,7 @@ export class CoreWSProvider { */ performPost(method: string, siteUrl: string, ajaxData: any, preSets: CoreWSPreSets) : Promise { // Perform the post request. - let observable = this.http.post(siteUrl, ajaxData).timeout(CoreConstants.wsTimeout), + let observable = this.http.post(siteUrl, ajaxData).timeout(CoreConstants.WS_TIMEOUT), promise; promise = this.utils.observableToPromise(observable).then((data: any) => { @@ -545,7 +603,7 @@ export class CoreWSProvider { // HTTP not finished, but we should delete the promise after timeout. timeout = setTimeout(() => { delete this.ongoingCalls[queueItemId]; - }, CoreConstants.wsTimeout); + }, CoreConstants.WS_TIMEOUT); // HTTP finished, delete from ongoing. return promise.finally(() => {