diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index ed4cf9987..388b3be63 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -37,7 +37,10 @@ const routes: Routes = [ @NgModule({ imports: [ - RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }), + RouterModule.forRoot(routes, { + preloadingStrategy: PreloadAllModules, + relativeLinkResolution: 'corrected', + }), ], exports: [RouterModule], }) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 16208a998..daf0e9600 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -50,6 +50,11 @@ import { CoreTimeUtilsProvider } from '@services/utils/time'; import { CoreUrlUtilsProvider } from '@services/utils/url'; import { CoreUtilsProvider } from '@services/utils/utils'; +// Import init DB functions of core services. +import { initCoreFilepoolDB } from '@services/filepool.db'; +import { initCoreSitesDB } from '@services/sites.db'; +import { initCoreSyncDB } from '@services/sync.db'; + // Import core modules. import { CoreEmulatorModule } from '@core/emulator/emulator.module'; import { CoreLoginModule } from '@core/login/login.module'; @@ -121,6 +126,8 @@ export class AppModule { // Set the injector. setSingletonsInjector(injector); + this.initCoreServicesDB(); + // Register a handler for platform ready. CoreInit.instance.registerProcess({ name: 'CorePlatformReady', @@ -154,4 +161,13 @@ export class AppModule { CoreInit.instance.executeInitProcesses(); } + /** + * Init the DB of core services. + */ + protected initCoreServicesDB(): void { + initCoreFilepoolDB(); + initCoreSitesDB(); + initCoreSyncDB(); + } + } diff --git a/src/app/classes/site.ts b/src/app/classes/site.ts index 53e66669e..539b3259d 100644 --- a/src/app/classes/site.ts +++ b/src/app/classes/site.ts @@ -36,7 +36,7 @@ import { CoreIonLoadingElement } from './ion-loading'; /** * Class that represents a site (combination of site + user). * It will have all the site data and provide utility functions regarding a site. - * To add tables to the site's database, please use CoreSitesProvider.registerSiteSchema. This will make sure that + * To add tables to the site's database, please use registerSiteSchema exported in @services/sites.ts. This will make sure that * the tables are created in all the sites, not just the current one. * * @todo: Refactor this class to improve "temporary" sites support (not fully authenticated). diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index b16efbbcb..53d44a614 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -18,7 +18,12 @@ import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreIconComponent } from './icon/icon'; +import { CoreIframeComponent } from './iframe/iframe'; +import { CoreInputErrorsComponent } from './input-errors/input-errors'; import { CoreLoadingComponent } from './loading/loading'; +import { CoreMarkRequiredComponent } from './mark-required/mark-required'; +import { CoreRecaptchaComponent } from './recaptcha/recaptcha'; +import { CoreRecaptchaModalComponent } from './recaptcha/recaptchamodal'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreDirectivesModule } from '@app/directives/directives.module'; @@ -27,7 +32,12 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; @NgModule({ declarations: [ CoreIconComponent, + CoreIframeComponent, + CoreInputErrorsComponent, CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, ], @@ -40,7 +50,12 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; ], exports: [ CoreIconComponent, + CoreIframeComponent, + CoreInputErrorsComponent, CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, ], diff --git a/src/app/components/iframe/core-iframe.html b/src/app/components/iframe/core-iframe.html new file mode 100644 index 000000000..1c05ee6e4 --- /dev/null +++ b/src/app/components/iframe/core-iframe.html @@ -0,0 +1,11 @@ +
+ + + + + + +
\ No newline at end of file diff --git a/src/app/components/iframe/iframe.scss b/src/app/components/iframe/iframe.scss new file mode 100644 index 000000000..d99e54aae --- /dev/null +++ b/src/app/components/iframe/iframe.scss @@ -0,0 +1,31 @@ +ion-app.app-root core-iframe { + + > div { + max-width: 100%; + max-height: 100%; + } + iframe { + border: 0; + display: block; + max-width: 100%; + background-color: $gray-light; + } + + .core-loading-container { + position: absolute; + @include position(0, 0, 0, 0); + display: table; + height: 100%; + width: 100%; + z-index: 1; + margin: 0; + padding: 0; + clear: both; + + .core-loading-spinner { + display: table-cell; + text-align: center; + vertical-align: middle; + } + } +} diff --git a/src/app/components/iframe/iframe.ts b/src/app/components/iframe/iframe.ts new file mode 100644 index 000000000..c3fa0e8b2 --- /dev/null +++ b/src/app/components/iframe/iframe.ts @@ -0,0 +1,117 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Component, Input, Output, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange, +} from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { NavController } from '@ionic/angular'; + +import { CoreFile } from '@services/file'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreIframeUtils } from '@services/utils/iframe'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLogger } from '@singletons/logger'; + +@Component({ + selector: 'core-iframe', + templateUrl: 'core-iframe.html', +}) +export class CoreIframeComponent implements OnChanges { + + @ViewChild('iframe') iframe?: ElementRef; + @Input() src?: string; + @Input() iframeWidth?: string; + @Input() iframeHeight?: string; + @Input() allowFullscreen?: boolean | string; + @Output() loaded: EventEmitter = new EventEmitter(); + + loading?: boolean; + safeUrl?: SafeResourceUrl; + + protected readonly IFRAME_TIMEOUT = 15000; + protected logger: CoreLogger; + protected initialized = false; + + constructor( + protected sanitizer: DomSanitizer, + protected navCtrl: NavController, + ) { + + this.logger = CoreLogger.getInstance('CoreIframe'); + this.loaded = new EventEmitter(); + } + + /** + * Init the data. + */ + protected init(): void { + if (this.initialized) { + return; + } + + const iframe: HTMLIFrameElement | undefined = this.iframe?.nativeElement; + if (!iframe) { + return; + } + + this.initialized = true; + + this.iframeWidth = (this.iframeWidth && CoreDomUtils.instance.formatPixelsSize(this.iframeWidth)) || '100%'; + this.iframeHeight = (this.iframeHeight && CoreDomUtils.instance.formatPixelsSize(this.iframeHeight)) || '100%'; + this.allowFullscreen = CoreUtils.instance.isTrueOrOne(this.allowFullscreen); + + // Show loading only with external URLs. + this.loading = !this.src || !CoreUrlUtils.instance.isLocalFileUrl(this.src); + + // @todo const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + CoreIframeUtils.instance.treatFrame(iframe, false, this.navCtrl); + + iframe.addEventListener('load', () => { + this.loading = false; + this.loaded.emit(iframe); // Notify iframe was loaded. + }); + + iframe.addEventListener('error', () => { + this.loading = false; + CoreDomUtils.instance.showErrorModal('core.errorloadingcontent', true); + }); + + if (this.loading) { + setTimeout(() => { + this.loading = false; + }, this.IFRAME_TIMEOUT); + } + } + + /** + * Detect changes on input properties. + */ + async ngOnChanges(changes: {[name: string]: SimpleChange }): Promise { + if (changes.src) { + const url = CoreUrlUtils.instance.getYoutubeEmbedUrl(changes.src.currentValue) || changes.src.currentValue; + + await CoreIframeUtils.instance.fixIframeCookies(url); + + this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(CoreFile.instance.convertFileSrc(url)); + + // Now that the URL has been set, initialize the iframe. Wait for the iframe to the added to the DOM. + setTimeout(() => { + this.init(); + }); + } + } + +} diff --git a/src/app/components/input-errors/core-input-errors.html b/src/app/components/input-errors/core-input-errors.html new file mode 100644 index 000000000..9f216a964 --- /dev/null +++ b/src/app/components/input-errors/core-input-errors.html @@ -0,0 +1,16 @@ + diff --git a/src/app/components/input-errors/input-errors.scss b/src/app/components/input-errors/input-errors.scss new file mode 100644 index 000000000..de3f96567 --- /dev/null +++ b/src/app/components/input-errors/input-errors.scss @@ -0,0 +1,16 @@ +:host { + width: 100%; + + .core-input-error-container { + .core-input-error { + padding: 4px; + color: var(--ion-color-danger); + font-size: 12px; + display: none; + + &:first-child { + display: block; + } + } + } +} diff --git a/src/app/components/input-errors/input-errors.ts b/src/app/components/input-errors/input-errors.ts new file mode 100644 index 000000000..1bad391ab --- /dev/null +++ b/src/app/components/input-errors/input-errors.ts @@ -0,0 +1,86 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnChanges, SimpleChange } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Component to show errors if an input isn't valid. + * + * @description + * The purpose of this component is to make easier and consistent the validation of forms. + * + * It should be applied next to the input element (ion-input, ion-select, ...). In case of ion-checkbox, it should be in another + * item, placing it in the same item as the checkbox will cause problems. + * + * Please notice that the inputs need to have a FormControl to make it work. That FormControl needs to be passed to this component. + * + * If this component is placed in the same ion-item as a ion-label or ion-input, then it should have the attribute "item-content", + * otherwise Ionic will remove it. + * + * Example usage: + * + * + * {{ 'core.login.username' | translate }} + * + * + * + */ +@Component({ + selector: 'core-input-errors', + templateUrl: 'core-input-errors.html', + styleUrls: ['input-errors.scss'], +}) +export class CoreInputErrorsComponent implements OnChanges { + + @Input() control?: FormControl; + @Input() errorMessages?: Record; + @Input() errorText?: string; // Set other non automatic errors. + errorKeys: string[] = []; + + /** + * Initialize some common errors if they aren't set. + */ + protected initErrorMessages(): void { + this.errorMessages = this.errorMessages || {}; + + this.errorMessages.required = this.errorMessages.required || Translate.instance.instant('core.required'); + this.errorMessages.email = this.errorMessages.email || Translate.instance.instant('core.login.invalidemail'); + this.errorMessages.date = this.errorMessages.date || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.datetime = this.errorMessages.datetime || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.datetimelocal = this.errorMessages.datetimelocal || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.time = this.errorMessages.time || Translate.instance.instant('core.login.invalidtime'); + this.errorMessages.url = this.errorMessages.url || Translate.instance.instant('core.login.invalidurl'); + + // Set empty values by default, the default error messages will be built in the template when needed. + this.errorMessages.max = this.errorMessages.max || ''; + this.errorMessages.min = this.errorMessages.min || ''; + } + + /** + * Component being changed. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if ((changes.control || changes.errorMessages) && this.control) { + this.initErrorMessages(); + + this.errorKeys = this.errorMessages ? Object.keys(this.errorMessages) : []; + } + if (changes.errorText) { + this.errorText = changes.errorText.currentValue; + } + } + +} diff --git a/src/app/components/mark-required/core-mark-required.html b/src/app/components/mark-required/core-mark-required.html new file mode 100644 index 000000000..bf5cd4897 --- /dev/null +++ b/src/app/components/mark-required/core-mark-required.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/components/mark-required/mark-required.scss b/src/app/components/mark-required/mark-required.scss new file mode 100644 index 000000000..def9d3ecb --- /dev/null +++ b/src/app/components/mark-required/mark-required.scss @@ -0,0 +1,8 @@ +:host { + .core-input-required-asterisk { + font-size: 8px; + --padding-start: 4px; + line-height: 100%; + vertical-align: top; + } +} diff --git a/src/app/components/mark-required/mark-required.ts b/src/app/components/mark-required/mark-required.ts new file mode 100644 index 000000000..c62e30bd6 --- /dev/null +++ b/src/app/components/mark-required/mark-required.ts @@ -0,0 +1,78 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, AfterViewInit, ElementRef } from '@angular/core'; + +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Directive to add a red asterisk for required input fields. + * + * @description + * For forms with required and not required fields, it is recommended to use this directive to mark the required ones. + * + * This directive should be applied in the label. Example: + * + * {{ 'core.login.username' | translate }} + */ +@Component({ + selector: '[core-mark-required]', + templateUrl: 'core-mark-required.html', + styleUrls: ['mark-required.scss'], +}) +export class CoreMarkRequiredComponent implements OnInit, AfterViewInit { + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('core-mark-required') coreMarkRequired: boolean | string = true; + + protected element: HTMLElement; + requiredLabel?: string; + + constructor( + element: ElementRef, + ) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.requiredLabel = Translate.instance.instant('core.required'); + this.coreMarkRequired = CoreUtils.instance.isTrueOrOne(this.coreMarkRequired); + } + + /** + * Called after the view is initialized. + */ + ngAfterViewInit(): void { + if (this.coreMarkRequired) { + // Add the "required" to the aria-label. + const ariaLabel = this.element.getAttribute('aria-label') || + CoreTextUtils.instance.cleanTags(this.element.innerHTML, true); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel + ' ' + this.requiredLabel); + } + } else { + // Remove the "required" from the aria-label. + const ariaLabel = this.element.getAttribute('aria-label'); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel.replace(' ' + this.requiredLabel, '')); + } + } + } + +} diff --git a/src/app/components/recaptcha/core-recaptcha.html b/src/app/components/recaptcha/core-recaptcha.html new file mode 100644 index 000000000..d094e17dc --- /dev/null +++ b/src/app/components/recaptcha/core-recaptcha.html @@ -0,0 +1,13 @@ + +
+ + + {{ 'core.resourcedisplayopen' | translate }} + + + {{ 'core.answered' | translate }} + + + {{ 'core.login.recaptchaexpired' | translate }} + +
diff --git a/src/app/components/recaptcha/core-recaptchamodal.html b/src/app/components/recaptcha/core-recaptchamodal.html new file mode 100644 index 000000000..e49d8989b --- /dev/null +++ b/src/app/components/recaptcha/core-recaptchamodal.html @@ -0,0 +1,14 @@ + + + {{ 'core.login.security_question' | translate }} + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/components/recaptcha/recaptcha.ts b/src/app/components/recaptcha/recaptcha.ts new file mode 100644 index 000000000..8edc8b2b0 --- /dev/null +++ b/src/app/components/recaptcha/recaptcha.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; + +import { CoreLang } from '@services/lang'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { ModalController } from '@singletons/core.singletons'; +import { CoreRecaptchaModalComponent } from './recaptchamodal'; + +/** + * Component that allows answering a recaptcha. + */ +@Component({ + selector: 'core-recaptcha', + templateUrl: 'core-recaptcha.html', +}) +export class CoreRecaptchaComponent implements OnInit { + + @Input() model?: Record; // The model where to store the recaptcha response. + @Input() publicKey?: string; // The site public key. + @Input() modelValueName = 'recaptcharesponse'; // Name of the model property where to store the response. + @Input() siteUrl?: string; // The site URL. If not defined, current site. + + expired = false; + + protected lang?: string; + + constructor() { + this.initLang(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.siteUrl = this.siteUrl || CoreSites.instance.getCurrentSite()?.getURL(); + } + + /** + * Initialize the lang property. + */ + protected async initLang(): Promise { + this.lang = await CoreLang.instance.getCurrentLanguage(); + } + + /** + * Open the recaptcha modal. + */ + async answerRecaptcha(): Promise { + // Set the iframe src. We use an iframe because reCaptcha V2 doesn't work with file:// protocol. + const src = CoreTextUtils.instance.concatenatePaths(this.siteUrl!, 'webservice/recaptcha.php?lang=' + this.lang); + + // Modal to answer the recaptcha. + // This is because the size of the recaptcha is dynamic, so it could cause problems if it was displayed inline. + + const modal = await ModalController.instance.create({ + component: CoreRecaptchaModalComponent, + cssClass: 'core-modal-fullscreen', + componentProps: { + recaptchaUrl: src, + }, + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + this.expired = result.data.expired; + this.model![this.modelValueName] = result.data.value; + } + +} diff --git a/src/app/components/recaptcha/recaptchamodal.ts b/src/app/components/recaptcha/recaptchamodal.ts new file mode 100644 index 000000000..5a56bc2a0 --- /dev/null +++ b/src/app/components/recaptcha/recaptchamodal.ts @@ -0,0 +1,121 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnDestroy } from '@angular/core'; + +import { ModalController } from '@singletons/core.singletons'; + +/** + * Component to display a the recaptcha in a modal. + */ +@Component({ + selector: 'core-recaptcha-modal', + templateUrl: 'core-recaptchamodal.html', +}) +export class CoreRecaptchaModalComponent implements OnDestroy { + + @Input() recaptchaUrl?: string; + + expired = false; + value = ''; + + protected messageListenerFunction: (event: MessageEvent) => Promise; + + constructor() { + // Listen for messages from the iframe. + this.messageListenerFunction = this.onIframeMessage.bind(this); + window.addEventListener('message', this.messageListenerFunction); + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.instance.dismiss({ + expired: this.expired, + value: this.value, + }); + } + + /** + * The iframe with the recaptcha was loaded. + * + * @param iframe Iframe element. + */ + loaded(iframe: HTMLIFrameElement): void { + // Search the iframe content. + const contentWindow = iframe?.contentWindow; + + if (contentWindow) { + try { + // Set the callbacks we're interested in. + contentWindow['recaptchacallback'] = this.onRecaptchaCallback.bind(this); + contentWindow['recaptchaexpiredcallback'] = this.onRecaptchaExpiredCallback.bind(this); + } catch (error) { + // Cannot access the window. + } + } + } + + /** + * Treat an iframe message event. + * + * @param event Event. + * @return Promise resolved when done. + */ + protected async onIframeMessage(event: MessageEvent): Promise { + if (!event.data || event.data.environment != 'moodleapp' || event.data.context != 'recaptcha') { + return; + } + + switch (event.data.action) { + case 'callback': + this.onRecaptchaCallback(event.data.value); + break; + case 'expired': + this.onRecaptchaExpiredCallback(); + break; + + default: + break; + } + } + + /** + * Recapcha callback called. + * + * @param value Value received. + */ + protected onRecaptchaCallback(value: string): void { + this.expired = false; + this.value = value; + this.closeModal(); + } + + /** + * Recapcha expired callback called. + */ + protected onRecaptchaExpiredCallback(): void { + this.expired = true; + this.value = ''; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + window.removeEventListener('message', this.messageListenerFunction); + } + +} diff --git a/src/app/components/tests/icon.test.ts b/src/app/components/tests/icon.test.ts index e7157feda..531bbc9fc 100644 --- a/src/app/components/tests/icon.test.ts +++ b/src/app/components/tests/icon.test.ts @@ -26,9 +26,10 @@ describe('CoreIconComponent', () => { expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0); const icon = fixture.nativeElement.querySelector('ion-icon'); + const name = icon.getAttribute('name') || icon.getAttribute('ng-reflect-name') || ''; + expect(icon).not.toBeNull(); - expect(icon.classList.contains('fa')).toBe(true); - expect(icon.classList.contains('fa-thumbs-up')).toBe(true); + expect(name).toEqual('fa-thumbs-up'); expect(icon.getAttribute('role')).toEqual('presentation'); }); diff --git a/src/app/core/login/components/site-help/site-help.html b/src/app/core/login/components/site-help/site-help.html new file mode 100644 index 000000000..5bc9e9615 --- /dev/null +++ b/src/app/core/login/components/site-help/site-help.html @@ -0,0 +1,76 @@ + + + {{ 'core.login.help' | translate }} + + + + + + + + + + + + +

{{ 'core.login.faqcannotfindmysitequestion' | translate }}

+
+
+ + +

{{ 'core.login.faqcannotfindmysiteanswer' | translate }}

+
+
+ + +

{{ 'core.login.faqwhatisurlquestion' | translate }}

+
+
+ + + +

{{ 'core.login.faqcannotconnectquestion' | translate }}

+
+
+ + +

{{ 'core.login.faqcannotconnectanswer' | translate }} {{ 'core.whoissiteadmin' | translate }}

+
+
+ + +

{{ 'core.login.faqsetupsitequestion' | translate }}

+
+
+ + +

+

+
+
+ + +

{{ 'core.login.faqtestappquestion' | translate }}

+
+
+ + +

{{ 'core.login.faqtestappanswer' | translate }}

+
+
+ + +

{{ 'core.login.faqwhereisqrcode' | translate }}

+
+
+ + +

+
+
+
+
\ No newline at end of file diff --git a/src/app/core/login/components/site-help/site-help.scss b/src/app/core/login/components/site-help/site-help.scss new file mode 100644 index 000000000..0c6f38720 --- /dev/null +++ b/src/app/core/login/components/site-help/site-help.scss @@ -0,0 +1,9 @@ +.core-login-faqwhatisurlanswer img { + max-height: 50px; +} + +.core-login-faqwhereisqrcodeanswer img { + max-height: 220px; + margin-top: 5px; + margin-bottom: 5px; +} \ No newline at end of file diff --git a/src/app/core/login/components/site-help/site-help.ts b/src/app/core/login/components/site-help/site-help.ts new file mode 100644 index 000000000..1cd9030ae --- /dev/null +++ b/src/app/core/login/components/site-help/site-help.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; + +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Translate } from '@singletons/core.singletons'; +import { CoreLoginHelperProvider } from '@core/login/services/helper'; + +/** + * Component that displays help to connect to a site. + */ +@Component({ + selector: 'core-login-site-help', + templateUrl: 'site-help.html', + styleUrls: ['site-help.scss'], +}) +export class CoreLoginSiteHelpComponent { + + urlImageHtml: string; + setupLinkHtml: string; + qrCodeImageHtml: string; + canScanQR: boolean; + + constructor() { + + this.canScanQR = CoreUtils.instance.canScanQR(); + this.urlImageHtml = CoreLoginHelperProvider.FAQ_URL_IMAGE_HTML; + this.qrCodeImageHtml = CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML; + this.setupLinkHtml = 'https://moodle.com/getstarted/'; + } + + /** + * Close help modal. + */ + closeHelp(): void { + ModalController.instance.dismiss(); + } + +} diff --git a/src/app/core/login/components/site-onboarding/site-onboarding.html b/src/app/core/login/components/site-onboarding/site-onboarding.html new file mode 100644 index 000000000..9c34463e2 --- /dev/null +++ b/src/app/core/login/components/site-onboarding/site-onboarding.html @@ -0,0 +1,59 @@ + + + + + + + + + + + {{'core.skip' | translate}} + + + + + +
+ + + + + + + + + +
+
diff --git a/src/app/core/login/components/site-onboarding/site-onboarding.scss b/src/app/core/login/components/site-onboarding/site-onboarding.scss new file mode 100644 index 000000000..1bbab53c3 --- /dev/null +++ b/src/app/core/login/components/site-onboarding/site-onboarding.scss @@ -0,0 +1,28 @@ +:host { + .core-login-onboarding-step { + padding: 10px 20px; + text-align: center; + /* @todo @include media-breakpoint-up(md) { + max-width: 80%; + }*/ + margin: 0 auto; + + p { + margin-bottom: 10px; + } + + ul { + margin-bottom: 30px; + list-style-type: none; + // @todo @include text-align('start'); + // @todo @include padding-horizontal(10px, null); + li { + margin-bottom: 10px; + } + } + + .button-block { + margin-top: 20px; + } + } +} diff --git a/src/app/core/login/components/site-onboarding/site-onboarding.ts b/src/app/core/login/components/site-onboarding/site-onboarding.ts new file mode 100644 index 000000000..29e568f4e --- /dev/null +++ b/src/app/core/login/components/site-onboarding/site-onboarding.ts @@ -0,0 +1,94 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; + +import { CoreConfig } from '@services/config'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLoginHelperProvider } from '@core/login/services/helper'; +import { ModalController } from '@singletons/core.singletons'; + +/** + * Component that displays onboarding help regarding the CoreLoginSitePage. + */ +@Component({ + selector: 'core-login-site-onboarding', + templateUrl: 'site-onboarding.html', + styleUrls: ['site-onboarding.scss', '../../login.scss'], +}) +export class CoreLoginSiteOnboardingComponent { + + step = 0; + + /** + * Go to next step. + * + * @param e Click event. + */ + next(e: Event): void { + e.stopPropagation(); + + this.step++; + } + + /** + * Go to previous step. + * + * @param e Click event. + */ + previous(e: Event): void { + e.stopPropagation(); + + if (this.step == 0) { + ModalController.instance.dismiss(); + } else { + this.step--; + } + } + + /** + * Close modal. + * + * @param e Click event. + */ + skip(e: Event): void { + e.stopPropagation(); + + this.saveOnboardingDone(); + ModalController.instance.dismiss(); + } + + /** + * Create a site. + * + * @param e Click event. + */ + gotoWeb(e: Event): void { + e.stopPropagation(); + + this.saveOnboardingDone(); + + CoreUtils.instance.openInBrowser('https://moodle.com/getstarted/'); + + ModalController.instance.dismiss(); + } + + /** + * Saves the onboarding has finished. + */ + protected saveOnboardingDone(): void { + CoreConfig.instance.set(CoreLoginHelperProvider.ONBOARDING_DONE, 1); + } + +} diff --git a/src/app/core/login/lang/en.json b/src/app/core/login/lang/en.json index e655fb691..29cf529c3 100644 --- a/src/app/core/login/lang/en.json +++ b/src/app/core/login/lang/en.json @@ -36,7 +36,7 @@ "faqsetupsitequestion": "I want to set up my own Moodle site.", "faqtestappanswer": "To test the app in a Moodle Demo Site, type \"teacher\" or \"student\" in the \"Your site\" field and click the \"Connect to your site\" button.", "faqtestappquestion": "I just want to test the app, what can I do?", - "faqwhatisurlanswer": "

Every organisation has their own unique address or URL for their Moodle site. To find the address:

  1. Open a web browser and go to your Moodle site login page.
  2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
    {{$image}}
  3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
  4. Now you can log in to your site using your username and password.
  5. ", + "faqwhatisurlanswer": "

    Every organisation has their own unique address or URL for their Moodle site. To find the address:

    1. Open a web browser and go to your Moodle site login page.
    2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
      {{$image}}
    3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
    4. Now you can log in to your site using your username and password.
    ", "faqwhatisurlquestion": "What is my site address? How can I find my site URL?", "faqwhereisqrcode": "Where can I find the QR code?", "faqwhereisqrcodeanswer": "

    If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.

    {{$image}}", @@ -121,4 +121,4 @@ "webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.", "youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.", "yourenteredsite": "Connect to your site" -} +} \ No newline at end of file diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index 384546ba8..b123f3b93 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -37,6 +37,24 @@ const routes: Routes = [ path: 'sites', loadChildren: () => import('./pages/sites/sites.page.module').then( m => m.CoreLoginSitesPageModule), }, + { + path: 'forgottenpassword', + loadChildren: () => import('./pages/forgotten-password/forgotten-password.module') + .then( m => m.CoreLoginForgottenPasswordPageModule), + }, + { + path: 'changepassword', + loadChildren: () => import('./pages/change-password/change-password.module') + .then( m => m.CoreLoginChangePasswordPageModule), + }, + { + path: 'sitepolicy', + loadChildren: () => import('./pages/site-policy/site-policy.module').then( m => m.CoreLoginSitePolicyPageModule), + }, + { + path: 'emailsignup', + loadChildren: () => import('./pages/email-signup/email-signup.module').then( m => m.CoreLoginEmailSignupPageModule), + }, ]; @NgModule({ diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts index fdfb74a9c..24294daaa 100644 --- a/src/app/core/login/login.module.ts +++ b/src/app/core/login/login.module.ts @@ -13,12 +13,32 @@ // 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 { CoreLoginRoutingModule } from './login-routing.module'; +import { CoreLoginSiteHelpComponent } from './components/site-help/site-help'; +import { CoreLoginSiteOnboardingComponent } from './components/site-onboarding/site-onboarding'; @NgModule({ imports: [ CoreLoginRoutingModule, + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreLoginSiteHelpComponent, + CoreLoginSiteOnboardingComponent, + ], + exports: [ + CoreLoginSiteHelpComponent, + CoreLoginSiteOnboardingComponent, ], - declarations: [], }) export class CoreLoginModule {} diff --git a/src/app/core/login/login.scss b/src/app/core/login/login.scss index 7bf780260..6afeeed42 100644 --- a/src/app/core/login/login.scss +++ b/src/app/core/login/login.scss @@ -21,4 +21,8 @@ max-width: 300px; margin: 5px auto; } + + .core-login-forgotten-password { + text-decoration: underline; + } } diff --git a/src/app/core/login/pages/change-password/change-password.html b/src/app/core/login/pages/change-password/change-password.html new file mode 100644 index 000000000..f40dde4be --- /dev/null +++ b/src/app/core/login/pages/change-password/change-password.html @@ -0,0 +1,48 @@ + + + + + + + {{ 'core.login.changepassword' | translate }} + + + + + + + + + + + + + +

    {{ 'core.login.forcepasswordchangenotice' | translate }}

    +

    {{ 'core.login.changepasswordinstructions' | translate }}

    +
    +
    + + {{ 'core.login.changepasswordbutton' | translate }} + +
    + + + +

    {{ 'core.login.changepasswordreconnectinstructions' | translate }}

    +
    +
    + + {{ 'core.login.reconnect' | translate }} + +
    + + +

    {{ 'core.login.changepasswordlogoutinstructions' | translate }}

    +
    +
    + + {{ logoutLabel | translate }} + +
    +
    diff --git a/src/app/core/login/pages/change-password/change-password.module.ts b/src/app/core/login/pages/change-password/change-password.module.ts new file mode 100644 index 000000000..f983cadd2 --- /dev/null +++ b/src/app/core/login/pages/change-password/change-password.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreLoginChangePasswordPage } from './change-password.page'; + +const routes: Routes = [ + { + path: '', + component: CoreLoginChangePasswordPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreDirectivesModule, + ], + declarations: [ + CoreLoginChangePasswordPage, + ], + exports: [RouterModule], +}) +export class CoreLoginChangePasswordPageModule {} diff --git a/src/app/core/login/pages/change-password/change-password.page.ts b/src/app/core/login/pages/change-password/change-password.page.ts new file mode 100644 index 000000000..b3ae64d02 --- /dev/null +++ b/src/app/core/login/pages/change-password/change-password.page.ts @@ -0,0 +1,77 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreLoginHelper } from '@core/login/services/helper'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Page that shows instructions to change the password. + */ +@Component({ + selector: 'page-core-login-change-password', + templateUrl: 'change-password.html', +}) +export class CoreLoginChangePasswordPage { + + changingPassword = false; + logoutLabel: string; + + constructor() { + this.logoutLabel = CoreLoginHelper.instance.getLogoutLabel(); + } + + /** + * Show a help modal. + */ + showHelp(): void { + CoreDomUtils.instance.showAlert( + Translate.instance.instant('core.help'), + Translate.instance.instant('core.login.changepasswordhelp'), + ); + } + + /** + * Open the change password page in a browser. + */ + openChangePasswordPage(): void { + CoreLoginHelper.instance.openInAppForEdit( + CoreSites.instance.getCurrentSiteId(), + '/login/change_password.php', + undefined, + true, + ); + this.changingPassword = true; + } + + /** + * Login the user. + */ + login(): void { + CoreLoginHelper.instance.goToSiteInitialPage(); + this.changingPassword = false; + } + + /** + * Logout the user. + */ + logout(): void { + CoreSites.instance.logout(); + this.changingPassword = false; + } + +} diff --git a/src/app/core/login/pages/credentials/credentials.html b/src/app/core/login/pages/credentials/credentials.html index 35661eec5..bf8dfa698 100644 --- a/src/app/core/login/pages/credentials/credentials.html +++ b/src/app/core/login/pages/credentials/credentials.html @@ -14,65 +14,79 @@ - + -
    +
    -

    +

    + +

    {{siteUrl}}

    - + @@ -80,7 +86,9 @@

    {{ 'core.login.selectsite' | translate }}

    - + + @@ -88,26 +96,24 @@ - - +
    {{ 'core.login.or' | translate }}
    + + + {{ 'core.scanqr' | translate }} +
    -
    - - + + diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 67aef30cb..9ef042fd1 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -15,6 +15,7 @@ import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; +import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; @@ -25,10 +26,11 @@ import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/h import { CoreSite } from '@classes/site'; import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; -import { Translate } from '@singletons/core.singletons'; +import { Translate, ModalController } from '@singletons/core.singletons'; import { CoreUrl } from '@singletons/url'; import { CoreUrlUtils } from '@services/utils/url'; -import { NavController } from '@ionic/angular'; +import { CoreLoginSiteHelpComponent } from '@core/login/components/site-help/site-help'; +import { CoreLoginSiteOnboardingComponent } from '@core/login/components/site-onboarding/site-onboarding'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -215,15 +217,25 @@ export class CoreLoginSitePage implements OnInit { /** * Show a help modal. */ - showHelp(): void { - // @todo + async showHelp(): Promise { + const modal = await ModalController.instance.create({ + component: CoreLoginSiteHelpComponent, + cssClass: 'core-modal-fullscreen', + }); + + await modal.present(); } /** * Show an onboarding modal. */ - showOnboarding(): void { - // @todo + async showOnboarding(): Promise { + const modal = await ModalController.instance.create({ + component: CoreLoginSiteOnboardingComponent, + cssClass: 'core-modal-fullscreen', + }); + + await modal.present(); } /** @@ -360,7 +372,6 @@ export class CoreLoginSitePage implements OnInit { pageParams['logoUrl'] = foundSite.imageurl; } - // @todo Navigate to credentials. this.navCtrl.navigateForward('/login/credentials', { queryParams: pageParams, }); diff --git a/src/app/core/login/pages/sites/sites.html b/src/app/core/login/pages/sites/sites.html index 8f0cab8f1..e80ca602e 100644 --- a/src/app/core/login/pages/sites/sites.html +++ b/src/app/core/login/pages/sites/sites.html @@ -19,9 +19,10 @@ - + - {{ 'core.pictureof' | translate:{$a: site.fullName} }} + {{ 'core.pictureof' | translate:{$a: site.fullName} }}

    {{site.fullName}}

    diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index 1d48d43fd..de0026857 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -52,7 +52,7 @@ export class CoreLoginHelperProvider { protected logger: CoreLogger; protected isSSOConfirmShown = false; protected isOpenEditAlertShown = false; - protected pageToLoad?: {page: string; params: Params; time: number}; // Page to load once main menu is opened. + protected pageToLoad?: {page: string; params?: Params; time: number}; // Page to load once main menu is opened. protected isOpeningReconnect = false; waitingForBrowser = false; @@ -123,7 +123,13 @@ export class CoreLoginHelperProvider { * Function called when an SSO InAppBrowser is closed or the app is resumed. Check if user needs to be logged out. */ checkLogout(): void { - // @todo + const currentSite = CoreSites.instance.getCurrentSite(); + const currentPage = CoreApp.instance.getCurrentPage(); + + if (!CoreApp.instance.isSSOAuthenticationOngoing() && currentSite?.isLoggedOut() && currentPage == 'login/reconnect') { + // User must reauthenticate but he closed the InAppBrowser without doing so, logout him. + CoreSites.instance.logout(); + } } /** @@ -163,12 +169,7 @@ export class CoreLoginHelperProvider { * @param username Username. * @param siteConfig Site config. */ - async forgottenPasswordClicked( - navCtrl: NavController, - siteUrl: string, - username: string, - siteConfig?: CoreSitePublicConfigResponse, - ): Promise { + async forgottenPasswordClicked(siteUrl: string, username: string, siteConfig?: CoreSitePublicConfigResponse): Promise { if (siteConfig && siteConfig.forgottenpasswordurl) { // URL set, open it. CoreUtils.instance.openInApp(siteConfig.forgottenpasswordurl); @@ -183,7 +184,7 @@ export class CoreLoginHelperProvider { const canReset = await this.canRequestPasswordReset(siteUrl); if (canReset) { - await navCtrl.navigateForward(['/login/forgottenpassword'], { + await this.navCtrl.navigateForward(['/login/forgottenpassword'], { queryParams: { siteUrl, username, @@ -203,7 +204,7 @@ export class CoreLoginHelperProvider { * @param profileFields Profile fields to format. * @return Categories with the fields to show in each one. */ - formatProfileFieldsForSignup(profileFields: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] { + formatProfileFieldsForSignup(profileFields?: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] { if (!profileFields) { return []; } @@ -268,8 +269,8 @@ export class CoreLoginHelperProvider { maxlengthMsg?: string, minMsg?: string, maxMsg?: string, - ): any { - const errors: any = {}; + ): Record { + const errors: Record = {}; if (requiredMsg) { errors.required = errors.requiredTrue = Translate.instance.instant(requiredMsg); @@ -445,15 +446,42 @@ export class CoreLoginHelperProvider { /** * Open a page that doesn't belong to any site. * - * @param navCtrl Nav Controller. * @param page Page to open. * @param params Params of the page. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - goToNoSitePage(page?: string, params?: Params): Promise { - // @todo - return Promise.resolve(); + async goToNoSitePage(page: string, params?: Params): Promise { + const currentPage = CoreApp.instance.getCurrentPage(); + + if (currentPage == page) { + // Already at page, nothing to do. + } else if (page == '/login/sites') { + // Just open the page as root. + await this.navCtrl.navigateRoot(page, { queryParams: params }); + } else if (page == '/login/credentials' && currentPage == '/login/site') { + // Just open the new page to keep the navigation history. + await this.navCtrl.navigateForward(page, { queryParams: params }); + } else { + // Check if there is any site stored. + const hasSites = await CoreSites.instance.hasSites(); + + if (!hasSites) { + // There are sites stored, open sites page first to be able to go back. + await this.navCtrl.navigateRoot('/login/sites'); + + await this.navCtrl.navigateForward(page, { queryParams: params }); + } else { + if (page != '/login/site') { + // Open the new site page to be able to go back. + await this.navCtrl.navigateRoot('/login/site'); + + await this.navCtrl.navigateForward(page, { queryParams: params }); + } else { + // Just open the page as root. + await this.navCtrl.navigateRoot(page, { queryParams: params }); + } + } + } } /** @@ -617,15 +645,38 @@ export class CoreLoginHelperProvider { /** * Load a site and load a certain page in that site. * + * @param siteId Site to load. * @param page Name of the page to load. * @param params Params to pass to the page. - * @param siteId Site to load. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected loadSiteAndPage(page: string, params: Params, siteId: string): Promise { - // @todo - return Promise.resolve(); + protected async loadSiteAndPage(siteId: string, page: string, params?: Params): Promise { + if (siteId == CoreConstants.NO_SITE_ID) { + // Page doesn't belong to a site, just load the page. + await this.navCtrl.navigateRoot(page, params); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const loggedIn = await CoreSites.instance.loadSite(siteId, page, params); + + if (!loggedIn) { + return; + } + + await this.openMainMenu({ + redirectPage: page, + redirectParams: params, + }); + } catch (error) { + // Site doesn't exist. + await this.navCtrl.navigateRoot('/login/sites'); + } finally { + modal.dismiss(); + } } /** @@ -634,7 +685,7 @@ export class CoreLoginHelperProvider { * @param page Name of the page to load. * @param params Params to pass to the page. */ - loadPageInMainMenu(page: string, params: Params): void { + loadPageInMainMenu(page: string, params?: Params): void { if (!CoreApp.instance.isMainMenuOpen()) { // Main menu not open. Store the page to be loaded later. this.pageToLoad = { @@ -833,9 +884,20 @@ export class CoreLoginHelperProvider { * * @param siteId The site ID. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - passwordChangeForced(siteId: string): void { - // @todo + async passwordChangeForced(siteId: string): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + if (!currentSite || siteId !== currentSite.getId()) { + return; // Site that triggered the event is not current site. + } + + const currentPage = CoreApp.instance.getCurrentPage(); + + // If current page is already change password, stop. + if (currentPage == '/login/changepassword') { + return; + } + + await this.navCtrl.navigateRoot('/login/changepassword', { queryParams: { siteId } }); } /** @@ -892,9 +954,26 @@ export class CoreLoginHelperProvider { * @param siteId Site to load. If not defined, current site. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars async redirect(page: string, params?: Params, siteId?: string): Promise { - // @todo + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (CoreSites.instance.isLoggedIn()) { + if (siteId && siteId != CoreSites.instance.getCurrentSiteId()) { + // Target page belongs to a different site. Change site. + // @todo: Check site plugins. + await CoreSites.instance.logout(); + + await this.loadSiteAndPage(siteId, page, params); + } else { + this.loadPageInMainMenu(page, params); + } + } else { + if (siteId) { + await this.loadSiteAndPage(siteId, page, params); + } else { + await this.navCtrl.navigateRoot('/login/sites'); + } + } } /** @@ -1019,7 +1098,25 @@ export class CoreLoginHelperProvider { const info = currentSite.getInfo(); if (typeof info != 'undefined' && typeof info.username != 'undefined' && !this.isOpeningReconnect) { - // @todo + // If current page is already reconnect, stop. + if (CoreApp.instance.getCurrentPage() == '/login/reconnect') { + return; + } + + this.isOpeningReconnect = true; + + await CoreUtils.instance.ignoreErrors(this.navCtrl.navigateRoot('/login/reconnect', { + queryParams: { + infoSiteUrl: info.siteurl, + siteUrl: result.siteUrl, + siteId: siteId, + pageName: data.pageName, + pageParams: data.params, + siteConfig: result.config, + }, + })); + + this.isOpeningReconnect = false; } } } catch (error) { @@ -1172,7 +1269,12 @@ export class CoreLoginHelperProvider { return; } - // @todo Navigate to site policy page. + // If current page is already site policy, stop. + if (CoreApp.instance.getCurrentPage() == '/login/sitepolicy') { + return; + } + + this.navCtrl.navigateRoot('/login/sitepolicy', { queryParams: { siteId: siteId } }); } /** diff --git a/src/app/core/mainmenu/pages/more/more.html b/src/app/core/mainmenu/pages/more/more.html index 5edaa06e7..92ed80e86 100644 --- a/src/app/core/mainmenu/pages/more/more.html +++ b/src/app/core/mainmenu/pages/more/more.html @@ -13,7 +13,10 @@

    {{siteInfo.fullname}}

    -

    +

    + + +

    {{ siteUrl }}

    @@ -27,7 +30,8 @@

    {{ handler.title | translate}}

    - {{handler.badge}} + {{handler.badge}} +
    diff --git a/src/app/core/mainmenu/services/mainmenu.ts b/src/app/core/mainmenu/services/mainmenu.ts index 2385272d1..f54076df6 100644 --- a/src/app/core/mainmenu/services/mainmenu.ts +++ b/src/app/core/mainmenu/services/mainmenu.ts @@ -98,7 +98,7 @@ export class CoreMainMenuProvider { const id = url + '#' + type; if (!icon) { // Icon not defined, use default one. - icon = type == 'embedded' ? 'fa-square-o' : 'fa-link'; // @todo: Find a better icon for embedded. + icon = type == 'embedded' ? 'fa-expand' : 'fa-link'; // @todo: Find a better icon for embedded. } if (!map[id]) { diff --git a/src/app/core/settings/pages/about/about.page.ts b/src/app/core/settings/pages/about/about.page.ts index bc35c3244..73eedee61 100644 --- a/src/app/core/settings/pages/about/about.page.ts +++ b/src/app/core/settings/pages/about/about.page.ts @@ -16,7 +16,6 @@ import { CoreSites } from '@services/sites'; import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { CoreConstants } from '@core/constants'; -import { CoreApp } from '@services/app'; @Component({ selector: 'settings-about', diff --git a/src/app/lang/en.json b/src/app/lang/en.json index bd3da88ca..c6c46dc48 100644 --- a/src/app/lang/en.json +++ b/src/app/lang/en.json @@ -1,18 +1,325 @@ { + "accounts": "Accounts", + "add": "Add", + "agelocationverification": "Age and location verification", + "ago": "{{$a}} ago", + "all": "All", + "allgroups": "All groups", + "allparticipants": "All participants", + "answer": "Answer", + "answered": "Answered", + "areyousure": "Are you sure?", "back": "Back", "browser": "Browser", + "cancel": "Cancel", "cannotconnect": "Cannot connect", "cannotconnecttrouble": "We're having trouble connecting to your site.", "cannotconnectverify": "Please check the address is correct.", + "cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", + "cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?", + "cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?", + "captureaudio": "Record audio", + "capturedimage": "Taken picture.", + "captureimage": "Take picture", + "capturevideo": "Record video", + "category": "Category", + "choose": "Choose", + "choosedots": "Choose...", + "clearsearch": "Clear search", + "clearstoreddata": "Clear storage {{$a}}", + "clicktohideshow": "Click to expand or collapse", + "clicktoseefull": "Click to see full contents.", + "close": "Close", + "comments": "Comments", + "commentscount": "Comments ({{$a}})", + "completion-alt-auto-fail": "Completed: {{$a}} (did not achieve pass grade)", + "completion-alt-auto-n": "Not completed: {{$a}}", + "completion-alt-auto-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "completion-alt-auto-pass": "Completed: {{$a}} (achieved pass grade)", + "completion-alt-auto-y": "Completed: {{$a}}", + "completion-alt-auto-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "completion-alt-manual-n": "Not completed: {{$a}}. Select to mark as complete.", + "completion-alt-manual-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as complete.", + "completion-alt-manual-y": "Completed: {{$a}}. Select to mark as not complete.", + "completion-alt-manual-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as not complete.", + "confirmcanceledit": "Are you sure you want to leave this page? All changes will be lost.", + "confirmdeletefile": "Are you sure you want to delete this file?", + "confirmgotabroot": "Are you sure you want to go back to {{name}}?", + "confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", + "confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have unsaved changes they will be lost.", + "confirmloss": "Are you sure? All changes will be lost.", + "confirmopeninbrowser": "Do you want to open it in a web browser?", + "considereddigitalminor": "You are too young to create an account on this site.", + "content": "Content", + "contenteditingsynced": "The content you are editing has been synced.", + "continue": "Continue", "copiedtoclipboard": "Text copied to clipboard", + "copytoclipboard": "Copy to clipboard", + "course": "Course", + "coursedetails": "Course details", + "coursenogroups": "You are not a member of any group of this course.", + "currentdevice": "Current device", + "datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.", + "date": "Date", + "day": "day", + "days": "days", + "decsep": ".", + "defaultvalue": "Default ({{$a}})", + "delete": "Delete", + "deletedoffline": "Deleted offline", + "deleteduser": "Deleted user", + "deleting": "Deleting", + "description": "Description", + "desktop": "Desktop", + "dfdaymonthyear": "MM-DD-YYYY", + "dfdayweekmonth": "ddd, D MMM", + "dffulldate": "dddd, D MMMM YYYY h[:]mm A", + "dflastweekdate": "ddd", + "dfmediumdate": "LLL", + "dftimedate": "h[:]mm A", + "digitalminor": "Digital minor", + "digitalminor_desc": "Please ask your parent/guardian to contact:", + "discard": "Discard", + "dismiss": "Dismiss", + "displayoptions": "Display options", + "done": "Done", + "download": "Download", + "downloaded": "Downloaded", + "downloadfile": "Download file", + "downloading": "Downloading", + "edit": "Edit", + "emptysplit": "This page will appear blank if the left panel is empty or is loading.", + "error": "Error", + "errorchangecompletion": "An error occurred while changing the completion status. Please try again.", + "errordeletefile": "Error deleting the file. Please try again.", + "errordownloading": "Error downloading file.", + "errordownloadingsomefiles": "Error downloading files. Some files might be missing.", + "errorfileexistssamename": "A file with this name already exists.", + "errorinvalidform": "The form contains invalid data. Please check that all required fields are filled in and that the data is valid.", + "errorinvalidresponse": "Invalid response received. Please contact your site administrator if the error persists.", + "errorloadingcontent": "Error loading content.", + "errorofflinedisabled": "Offline browsing is disabled on your site. You need to be connected to the internet to use the app.", + "erroropenfilenoapp": "Error opening file: no app found to open this type of file.", + "erroropenfilenoextension": "Error opening file: the file doesn't have an extension.", + "erroropenpopup": "This activity is trying to open a popup. This is not supported in the app.", + "errorrenamefile": "Error renaming file. Please try again.", + "errorsomedatanotdownloaded": "If you downloaded this activity, please notice that some data isn't downloaded during the download process for performance and data usage reasons.", + "errorsync": "An error occurred while synchronising. Please try again.", + "errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.", + "errorurlschemeinvalidscheme": "This URL is meant to be used in another app: {{$a}}.", + "errorurlschemeinvalidsite": "This site URL cannot be opened in this app.", + "explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", + "favourites": "Starred", + "filename": "Filename", + "filenameexist": "File name already exists: {{$a}}", + "filenotfound": "File not found, sorry.", + "folder": "Folder", + "forcepasswordchangenotice": "You must change your password to proceed.", + "fulllistofcourses": "All courses", + "fullnameandsitename": "{{fullname}} ({{sitename}})", + "group": "Group", + "groupsseparate": "Separate groups", + "groupsvisible": "Visible groups", + "hasdatatosync": "This {{$a}} has offline data to be synchronised.", + "help": "Help", + "hide": "Hide", + "hour": "hour", + "hours": "hours", + "humanreadablesize": "{{size}} {{unit}}", + "image": "Image", + "imageviewer": "Image viewer", + "info": "Information", + "invalidformdata": "Incorrect form data", + "labelsep": ":", + "filter": "Filter", + "lastaccess": "Last access", + "lastdownloaded": "Last downloaded", + "lastmodified": "Last modified", + "lastsync": "Last synchronisation", + "layoutgrid": "Grid", + "list": "List", + "listsep": ",", "loading": "Loading", + "loadmore": "Load more", + "location": "Location", + "lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", + "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", + "min": "min", + "mins": "mins", + "misc": "Miscellaneous", + "mod_assign": "Assignment", + "mod_assignment": "Assignment 2.2 (Disabled)", + "mod_book": "Book", + "mod_chat": "Chat", + "mod_choice": "Choice", + "mod_data": "Database", + "mod_database": "Database", + "mod_external-tool": "External tool", + "mod_feedback": "Feedback", + "mod_file": "File", + "mod_folder": "Folder", + "mod_forum": "Forum", + "mod_glossary": "Glossary", + "mod_h5pactivity": "H5P", + "mod_ims": "IMS content package", + "mod_imscp": "IMS content package", + "mod_label": "Label", + "mod_lesson": "Lesson", + "mod_lti": "External tool", + "mod_page": "Page", + "mod_quiz": "Quiz", + "mod_resource": "File", + "mod_scorm": "SCORM package", + "mod_survey": "Survey", + "mod_url": "URL", + "mod_wiki": "Wiki", + "mod_workshop": "Workshop", + "moduleintro": "Description", + "more": "more", + "mygroups": "My groups", + "name": "Name", "needhelp": "Need help?", + "networkerroriframemsg": "This content is not available offline. Please connect to the internet and try again.", "networkerrormsg": "There was a problem connecting to the site. Please check your connection and try again.", + "never": "Never", + "next": "Next", "no": "No", + "nocomments": "No comments", + "nograde": "No grade", + "none": "None", + "nopasswordchangeforced": "You cannot proceed without changing your password.", + "nopermissionerror": "Sorry, but you do not currently have permissions to do that", + "nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", + "noresults": "No results", + "noselection": "No selection", + "notapplicable": "n/a", + "notavailable": "Not available", + "notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", + "notice": "Notice", + "nooptionavailable": "No option available", + "notingroup": "Sorry, but you need to be part of a group to see this page.", + "notsent": "Not sent", + "now": "now", + "nummore": "{{$a}} more", + "numwords": "{{$a}} words", "offline": "Offline", "ok": "OK", "online": "Online", + "openfile": "Open file", + "openfullimage": "Click here to display the full size image", + "openinbrowser": "Open in browser", + "openmodinbrowser": "Open {{$a}} in browser", + "othergroups": "Other groups", + "pagea": "Page {{$a}}", + "parentlanguage": "", + "paymentinstant": "Use the button below to pay and be enrolled within minutes!", + "percentagenumber": "{{$a}}%", + "phone": "Phone", + "pictureof": "Picture of {{$a}}", + "previous": "Previous", + "proceed": "Proceed", + "pulltorefresh": "Pull to refresh", + "qrscanner": "QR scanner", + "quotausage": "You have currently used {{$a.used}} of your {{$a.total}} limit.", + "redirectingtosite": "You will be redirected to the site.", + "refresh": "Refresh", + "remove": "Remove", + "removefiles": "Remove files {{$a}}", + "required": "Required", + "requireduserdatamissing": "This user lacks some required profile data. Please enter the data in your site and try again.
    {{$a}}", + "resourcedisplayopen": "Open", + "resources": "Resources", + "restore": "Restore", + "restricted": "Restricted", + "retry": "Retry", + "save": "Save", + "savechanges": "Save changes", + "scanqr": "Scan QR code", + "search": "Search", + "searching": "Searching", + "searchresults": "Search results", + "sec": "sec", + "secs": "secs", + "seemoredetail": "Click here to see more detail", + "selectacategory": "Please select a category", + "selectacourse": "Select a course", + "selectagroup": "Select a group", + "send": "Send", + "sending": "Sending", + "serverconnection": "Error connecting to the server", + "show": "Show", + "showless": "Show less...", + "showmore": "Show more...", + "site": "Site", + "sitemaintenance": "The site is undergoing maintenance and is currently not available", + "sizeb": "bytes", + "sizegb": "GB", + "sizekb": "KB", + "sizemb": "MB", + "sizetb": "TB", + "skip": "Skip", + "sorry": "Sorry...", + "sort": "Sort", + "sortby": "Sort by", + "start": "Start", + "storingfiles": "Storing files", + "strftimedate": "%d %B %Y", + "strftimedatefullshort": "%d/%m/%y", + "strftimedateshort": "%d %B", + "strftimedatetime": "%d %B %Y, %I:%M %p", + "strftimedatetimeshort": "%d/%m/%y, %H:%M", + "strftimedaydate": "%A, %d %B %Y", + "strftimedaydatetime": "%A, %d %B %Y, %I:%M %p", + "strftimedayshort": "%A, %d %B", + "strftimedaytime": "%a, %H:%M", + "strftimemonthyear": "%B %Y", + "strftimerecent": "%d %b, %H:%M", + "strftimerecentfull": "%a, %d %b %Y, %I:%M %p", + "strftimetime": "%I:%M %p", + "strftimetime12": "%I:%M %p", + "strftimetime24": "%H:%M", + "submit": "Submit", + "success": "Success", + "tablet": "Tablet", + "teachers": "Teachers", + "thereisdatatosync": "There are offline {{$a}} to be synchronised.", + "thisdirection": "ltr", + "time": "Time", + "timesup": "Time is up!", + "today": "Today", "tryagain": "Try again", + "twoparagraphs": "{{p1}}

    {{p2}}", + "uhoh": "Uh oh!", + "unexpectederror": "Unexpected error. Please close and reopen the application then try again.", + "unicodenotsupported": "Some emojis are not supported on this site. Such characters will be removed when the message is sent.", + "unicodenotsupportedcleanerror": "Empty text was found when cleaning Unicode chars.", "unknown": "Unknown", - "yes": "Yes" + "unlimited": "Unlimited", + "unzipping": "Unzipping", + "upgraderunning": "Site is being upgraded, please retry later.", + "updaterequired": "App update required", + "updaterequireddesc": "Please update your app to version {{$a}}", + "user": "User", + "userdeleted": "This user account has been deleted", + "userdetails": "User details", + "usernotfullysetup": "User not fully set-up", + "users": "Users", + "view": "View", + "viewcode": "View code", + "vieweditor": "View editor", + "viewembeddedcontent": "View embedded content", + "viewprofile": "View profile", + "warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}", + "whatisyourage": "What is your age?", + "wheredoyoulive": "In which country do you live?", + "whoissiteadmin": "\"Site Administrators\" are the people who manage the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.", + "whoops": "Oops!", + "whyisthishappening": "Why is this happening?", + "whyisthisrequired": "Why is this required?", + "wsfunctionnotavailable": "The web service function is not available.", + "year": "year", + "years": "years", + "yes": "Yes", + "youreoffline": "You are offline", + "youreonline": "You are back online" } diff --git a/src/app/services/app.db.ts b/src/app/services/app.db.ts new file mode 100644 index 000000000..6621bafc6 --- /dev/null +++ b/src/app/services/app.db.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SQLiteDBTableSchema } from '@classes/sqlitedb'; + +/** + * Database variables for CoreApp service. + */ +export const DBNAME = 'MoodleMobile'; +export const SCHEMA_VERSIONS_TABLE_NAME = 'schema_versions'; + +export const SCHEMA_VERSIONS_TABLE_SCHEMA: SQLiteDBTableSchema = { + name: SCHEMA_VERSIONS_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'version', + type: 'INTEGER', + }, + ], +}; + +export type SchemaVersionsDBEntry = { + name: string; + version: number; +}; diff --git a/src/app/services/app.ts b/src/app/services/app.ts index a22293047..489cb067f 100644 --- a/src/app/services/app.ts +++ b/src/app/services/app.ts @@ -13,20 +13,19 @@ // limitations under the License. import { Injectable, NgZone, ApplicationRef } from '@angular/core'; -import { Params } from '@angular/router'; +import { Params, Router } from '@angular/router'; import { Connection } from '@ionic-native/network/ngx'; import { CoreDB } from '@services/db'; import { CoreEvents } from '@singletons/events'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; +import { CoreUrlUtils } from '@services/utils/url'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { CoreConstants } from '@core/constants'; import { makeSingleton, Keyboard, Network, StatusBar, Platform } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; - -const DBNAME = 'MoodleMobile'; -const SCHEMA_VERSIONS_TABLE = 'schema_versions'; +import { DBNAME, SCHEMA_VERSIONS_TABLE_NAME, SCHEMA_VERSIONS_TABLE_SCHEMA, SchemaVersionsDBEntry } from '@services/app.db'; /** * Factory to provide some global functionalities, like access to the global app database. @@ -57,27 +56,17 @@ export class CoreAppProvider { // Variables for DB. protected createVersionsTableReady: Promise; - protected versionsTableSchema: SQLiteDBTableSchema = { - name: SCHEMA_VERSIONS_TABLE, - columns: [ - { - name: 'name', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'version', - type: 'INTEGER', - }, - ], - }; - constructor(appRef: ApplicationRef, zone: NgZone) { + constructor( + appRef: ApplicationRef, + zone: NgZone, + protected router: Router, + ) { this.logger = CoreLogger.getInstance('CoreAppProvider'); this.db = CoreDB.instance.getDB(DBNAME); // Create the schema versions table. - this.createVersionsTableReady = this.db.createTableFromSchema(this.versionsTableSchema); + this.createVersionsTableReady = this.db.createTableFromSchema(SCHEMA_VERSIONS_TABLE_SCHEMA); Keyboard.instance.onKeyboardShow().subscribe((data) => { // Execute the callback in the Angular zone, so change detection doesn't stop working. @@ -175,7 +164,7 @@ export class CoreAppProvider { await this.createVersionsTableReady; // Fetch installed version of the schema. - const entry = await this.db.getRecord(SCHEMA_VERSIONS_TABLE, { name: schema.name }); + const entry = await this.db.getRecord(SCHEMA_VERSIONS_TABLE_NAME, { name: schema.name }); oldVersion = entry.version; } catch (error) { @@ -198,7 +187,16 @@ export class CoreAppProvider { } // Set installed version. - await this.db.insertRecord(SCHEMA_VERSIONS_TABLE, { name: schema.name, version: schema.version }); + await this.db.insertRecord(SCHEMA_VERSIONS_TABLE_NAME, { name: schema.name, version: schema.version }); + } + + /** + * Get current page route without params. + * + * @return Current page route. + */ + getCurrentPage(): string { + return CoreUrlUtils.instance.removeUrlParams(this.router.url); } /** @@ -741,8 +739,3 @@ export type WindowForAutomatedTests = Window & { appProvider?: CoreAppProvider; appRef?: ApplicationRef; }; - -type SchemaVersionsDBEntry = { - name: string; - version: number; -}; diff --git a/src/app/services/config.db.ts b/src/app/services/config.db.ts new file mode 100644 index 000000000..b441dc78e --- /dev/null +++ b/src/app/services/config.db.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreAppSchema } from '@services/app'; + +/** + * Database variables for for CoreConfig service. + */ +export const CONFIG_TABLE_NAME = 'core_config'; + +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreConfigProvider', + version: 1, + tables: [ + { + name: CONFIG_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true, + }, + { + name: 'value', + }, + ], + }, + ], +}; + +export type ConfigDBEntry = { + name: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +}; diff --git a/src/app/services/config.ts b/src/app/services/config.ts index 5fb874158..9cdf15782 100644 --- a/src/app/services/config.ts +++ b/src/app/services/config.ts @@ -14,11 +14,10 @@ import { Injectable } from '@angular/core'; -import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreApp } from '@services/app'; import { SQLiteDB } from '@classes/sqlitedb'; import { makeSingleton } from '@singletons/core.singletons'; - -const TABLE_NAME = 'core_config'; +import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/config.db'; /** * Factory to provide access to dynamic and permanent config and settings. @@ -28,32 +27,11 @@ const TABLE_NAME = 'core_config'; export class CoreConfigProvider { protected appDB: SQLiteDB; - protected tableSchema: CoreAppSchema = { - name: 'CoreConfigProvider', - version: 1, - tables: [ - { - name: TABLE_NAME, - columns: [ - { - name: 'name', - type: 'TEXT', - unique: true, - notNull: true, - }, - { - name: 'value', - }, - ], - }, - ], - }; - protected dbReady: Promise; // Promise resolved when the app DB is initialized. constructor() { this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); } @@ -67,7 +45,7 @@ export class CoreConfigProvider { async delete(name: string): Promise { await this.dbReady; - await this.appDB.deleteRecords(TABLE_NAME, { name }); + await this.appDB.deleteRecords(CONFIG_TABLE_NAME, { name }); } /** @@ -81,7 +59,7 @@ export class CoreConfigProvider { await this.dbReady; try { - const entry = await this.appDB.getRecord(TABLE_NAME, { name }); + const entry = await this.appDB.getRecord(CONFIG_TABLE_NAME, { name }); return entry.value; } catch (error) { @@ -103,15 +81,9 @@ export class CoreConfigProvider { async set(name: string, value: number | string): Promise { await this.dbReady; - await this.appDB.insertRecord(TABLE_NAME, { name, value }); + await this.appDB.insertRecord(CONFIG_TABLE_NAME, { name, value }); } } export class CoreConfig extends makeSingleton(CoreConfigProvider) {} - -type ConfigDBEntry = { - name: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any; -}; diff --git a/src/app/services/cron.db.ts b/src/app/services/cron.db.ts new file mode 100644 index 000000000..d96d2aba1 --- /dev/null +++ b/src/app/services/cron.db.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreAppSchema } from '@services/app'; + +/** + * Database variables for CoreCron service. + */ +export const CRON_TABLE_NAME = 'cron'; +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreCronDelegate', + version: 1, + tables: [ + { + name: CRON_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'value', + type: 'INTEGER', + }, + ], + }, + ], +}; + +export type CronDBEntry = { + id: string; + value: number; +}; diff --git a/src/app/services/cron.ts b/src/app/services/cron.ts index fd13576ab..f542bfcbc 100644 --- a/src/app/services/cron.ts +++ b/src/app/services/cron.ts @@ -14,7 +14,7 @@ import { Injectable, NgZone } from '@angular/core'; -import { CoreApp, CoreAppProvider, CoreAppSchema } from '@services/app'; +import { CoreApp, CoreAppProvider } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreUtils } from '@services/utils/utils'; import { CoreConstants } from '@core/constants'; @@ -23,8 +23,7 @@ import { CoreError } from '@classes/errors/error'; import { makeSingleton, Network } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; - -const CRON_TABLE = 'cron'; +import { APP_SCHEMA, CRON_TABLE_NAME, CronDBEntry } from '@services/cron.db'; /* * Service to handle cron processes. The registered processes will be executed every certain time. @@ -37,28 +36,6 @@ export class CoreCronDelegate { static readonly MIN_INTERVAL = 300000; // Minimum interval is 5 minutes. static readonly MAX_TIME_PROCESS = 120000; // Max time a process can block the queue. Defaults to 2 minutes. - // Variables for database. - protected tableSchema: CoreAppSchema = { - name: 'CoreCronDelegate', - version: 1, - tables: [ - { - name: CRON_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'value', - type: 'INTEGER', - }, - ], - }, - ], - }; - protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. @@ -69,7 +46,7 @@ export class CoreCronDelegate { this.logger = CoreLogger.getInstance('CoreCronDelegate'); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); @@ -268,7 +245,7 @@ export class CoreCronDelegate { const id = this.getHandlerLastExecutionId(name); try { - const entry = await this.appDB.getRecord(CRON_TABLE, { id }); + const entry = await this.appDB.getRecord(CRON_TABLE_NAME, { id }); const time = Number(entry.value); @@ -431,7 +408,7 @@ export class CoreCronDelegate { value: time, }; - await this.appDB.insertRecord(CRON_TABLE, entry); + await this.appDB.insertRecord(CRON_TABLE_NAME, entry); } /** @@ -562,8 +539,3 @@ export interface CoreCronHandler { export type WindowForAutomatedTests = Window & { cronProvider?: CoreCronDelegate; }; - -type CronDBEntry = { - id: string; - value: number; -}; diff --git a/src/app/services/filepool.db.ts b/src/app/services/filepool.db.ts new file mode 100644 index 000000000..42c8a15b0 --- /dev/null +++ b/src/app/services/filepool.db.ts @@ -0,0 +1,367 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreAppSchema } from '@services/app'; +import { CoreSiteSchema, registerSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreFilepool service. + */ +export const QUEUE_TABLE_NAME = 'filepool_files_queue'; // Queue of files to download. +export const FILES_TABLE_NAME = 'filepool_files'; // Downloaded files. +export const LINKS_TABLE_NAME = 'filepool_files_links'; // Links between downloaded files and components. +export const PACKAGES_TABLE_NAME = 'filepool_packages'; // Downloaded packages (sets of files). +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreFilepoolProvider', + version: 1, + tables: [ + { + name: QUEUE_TABLE_NAME, + columns: [ + { + name: 'siteId', + type: 'TEXT', + }, + { + name: 'fileId', + type: 'TEXT', + }, + { + name: 'added', + type: 'INTEGER', + }, + { + name: 'priority', + type: 'INTEGER', + }, + { + name: 'url', + type: 'TEXT', + }, + { + name: 'revision', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'isexternalfile', + type: 'INTEGER', + }, + { + name: 'repositorytype', + type: 'TEXT', + }, + { + name: 'path', + type: 'TEXT', + }, + { + name: 'links', + type: 'TEXT', + }, + ], + primaryKeys: ['siteId', 'fileId'], + }, + ], +}; + +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreFilepoolProvider', + version: 1, + tables: [ + { + name: FILES_TABLE_NAME, + columns: [ + { + name: 'fileId', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'url', + type: 'TEXT', + notNull: true, + }, + { + name: 'revision', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'stale', + type: 'INTEGER', + }, + { + name: 'downloadTime', + type: 'INTEGER', + }, + { + name: 'isexternalfile', + type: 'INTEGER', + }, + { + name: 'repositorytype', + type: 'TEXT', + }, + { + name: 'path', + type: 'TEXT', + }, + { + name: 'extension', + type: 'TEXT', + }, + ], + }, + { + name: LINKS_TABLE_NAME, + columns: [ + { + name: 'fileId', + type: 'TEXT', + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentId', + type: 'TEXT', + }, + ], + primaryKeys: ['fileId', 'component', 'componentId'], + }, + { + name: PACKAGES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentId', + type: 'TEXT', + }, + { + name: 'status', + type: 'TEXT', + }, + { + name: 'previous', + type: 'TEXT', + }, + { + name: 'updated', + type: 'INTEGER', + }, + { + name: 'downloadTime', + type: 'INTEGER', + }, + { + name: 'previousDownloadTime', + type: 'INTEGER', + }, + { + name: 'extra', + type: 'TEXT', + }, + ], + }, + ], +}; + +/** + * File options. + */ +export type CoreFilepoolFileOptions = { + revision?: number; // File's revision. + timemodified?: number; // File's timemodified. + isexternalfile?: number; // 1 if it's a external file (from an external repository), 0 otherwise. + repositorytype?: string; // Type of the repository this file belongs to. +}; + +/** + * Entry from filepool. + */ +export type CoreFilepoolFileEntry = CoreFilepoolFileOptions & { + /** + * The fileId to identify the file. + */ + fileId: string; + + /** + * File's URL. + */ + url: string; + + /** + * 1 if file is stale (needs to be updated), 0 otherwise. + */ + stale: number; + + /** + * Timestamp when this file was downloaded. + */ + downloadTime: number; + + /** + * File's path. + */ + path: string; + + /** + * File's extension. + */ + extension: string; +}; + +/** + * DB data for entry from file's queue. + */ +export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & { + /** + * The site the file belongs to. + */ + siteId: string; + + /** + * The fileId to identify the file. + */ + fileId: string; + + /** + * Timestamp when the file was added to the queue. + */ + added: number; + + /** + * The priority of the file. + */ + priority: number; + + /** + * File's URL. + */ + url: string; + + /** + * File's path. + */ + path?: string; + + /** + * File links (to link the file to components and componentIds). Serialized to store on DB. + */ + links: string; +}; + +/** + * Entry from the file's queue. + */ +export type CoreFilepoolQueueEntry = CoreFilepoolQueueDBEntry & { + /** + * File links (to link the file to components and componentIds). + */ + linksUnserialized?: CoreFilepoolComponentLink[]; +}; + +/** + * Entry from packages table. + */ +export type CoreFilepoolPackageEntry = { + /** + * Package id. + */ + id?: string; + + /** + * The component to link the files to. + */ + component?: string; + + /** + * An ID to use in conjunction with the component. + */ + componentId?: string | number; + + /** + * Package status. + */ + status?: string; + + /** + * Package previous status. + */ + previous?: string; + + /** + * Timestamp when this package was updated. + */ + updated?: number; + + /** + * Timestamp when this package was downloaded. + */ + downloadTime?: number; + + /** + * Previous download time. + */ + previousDownloadTime?: number; + + /** + * Extra data stored by the package. + */ + extra?: string; +}; + +/** + * A component link. + */ +export type CoreFilepoolComponentLink = { + /** + * Link's component. + */ + component: string; + + /** + * Link's componentId. + */ + componentId?: string | number; +}; + +/** + * Links table record type. + */ +export type CoreFilepoolLinksRecord = { + fileId: string; // File Id. + component: string; // Component name. + componentId: number | string; // Component Id. +}; + +export const initCoreFilepoolDB = (): void => { + registerSiteSchema(SITE_SCHEMA); +}; diff --git a/src/app/services/filepool.ts b/src/app/services/filepool.ts index 6baddc81c..b9bdbed64 100644 --- a/src/app/services/filepool.ts +++ b/src/app/services/filepool.ts @@ -15,12 +15,12 @@ import { Injectable } from '@angular/core'; import { Md5 } from 'ts-md5/dist/md5'; -import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreApp } from '@services/app'; import { CoreEvents } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreInit } from '@services/init'; import { CorePluginFile } from '@services/plugin-file-delegate'; -import { CoreSites, CoreSiteSchema } from '@services/sites'; +import { CoreSites } from '@services/sites'; import { CoreWS, CoreWSExternalFile } from '@services/ws'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; @@ -33,6 +33,20 @@ import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; import { makeSingleton, Network, NgZone, Translate } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; +import { + APP_SCHEMA, + FILES_TABLE_NAME, + QUEUE_TABLE_NAME, + PACKAGES_TABLE_NAME, + LINKS_TABLE_NAME, + CoreFilepoolFileEntry, + CoreFilepoolComponentLink, + CoreFilepoolFileOptions, + CoreFilepoolLinksRecord, + CoreFilepoolPackageEntry, + CoreFilepoolQueueEntry, + CoreFilepoolQueueDBEntry, +} from '@services/filepool.db'; /* * Factory for handling downloading files and retrieve downloaded files. @@ -60,182 +74,6 @@ export class CoreFilepoolProvider { protected static readonly FILE_UPDATE_UNKNOWN_WHERE_CLAUSE = 'isexternalfile = 1 OR ((revision IS NULL OR revision = 0) AND (timemodified IS NULL OR timemodified = 0))'; - // Variables for database. - protected static readonly QUEUE_TABLE = 'filepool_files_queue'; // Queue of files to download. - protected static readonly FILES_TABLE = 'filepool_files'; // Downloaded files. - protected static readonly LINKS_TABLE = 'filepool_files_links'; // Links between downloaded files and components. - protected static readonly PACKAGES_TABLE = 'filepool_packages'; // Downloaded packages (sets of files). - protected appTablesSchema: CoreAppSchema = { - name: 'CoreFilepoolProvider', - version: 1, - tables: [ - { - name: CoreFilepoolProvider.QUEUE_TABLE, - columns: [ - { - name: 'siteId', - type: 'TEXT', - }, - { - name: 'fileId', - type: 'TEXT', - }, - { - name: 'added', - type: 'INTEGER', - }, - { - name: 'priority', - type: 'INTEGER', - }, - { - name: 'url', - type: 'TEXT', - }, - { - name: 'revision', - type: 'INTEGER', - }, - { - name: 'timemodified', - type: 'INTEGER', - }, - { - name: 'isexternalfile', - type: 'INTEGER', - }, - { - name: 'repositorytype', - type: 'TEXT', - }, - { - name: 'path', - type: 'TEXT', - }, - { - name: 'links', - type: 'TEXT', - }, - ], - primaryKeys: ['siteId', 'fileId'], - }, - ], - }; - - protected siteSchema: CoreSiteSchema = { - name: 'CoreFilepoolProvider', - version: 1, - tables: [ - { - name: CoreFilepoolProvider.FILES_TABLE, - columns: [ - { - name: 'fileId', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'url', - type: 'TEXT', - notNull: true, - }, - { - name: 'revision', - type: 'INTEGER', - }, - { - name: 'timemodified', - type: 'INTEGER', - }, - { - name: 'stale', - type: 'INTEGER', - }, - { - name: 'downloadTime', - type: 'INTEGER', - }, - { - name: 'isexternalfile', - type: 'INTEGER', - }, - { - name: 'repositorytype', - type: 'TEXT', - }, - { - name: 'path', - type: 'TEXT', - }, - { - name: 'extension', - type: 'TEXT', - }, - ], - }, - { - name: CoreFilepoolProvider.LINKS_TABLE, - columns: [ - { - name: 'fileId', - type: 'TEXT', - }, - { - name: 'component', - type: 'TEXT', - }, - { - name: 'componentId', - type: 'TEXT', - }, - ], - primaryKeys: ['fileId', 'component', 'componentId'], - }, - { - name: CoreFilepoolProvider.PACKAGES_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'component', - type: 'TEXT', - }, - { - name: 'componentId', - type: 'TEXT', - }, - { - name: 'status', - type: 'TEXT', - }, - { - name: 'previous', - type: 'TEXT', - }, - { - name: 'updated', - type: 'INTEGER', - }, - { - name: 'downloadTime', - type: 'INTEGER', - }, - { - name: 'previousDownloadTime', - type: 'INTEGER', - }, - { - name: 'extra', - type: 'TEXT', - }, - ], - }, - ], - }; - protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. @@ -258,12 +96,10 @@ export class CoreFilepoolProvider { this.logger = CoreLogger.getInstance('CoreFilepoolProvider'); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); - CoreSites.instance.registerSiteSchema(this.siteSchema); - this.init(); } @@ -308,7 +144,7 @@ export class CoreFilepoolProvider { componentId: componentId || '', }; - await db.insertRecord(CoreFilepoolProvider.LINKS_TABLE, newEntry); + await db.insertRecord(LINKS_TABLE_NAME, newEntry); } /** @@ -373,7 +209,7 @@ export class CoreFilepoolProvider { const db = await CoreSites.instance.getSiteDb(siteId); - await db.insertRecord(CoreFilepoolProvider.FILES_TABLE, record); + await db.insertRecord(FILES_TABLE_NAME, record); } /** @@ -433,7 +269,7 @@ export class CoreFilepoolProvider { this.logger.debug(`Adding ${fileId} to the queue`); - await this.appDB.insertRecord(CoreFilepoolProvider.QUEUE_TABLE, { + await this.appDB.insertRecord(QUEUE_TABLE_NAME, { siteId, fileId, url, @@ -563,7 +399,7 @@ export class CoreFilepoolProvider { // Update only when required. this.logger.debug(`Updating file ${fileId} which is already in queue`); - return this.appDB.updateRecords(CoreFilepoolProvider.QUEUE_TABLE, newData, primaryKey).then(() => + return this.appDB.updateRecords(QUEUE_TABLE_NAME, newData, primaryKey).then(() => this.getQueuePromise(siteId, fileId, true, onProgress)); } @@ -692,9 +528,9 @@ export class CoreFilepoolProvider { const site = await CoreSites.instance.getSite(siteId); // Get all the packages to be able to "notify" the change in the status. - const entries: CoreFilepoolPackageEntry[] = await site.getDb().getAllRecords(CoreFilepoolProvider.PACKAGES_TABLE); + const entries: CoreFilepoolPackageEntry[] = await site.getDb().getAllRecords(PACKAGES_TABLE_NAME); // Delete all the entries. - await site.getDb().deleteRecords(CoreFilepoolProvider.PACKAGES_TABLE); + await site.getDb().deleteRecords(PACKAGES_TABLE_NAME); entries.forEach((entry) => { // Trigger module status changed, setting it as not downloaded. @@ -712,8 +548,8 @@ export class CoreFilepoolProvider { const db = await CoreSites.instance.getSiteDb(siteId); await Promise.all([ - db.deleteRecords(CoreFilepoolProvider.FILES_TABLE), - db.deleteRecords(CoreFilepoolProvider.LINKS_TABLE), + db.deleteRecords(FILES_TABLE_NAME), + db.deleteRecords(LINKS_TABLE_NAME), ]); } @@ -732,7 +568,7 @@ export class CoreFilepoolProvider { componentId: this.fixComponentId(componentId), }; - const count = await db.countRecords(CoreFilepoolProvider.LINKS_TABLE, conditions); + const count = await db.countRecords(LINKS_TABLE_NAME, conditions); if (count <= 0) { throw new CoreError('Component doesn\'t have files'); } @@ -1257,7 +1093,7 @@ export class CoreFilepoolProvider { // Minor problem: file will remain in the filesystem once downloaded again. this.logger.debug('Staled file with no extension ' + entry.fileId); - await db.updateRecords(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, { fileId: entry.fileId }); + await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId: entry.fileId }); return; } @@ -1267,7 +1103,7 @@ export class CoreFilepoolProvider { entry.fileId = CoreMimetypeUtils.instance.removeExtension(fileId); entry.extension = extension; - await db.updateRecords(CoreFilepoolProvider.FILES_TABLE, entry, { fileId }); + await db.updateRecords(FILES_TABLE_NAME, entry, { fileId }); if (entry.fileId == fileId) { // File ID hasn't changed, we're done. this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); @@ -1276,7 +1112,7 @@ export class CoreFilepoolProvider { } // Now update the links. - await db.updateRecords(CoreFilepoolProvider.LINKS_TABLE, { fileId: entry.fileId }, { fileId }); + await db.updateRecords(LINKS_TABLE_NAME, { fileId: entry.fileId }, { fileId }); } /** @@ -1339,7 +1175,7 @@ export class CoreFilepoolProvider { componentId: this.fixComponentId(componentId), }; - const items = await db.getRecords(CoreFilepoolProvider.LINKS_TABLE, conditions); + const items = await db.getRecords(LINKS_TABLE_NAME, conditions); items.forEach((item) => { item.componentId = this.fixComponentId(item.componentId); }); @@ -1449,7 +1285,7 @@ export class CoreFilepoolProvider { */ protected async getFileLinks(siteId: string, fileId: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); - const items = await db.getRecords(CoreFilepoolProvider.LINKS_TABLE, { fileId }); + const items = await db.getRecords(LINKS_TABLE_NAME, { fileId }); items.forEach((item) => { item.componentId = this.fixComponentId(item.componentId); @@ -1527,7 +1363,7 @@ export class CoreFilepoolProvider { await Promise.all(items.map(async (item) => { try { const fileEntry = await db.getRecord( - CoreFilepoolProvider.FILES_TABLE, + FILES_TABLE_NAME, { fileId: item.fileId }, ); @@ -1808,7 +1644,7 @@ export class CoreFilepoolProvider { const site = await CoreSites.instance.getSite(siteId); const packageId = this.getPackageId(component, componentId); - return site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); + return site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); } /** @@ -2258,7 +2094,7 @@ export class CoreFilepoolProvider { */ protected async hasFileInPool(siteId: string, fileId: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); - const entry = await db.getRecord(CoreFilepoolProvider.FILES_TABLE, { fileId }); + const entry = await db.getRecord(FILES_TABLE_NAME, { fileId }); if (typeof entry === 'undefined') { throw new CoreError('File not found in filepool.'); @@ -2277,7 +2113,7 @@ export class CoreFilepoolProvider { protected async hasFileInQueue(siteId: string, fileId: string): Promise { await this.dbReady; - const entry = await this.appDB.getRecord(CoreFilepoolProvider.QUEUE_TABLE, { siteId, fileId }); + const entry = await this.appDB.getRecord(QUEUE_TABLE_NAME, { siteId, fileId }); if (typeof entry === 'undefined') { throw new CoreError('File not found in queue.'); @@ -2301,7 +2137,7 @@ export class CoreFilepoolProvider { const where = onlyUnknown ? CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : undefined; - await db.updateRecordsWhere(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, where); + await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, where); } /** @@ -2322,7 +2158,7 @@ export class CoreFilepoolProvider { const db = await CoreSites.instance.getSiteDb(siteId); - await db.updateRecords(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, { fileId }); + await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId }); } /** @@ -2359,7 +2195,7 @@ export class CoreFilepoolProvider { whereAndParams[0] += ' AND (' + CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE + ')'; } - await db.updateRecordsWhere(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, whereAndParams[0], whereAndParams[1]); + await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, whereAndParams[0], whereAndParams[1]); } /** @@ -2615,7 +2451,7 @@ export class CoreFilepoolProvider { try { items = await this.appDB.getRecords( - CoreFilepoolProvider.QUEUE_TABLE, + QUEUE_TABLE_NAME, undefined, 'priority DESC, added ASC', undefined, @@ -2760,7 +2596,7 @@ export class CoreFilepoolProvider { protected async removeFromQueue(siteId: string, fileId: string): Promise { await this.dbReady; - await this.appDB.deleteRecords(CoreFilepoolProvider.QUEUE_TABLE, { siteId, fileId }); + await this.appDB.deleteRecords(QUEUE_TABLE_NAME, { siteId, fileId }); } /** @@ -2797,10 +2633,10 @@ export class CoreFilepoolProvider { const promises: Promise[] = []; // Remove entry from filepool store. - promises.push(db.deleteRecords(CoreFilepoolProvider.FILES_TABLE, conditions)); + promises.push(db.deleteRecords(FILES_TABLE_NAME, conditions)); // Remove links. - promises.push(db.deleteRecords(CoreFilepoolProvider.LINKS_TABLE, conditions)); + promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions)); // Remove the file. if (CoreFile.instance.isAvailable()) { @@ -2885,7 +2721,7 @@ export class CoreFilepoolProvider { const packageId = this.getPackageId(component, componentId); // Get current stored data, we'll only update 'status' and 'updated' fields. - const entry = site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); + const entry = site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); const newData: CoreFilepoolPackageEntry = {}; if (entry.status == CoreConstants.DOWNLOADING) { // Going back from downloading to previous status, restore previous download time. @@ -2895,7 +2731,7 @@ export class CoreFilepoolProvider { newData.updated = Date.now(); this.logger.debug(`Set previous status '${entry.status}' for package ${component} ${componentId}`); - await site.getDb().updateRecords(CoreFilepoolProvider.PACKAGES_TABLE, newData, { id: packageId }); + await site.getDb().updateRecords(PACKAGES_TABLE_NAME, newData, { id: packageId }); // Success updating, trigger event. this.triggerPackageStatusChanged(site.id!, newData.status, component, componentId); @@ -2973,7 +2809,7 @@ export class CoreFilepoolProvider { let previousStatus: string | undefined; // Search current status to set it as previous status. try { - const entry = site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); + const entry = site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); if (typeof extra == 'undefined' || extra === null) { extra = entry.extra; } @@ -3008,7 +2844,7 @@ export class CoreFilepoolProvider { return; } - await site.getDb().insertRecord(CoreFilepoolProvider.PACKAGES_TABLE, packageEntry); + await site.getDb().insertRecord(PACKAGES_TABLE_NAME, packageEntry); // Success inserting, trigger event. this.triggerPackageStatusChanged(siteId, status, component, componentId); @@ -3132,7 +2968,7 @@ export class CoreFilepoolProvider { const packageId = this.getPackageId(component, componentId); await site.getDb().updateRecords( - CoreFilepoolProvider.PACKAGES_TABLE, + PACKAGES_TABLE_NAME, { downloadTime: CoreTimeUtils.instance.timestamp() }, { id: packageId }, ); @@ -3142,166 +2978,6 @@ export class CoreFilepoolProvider { export class CoreFilepool extends makeSingleton(CoreFilepoolProvider) {} -/** - * File options. - */ -type CoreFilepoolFileOptions = { - revision?: number; // File's revision. - timemodified?: number; // File's timemodified. - isexternalfile?: number; // 1 if it's a external file (from an external repository), 0 otherwise. - repositorytype?: string; // Type of the repository this file belongs to. -}; - -/** - * Entry from filepool. - */ -export type CoreFilepoolFileEntry = CoreFilepoolFileOptions & { - /** - * The fileId to identify the file. - */ - fileId: string; - - /** - * File's URL. - */ - url: string; - - /** - * 1 if file is stale (needs to be updated), 0 otherwise. - */ - stale: number; - - /** - * Timestamp when this file was downloaded. - */ - downloadTime: number; - - /** - * File's path. - */ - path: string; - - /** - * File's extension. - */ - extension: string; -}; - -/** - * DB data for entry from file's queue. - */ -export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & { - /** - * The site the file belongs to. - */ - siteId: string; - - /** - * The fileId to identify the file. - */ - fileId: string; - - /** - * Timestamp when the file was added to the queue. - */ - added: number; - - /** - * The priority of the file. - */ - priority: number; - - /** - * File's URL. - */ - url: string; - - /** - * File's path. - */ - path?: string; - - /** - * File links (to link the file to components and componentIds). Serialized to store on DB. - */ - links: string; -}; - -/** - * Entry from the file's queue. - */ -export type CoreFilepoolQueueEntry = CoreFilepoolQueueDBEntry & { - /** - * File links (to link the file to components and componentIds). - */ - linksUnserialized?: CoreFilepoolComponentLink[]; -}; - -/** - * Entry from packages table. - */ -export type CoreFilepoolPackageEntry = { - /** - * Package id. - */ - id?: string; - - /** - * The component to link the files to. - */ - component?: string; - - /** - * An ID to use in conjunction with the component. - */ - componentId?: string | number; - - /** - * Package status. - */ - status?: string; - - /** - * Package previous status. - */ - previous?: string; - - /** - * Timestamp when this package was updated. - */ - updated?: number; - - /** - * Timestamp when this package was downloaded. - */ - downloadTime?: number; - - /** - * Previous download time. - */ - previousDownloadTime?: number; - - /** - * Extra data stored by the package. - */ - extra?: string; -}; - -/** - * A component link. - */ -export type CoreFilepoolComponentLink = { - /** - * Link's component. - */ - component: string; - - /** - * Link's componentId. - */ - componentId?: string | number; -}; - /** * File actions. */ @@ -3359,14 +3035,5 @@ type CoreFilepoolPromiseDefer = PromiseDefer & { onProgress?: CoreFilepoolOnProgressCallback; // On Progress function. }; -/** - * Links table record type. - */ -type CoreFilepoolLinksRecord = { - fileId: string; // File Id. - component: string; // Component name. - componentId: number | string; // Component Id. -}; - type AnchorOrMediaElement = HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement; diff --git a/src/app/services/local-notifications.db.ts b/src/app/services/local-notifications.db.ts new file mode 100644 index 000000000..4eb333077 --- /dev/null +++ b/src/app/services/local-notifications.db.ts @@ -0,0 +1,80 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreAppSchema } from '@services/app'; +import { PromiseDefer } from '@services/utils/utils'; + +/** + * Database variables for CoreLocalNotifications service. + */ +export const SITES_TABLE_NAME = 'notification_sites'; // Store to asigne unique codes to each site. +export const COMPONENTS_TABLE_NAME = 'notification_components'; // Store to asigne unique codes to each component. +export const TRIGGERED_TABLE_NAME = 'notifications_triggered'; // Store to prevent re-triggering notifications. +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreLocalNotificationsProvider', + version: 1, + tables: [ + { + name: SITES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'code', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: COMPONENTS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'code', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: TRIGGERED_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'at', + type: 'INTEGER', + notNull: true, + }, + ], + }, + ], +}; + +export type CodeRequestsQueueItem = { + table: string; + id: string; + deferreds: PromiseDefer[]; +}; diff --git a/src/app/services/local-notifications.ts b/src/app/services/local-notifications.ts index 5f1c7e3d7..def7f011c 100644 --- a/src/app/services/local-notifications.ts +++ b/src/app/services/local-notifications.ts @@ -16,11 +16,11 @@ import { Injectable } from '@angular/core'; import { Subject, Subscription } from 'rxjs'; import { ILocalNotification } from '@ionic-native/local-notifications'; -import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreTextUtils } from '@services/utils/text'; -import { CoreUtils, PromiseDefer } from '@services/utils/utils'; +import { CoreUtils } from '@services/utils/utils'; import { SQLiteDB } from '@classes/sqlitedb'; import { CoreSite } from '@classes/site'; import { CoreQueueRunner } from '@classes/queue-runner'; @@ -28,6 +28,13 @@ import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; +import { + APP_SCHEMA, + TRIGGERED_TABLE_NAME, + COMPONENTS_TABLE_NAME, + SITES_TABLE_NAME, + CodeRequestsQueueItem, +} from '@services/local-notifications.db'; /** * Service to handle local notifications. @@ -35,62 +42,6 @@ import { CoreLogger } from '@singletons/logger'; @Injectable() export class CoreLocalNotificationsProvider { - // Variables for the database. - protected static readonly SITES_TABLE = 'notification_sites'; // Store to asigne unique codes to each site. - protected static readonly COMPONENTS_TABLE = 'notification_components'; // Store to asigne unique codes to each component. - protected static readonly TRIGGERED_TABLE = 'notifications_triggered'; // Store to prevent re-triggering notifications. - protected tablesSchema: CoreAppSchema = { - name: 'CoreLocalNotificationsProvider', - version: 1, - tables: [ - { - name: CoreLocalNotificationsProvider.SITES_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'code', - type: 'INTEGER', - notNull: true, - }, - ], - }, - { - name: CoreLocalNotificationsProvider.COMPONENTS_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'code', - type: 'INTEGER', - notNull: true, - }, - ], - }, - { - name: CoreLocalNotificationsProvider.TRIGGERED_TABLE, - columns: [ - { - name: 'id', - type: 'INTEGER', - primaryKey: true, - }, - { - name: 'at', - type: 'INTEGER', - notNull: true, - }, - ], - }, - ], - }; - protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. @@ -111,7 +62,7 @@ export class CoreLocalNotificationsProvider { this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider'); this.queueRunner = new CoreQueueRunner(10); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.tablesSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); @@ -301,7 +252,7 @@ export class CoreLocalNotificationsProvider { * @return Promise resolved when the component code is retrieved. */ protected getComponentCode(component: string): Promise { - return this.requestCode(CoreLocalNotificationsProvider.COMPONENTS_TABLE, component); + return this.requestCode(COMPONENTS_TABLE_NAME, component); } /** @@ -312,7 +263,7 @@ export class CoreLocalNotificationsProvider { * @return Promise resolved when the site code is retrieved. */ protected getSiteCode(siteId: string): Promise { - return this.requestCode(CoreLocalNotificationsProvider.SITES_TABLE, siteId); + return this.requestCode(SITES_TABLE_NAME, siteId); } /** @@ -377,7 +328,7 @@ export class CoreLocalNotificationsProvider { try { const stored = await this.appDB.getRecord<{ id: number; at: number }>( - CoreLocalNotificationsProvider.TRIGGERED_TABLE, + TRIGGERED_TABLE_NAME, { id: notification.id }, ); @@ -532,7 +483,7 @@ export class CoreLocalNotificationsProvider { async removeTriggered(id: number): Promise { await this.dbReady; - await this.appDB.deleteRecords(CoreLocalNotificationsProvider.TRIGGERED_TABLE, { id: id }); + await this.appDB.deleteRecords(TRIGGERED_TABLE_NAME, { id: id }); } /** @@ -695,7 +646,7 @@ export class CoreLocalNotificationsProvider { at: notification.trigger && notification.trigger.at ? notification.trigger.at.getTime() : Date.now(), }; - return this.appDB.insertRecord(CoreLocalNotificationsProvider.TRIGGERED_TABLE, entry); + return this.appDB.insertRecord(TRIGGERED_TABLE_NAME, entry); } /** @@ -708,10 +659,10 @@ export class CoreLocalNotificationsProvider { async updateComponentName(oldName: string, newName: string): Promise { await this.dbReady; - const oldId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + oldName; - const newId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + newName; + const oldId = COMPONENTS_TABLE_NAME + '#' + oldName; + const newId = COMPONENTS_TABLE_NAME + '#' + newName; - await this.appDB.updateRecords(CoreLocalNotificationsProvider.COMPONENTS_TABLE, { id: newId }, { id: oldId }); + await this.appDB.updateRecords(COMPONENTS_TABLE_NAME, { id: newId }, { id: oldId }); } } @@ -719,9 +670,3 @@ export class CoreLocalNotificationsProvider { export class CoreLocalNotifications extends makeSingleton(CoreLocalNotificationsProvider) {} export type CoreLocalNotificationsClickCallback = (value: T) => void; - -type CodeRequestsQueueItem = { - table: string; - id: string; - deferreds: PromiseDefer[]; -}; diff --git a/src/app/services/sites.db.ts b/src/app/services/sites.db.ts new file mode 100644 index 000000000..83c6e8027 --- /dev/null +++ b/src/app/services/sites.db.ts @@ -0,0 +1,233 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreAppSchema } from '@services/app'; +import { CoreSiteSchema, registerSiteSchema } from '@services/sites'; +import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; +import { CoreSite } from '@classes/site'; + +/** + * Database variables for CoreSites service. + */ +export const SITES_TABLE_NAME = 'sites_2'; +export const CURRENT_SITE_TABLE_NAME = 'current_site'; +export const SCHEMA_VERSIONS_TABLE_NAME = 'schema_versions'; + +// Schema to register in App DB. +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreSitesProvider', + version: 2, + tables: [ + { + name: SITES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'siteUrl', + type: 'TEXT', + notNull: true, + }, + { + name: 'token', + type: 'TEXT', + }, + { + name: 'info', + type: 'TEXT', + }, + { + name: 'privateToken', + type: 'TEXT', + }, + { + name: 'config', + type: 'TEXT', + }, + { + name: 'loggedOut', + type: 'INTEGER', + }, + { + name: 'oauthId', + type: 'INTEGER', + }, + ], + }, + { + name: CURRENT_SITE_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'siteId', + type: 'TEXT', + notNull: true, + unique: true, + }, + ], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion < 2) { + const newTable = SITES_TABLE_NAME; + const oldTable = 'sites'; + + try { + // Check if V1 table exists. + await db.tableExists(oldTable); + + // Move the records from the old table. + const sites = await db.getAllRecords(oldTable); + const promises: Promise[] = []; + + sites.forEach((site) => { + promises.push(db.insertRecord(newTable, site)); + }); + + await Promise.all(promises); + + // Data moved, drop the old table. + await db.dropTable(oldTable); + } catch (error) { + // Old table does not exist, ignore. + } + } + }, +}; + +// Schema to register for Site DB. +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreSitesProvider', + version: 2, + canBeCleared: [CoreSite.WS_CACHE_TABLE], + tables: [ + { + name: CoreSite.WS_CACHE_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'data', + type: 'TEXT', + }, + { + name: 'key', + type: 'TEXT', + }, + { + name: 'expirationTime', + type: 'INTEGER', + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentId', + type: 'INTEGER', + }, + ], + }, + { + name: CoreSite.CONFIG_TABLE, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true, + }, + { + name: 'value', + }, + ], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion && oldVersion < 2) { + const newTable = CoreSite.WS_CACHE_TABLE; + const oldTable = 'wscache'; + + try { + await db.tableExists(oldTable); + } catch (error) { + // Old table does not exist, ignore. + return; + } + // Cannot use insertRecordsFrom because there are extra fields, so manually code INSERT INTO. + await db.execute( + 'INSERT INTO ' + newTable + ' ' + + 'SELECT id, data, key, expirationTime, NULL as component, NULL as componentId ' + + 'FROM ' + oldTable, + ); + + try { + await db.dropTable(oldTable); + } catch (error) { + // Error deleting old table, ignore. + } + } + }, +}; + +// Table for site DB to include the schema versions. It's not part of SITE_SCHEMA because it needs to be created first. +export const SCHEMA_VERSIONS_TABLE_SCHEMA: SQLiteDBTableSchema = { + name: SCHEMA_VERSIONS_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'version', + type: 'INTEGER', + }, + ], +}; + +export type SiteDBEntry = { + id: string; + siteUrl: string; + token: string; + info: string; + privateToken: string; + config: string; + loggedOut: number; + oauthId: number; +}; + +export type CurrentSiteDBEntry = { + id: number; + siteId: string; +}; + +export type SchemaVersionsDBEntry = { + name: string; + version: number; +}; + +export const initCoreSitesDB = (): void => { + registerSiteSchema(SITE_SCHEMA); +}; diff --git a/src/app/services/sites.ts b/src/app/services/sites.ts index 71604d276..7bcccfc8b 100644 --- a/src/app/services/sites.ts +++ b/src/app/services/sites.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { Md5 } from 'ts-md5/dist/md5'; import { timeout } from 'rxjs/operators'; -import { CoreApp, CoreAppSchema, CoreStoreConfig } from '@services/app'; +import { CoreApp, CoreStoreConfig } from '@services/app'; import { CoreEvents } from '@singletons/events'; import { CoreWS } from '@services/ws'; import { CoreDomUtils } from '@services/utils/dom'; @@ -38,114 +38,36 @@ import { CoreError } from '@classes/errors/error'; import { CoreSiteError } from '@classes/errors/siteerror'; import { makeSingleton, Translate, Http } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; +import { + APP_SCHEMA, + SCHEMA_VERSIONS_TABLE_SCHEMA, + SITES_TABLE_NAME, + CURRENT_SITE_TABLE_NAME, + SCHEMA_VERSIONS_TABLE_NAME, + SiteDBEntry, + CurrentSiteDBEntry, + SchemaVersionsDBEntry, +} from '@services/sites.db'; -const SITES_TABLE = 'sites_2'; -const CURRENT_SITE_TABLE = 'current_site'; -const SCHEMA_VERSIONS_TABLE = 'schema_versions'; + +// Schemas for site tables. Other providers can add schemas in here using the registerSiteSchema function. +const siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; +export const registerSiteSchema = (schema: CoreSiteSchema): void => { + siteSchemas[schema.name] = schema; +}; /* * Service to manage and interact with sites. * It allows creating tables in the databases of all sites. Each service or component should be responsible of creating * their own database tables. Example: * - * constructor(sitesProvider: CoreSitesProvider) { - * this.sitesProvider.registerSiteSchema(this.tableSchema); + * import { registerSiteSchema } from '@services/sites'; * - * This provider will automatically create the tables in the databases of all the instantiated sites, and also to the - * databases of sites instantiated from now on. + * registerSiteSchema(tableSchema); */ @Injectable() export class CoreSitesProvider { - // Variables for the database. - protected appTablesSchema: CoreAppSchema = { - name: 'CoreSitesProvider', - version: 2, - tables: [ - { - name: SITES_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'siteUrl', - type: 'TEXT', - notNull: true, - }, - { - name: 'token', - type: 'TEXT', - }, - { - name: 'info', - type: 'TEXT', - }, - { - name: 'privateToken', - type: 'TEXT', - }, - { - name: 'config', - type: 'TEXT', - }, - { - name: 'loggedOut', - type: 'INTEGER', - }, - { - name: 'oauthId', - type: 'INTEGER', - }, - ], - }, - { - name: CURRENT_SITE_TABLE, - columns: [ - { - name: 'id', - type: 'INTEGER', - primaryKey: true, - }, - { - name: 'siteId', - type: 'TEXT', - notNull: true, - unique: true, - }, - ], - }, - ], - async migrate(db: SQLiteDB, oldVersion: number): Promise { - if (oldVersion < 2) { - const newTable = SITES_TABLE; - const oldTable = 'sites'; - - try { - // Check if V1 table exists. - await db.tableExists(oldTable); - - // Move the records from the old table. - const sites = await db.getAllRecords(oldTable); - const promises: Promise[] = []; - - sites.forEach((site) => { - promises.push(db.insertRecord(newTable, site)); - }); - - await Promise.all(promises); - - // Data moved, drop the old table. - await db.dropTable(oldTable); - } catch (error) { - // Old table does not exist, ignore. - } - } - }, - }; - // Constants to validate a site version. protected readonly WORKPLACE_APP = 3; protected readonly MOODLE_APP = 2; @@ -162,112 +84,15 @@ export class CoreSitesProvider { protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. protected siteSchemasMigration: { [siteId: string]: Promise } = {}; - - // Schemas for site tables. Other providers can add schemas in here. - protected siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; - protected siteTablesSchemas: SQLiteDBTableSchema[] = [ - { - name: SCHEMA_VERSIONS_TABLE, - columns: [ - { - name: 'name', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'version', - type: 'INTEGER', - }, - ], - }, - ]; - - // Site schema for this provider. - protected siteSchema: CoreSiteSchema = { - name: 'CoreSitesProvider', - version: 2, - canBeCleared: [CoreSite.WS_CACHE_TABLE], - tables: [ - { - name: CoreSite.WS_CACHE_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'data', - type: 'TEXT', - }, - { - name: 'key', - type: 'TEXT', - }, - { - name: 'expirationTime', - type: 'INTEGER', - }, - { - name: 'component', - type: 'TEXT', - }, - { - name: 'componentId', - type: 'INTEGER', - }, - ], - }, - { - name: CoreSite.CONFIG_TABLE, - columns: [ - { - name: 'name', - type: 'TEXT', - unique: true, - notNull: true, - }, - { - name: 'value', - }, - ], - }, - ], - async migrate(db: SQLiteDB, oldVersion: number): Promise { - if (oldVersion && oldVersion < 2) { - const newTable = CoreSite.WS_CACHE_TABLE; - const oldTable = 'wscache'; - - try { - await db.tableExists(oldTable); - } catch (error) { - // Old table does not exist, ignore. - return; - } - // Cannot use insertRecordsFrom because there are extra fields, so manually code INSERT INTO. - await db.execute( - 'INSERT INTO ' + newTable + ' ' + - 'SELECT id, data, key, expirationTime, NULL as component, NULL as componentId ' + - 'FROM ' + oldTable, - ); - - try { - await db.dropTable(oldTable); - } catch (error) { - // Error deleting old table, ignore. - } - } - }, - }; + protected pluginsSiteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; constructor() { this.logger = CoreLogger.getInstance('CoreSitesProvider'); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); - this.registerSiteSchema(this.siteSchema); } /** @@ -857,7 +682,7 @@ export class CoreSitesProvider { oauthId, }; - await this.appDB.insertRecord(SITES_TABLE, entry); + await this.appDB.insertRecord(SITES_TABLE_NAME, entry); } /** @@ -1084,7 +909,7 @@ export class CoreSitesProvider { delete this.sites[siteId]; try { - await this.appDB.deleteRecords(SITES_TABLE, { id: siteId }); + await this.appDB.deleteRecords(SITES_TABLE_NAME, { id: siteId }); } catch (err) { // DB remove shouldn't fail, but we'll go ahead even if it does. } @@ -1103,7 +928,7 @@ export class CoreSitesProvider { async hasSites(): Promise { await this.dbReady; - const count = await this.appDB.countRecords(SITES_TABLE); + const count = await this.appDB.countRecords(SITES_TABLE_NAME); return count > 0; } @@ -1129,7 +954,7 @@ export class CoreSitesProvider { return this.sites[siteId]; } else { // Retrieve and create the site. - const data = await this.appDB.getRecord(SITES_TABLE, { id: siteId }); + const data = await this.appDB.getRecord(SITES_TABLE_NAME, { id: siteId }); return this.makeSiteFromSiteListEntry(data); } @@ -1202,7 +1027,7 @@ export class CoreSitesProvider { async getSites(ids?: string[]): Promise { await this.dbReady; - const sites = await this.appDB.getAllRecords(SITES_TABLE); + const sites = await this.appDB.getAllRecords(SITES_TABLE_NAME); const formattedSites: CoreSiteBasicInfo[] = []; sites.forEach((site) => { @@ -1266,7 +1091,7 @@ export class CoreSitesProvider { async getLoggedInSitesIds(): Promise { await this.dbReady; - const sites = await this.appDB.getRecords(SITES_TABLE, { loggedOut : 0 }); + const sites = await this.appDB.getRecords(SITES_TABLE_NAME, { loggedOut : 0 }); return sites.map((site) => site.id); } @@ -1279,7 +1104,7 @@ export class CoreSitesProvider { async getSitesIds(): Promise { await this.dbReady; - const sites = await this.appDB.getAllRecords(SITES_TABLE); + const sites = await this.appDB.getAllRecords(SITES_TABLE_NAME); return sites.map((site) => site.id); } @@ -1298,7 +1123,7 @@ export class CoreSitesProvider { siteId, }; - await this.appDB.insertRecord(CURRENT_SITE_TABLE, entry); + await this.appDB.insertRecord(CURRENT_SITE_TABLE_NAME, entry); CoreEvents.trigger(CoreEvents.LOGIN, {}, siteId); } @@ -1324,7 +1149,7 @@ export class CoreSitesProvider { promises.push(this.setSiteLoggedOut(siteId, true)); } - promises.push(this.appDB.deleteRecords(CURRENT_SITE_TABLE, { id: 1 })); + promises.push(this.appDB.deleteRecords(CURRENT_SITE_TABLE_NAME, { id: 1 })); } try { @@ -1349,7 +1174,7 @@ export class CoreSitesProvider { this.sessionRestored = true; try { - const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE, { id: 1 }); + const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE_NAME, { id: 1 }); const siteId = currentSite.siteId; this.logger.debug(`Restore session in site ${siteId}`); @@ -1377,7 +1202,7 @@ export class CoreSitesProvider { site.setLoggedOut(loggedOut); - await this.appDB.updateRecords(SITES_TABLE, newValues, { id: siteId }); + await this.appDB.updateRecords(SITES_TABLE_NAME, newValues, { id: siteId }); } /** @@ -1426,7 +1251,7 @@ export class CoreSitesProvider { site.privateToken = privateToken; site.setLoggedOut(false); // Token updated means the user authenticated again, not logged out anymore. - await this.appDB.updateRecords(SITES_TABLE, newValues, { id: siteId }); + await this.appDB.updateRecords(SITES_TABLE_NAME, newValues, { id: siteId }); } /** @@ -1470,7 +1295,7 @@ export class CoreSitesProvider { } try { - await this.appDB.updateRecords(SITES_TABLE, newValues, { id: siteId }); + await this.appDB.updateRecords(SITES_TABLE_NAME, newValues, { id: siteId }); } finally { CoreEvents.trigger(CoreEvents.SITE_UPDATED, info, siteId); } @@ -1529,7 +1354,7 @@ export class CoreSitesProvider { } try { - const siteEntries = await this.appDB.getAllRecords(SITES_TABLE); + const siteEntries = await this.appDB.getAllRecords(SITES_TABLE_NAME); const ids: string[] = []; const promises: Promise[] = []; @@ -1562,7 +1387,7 @@ export class CoreSitesProvider { async getStoredCurrentSiteId(): Promise { await this.dbReady; - const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE, { id: 1 }); + const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE_NAME, { id: 1 }); return currentSite.siteId; } @@ -1605,32 +1430,6 @@ export class CoreSitesProvider { return this.getSite(siteId).then((site) => site.isFeatureDisabled(name)); } - /** - * Create a table in all the sites databases. - * - * @param table Table schema. - * @deprecated. Please use registerSiteSchema instead. - */ - createTableFromSchema(table: SQLiteDBTableSchema): void { - this.createTablesFromSchema([table]); - } - - /** - * Create several tables in all the sites databases. - * - * @param tables List of tables schema. - * @deprecated. Please use registerSiteSchema instead. - */ - createTablesFromSchema(tables: SQLiteDBTableSchema[]): void { - // Add the tables to the list of schemas. This list is to create all the tables in new sites. - this.siteTablesSchemas = this.siteTablesSchemas.concat(tables); - - // Now create these tables in current sites. - for (const id in this.sites) { - this.sites[id].getDb().createTablesFromSchema(tables); - } - } - /** * Check if a WS is available in the current site, if any. * @@ -1645,40 +1444,29 @@ export class CoreSitesProvider { } /** - * Register a site schema. + * Register a site schema in current site. + * This function is meant for site plugins to create DB tables in current site. Tables created from within the app + * whould use the registerSiteSchema function exported in this same file. * * @param schema The schema to register. * @return Promise resolved when done. */ async registerSiteSchema(schema: CoreSiteSchema): Promise { - if (this.currentSite) { - try { - // Site has already been created, apply the schema directly. - const schemas: {[name: string]: CoreRegisteredSiteSchema} = {}; - schemas[schema.name] = schema; + if (!this.currentSite) { + return; + } - if (!schema.onlyCurrentSite) { - // Apply it to all sites. - const siteIds = await this.getSitesIds(); + try { + // Site has already been created, apply the schema directly. + const schemas: {[name: string]: CoreRegisteredSiteSchema} = {}; + schemas[schema.name] = schema; - await Promise.all(siteIds.map(async (siteId) => { - const site = await this.getSite(siteId); + // Apply it to the specified site only. + (schema as CoreRegisteredSiteSchema).siteId = this.currentSite.getId(); - return this.applySiteSchemas(site, schemas); - })); - } else { - // Apply it to the specified site only. - (schema as CoreRegisteredSiteSchema).siteId = this.currentSite.getId(); - - await this.applySiteSchemas(this.currentSite, schemas); - } - } finally { - // Add the schema to the list. It's done in the end to prevent a schema being applied twice. - this.siteSchemas[schema.name] = schema; - } - } else if (!schema.onlyCurrentSite) { - // Add the schema to the list, it will be applied when the sites are created. - this.siteSchemas[schema.name] = schema; + await this.applySiteSchemas(this.currentSite, schemas); + } finally { + this.pluginsSiteSchemas[schema.name] = schema; } } @@ -1700,8 +1488,8 @@ export class CoreSitesProvider { this.logger.debug(`Migrating all schemas of ${site.id}`); // First create tables not registerd with name/version. - const promise = site.getDb().createTablesFromSchema(this.siteTablesSchemas) - .then(() => this.applySiteSchemas(site, this.siteSchemas)); + const promise = site.getDb().createTableFromSchema(SCHEMA_VERSIONS_TABLE_SCHEMA) + .then(() => this.applySiteSchemas(site, siteSchemas)); this.siteSchemasMigration[site.id] = promise; @@ -1721,7 +1509,7 @@ export class CoreSitesProvider { const db = site.getDb(); // Fetch installed versions of the schema. - const records = await db.getAllRecords(SCHEMA_VERSIONS_TABLE); + const records = await db.getAllRecords(SCHEMA_VERSIONS_TABLE_NAME); const versions: {[name: string]: number} = {}; records.forEach((record) => { @@ -1768,7 +1556,7 @@ export class CoreSitesProvider { } // Set installed version. - await db.insertRecord(SCHEMA_VERSIONS_TABLE, { name, version: schema.version }); + await db.insertRecord(SCHEMA_VERSIONS_TABLE_NAME, { name, version: schema.version }); } /** @@ -1814,13 +1602,13 @@ export class CoreSitesProvider { */ getSiteTableSchemasToClear(site: CoreSite): string[] { let reset: string[] = []; - for (const name in this.siteSchemas) { - const schema = this.siteSchemas[name]; + const schemas = Object.values(siteSchemas).concat(Object.values(this.pluginsSiteSchemas)); + schemas.forEach((schema) => { if (schema.canBeCleared && (!schema.siteId || site.getId() == schema.siteId)) { reset = reset.concat(schema.canBeCleared); } - } + }); return reset; } @@ -1980,12 +1768,6 @@ export type CoreSiteSchema = { */ canBeCleared?: string[]; - /** - * If true, the schema will only be applied to the current site. Otherwise it will be applied to all sites. - * If you're implementing a site plugin, please set it to true. - */ - onlyCurrentSite?: boolean; - /** * Tables to create when installing or upgrading the schema. */ @@ -2088,24 +1870,3 @@ export type CoreSitesLoginTokenResponse = { debuginfo?: string; reproductionlink?: string; }; - -type SiteDBEntry = { - id: string; - siteUrl: string; - token: string; - info: string; - privateToken: string; - config: string; - loggedOut: number; - oauthId: number; -}; - -type CurrentSiteDBEntry = { - id: number; - siteId: string; -}; - -type SchemaVersionsDBEntry = { - name: string; - version: number; -}; diff --git a/src/app/services/sync.db.ts b/src/app/services/sync.db.ts new file mode 100644 index 000000000..0f30c0a12 --- /dev/null +++ b/src/app/services/sync.db.ts @@ -0,0 +1,62 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema, registerSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreSync service. + */ +export const SYNC_TABLE_NAME = 'sync'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreSyncProvider', + version: 1, + tables: [ + { + name: SYNC_TABLE_NAME, + columns: [ + { + name: 'component', + type: 'TEXT', + notNull: true, + }, + { + name: 'id', + type: 'TEXT', + notNull: true, + }, + { + name: 'time', + type: 'INTEGER', + }, + { + name: 'warnings', + type: 'TEXT', + }, + ], + primaryKeys: ['component', 'id'], + }, + ], +}; + +export type CoreSyncRecord = { + component: string; + id: string; + time: number; + warnings: string; +}; + +export const initCoreSyncDB = (): void => { + registerSiteSchema(SITE_SCHEMA); +}; + diff --git a/src/app/services/sync.ts b/src/app/services/sync.ts index 2246f5cbe..efbe00ab4 100644 --- a/src/app/services/sync.ts +++ b/src/app/services/sync.ts @@ -16,8 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreEvents } from '@singletons/events'; import { CoreSites, CoreSiteSchema } from '@services/sites'; import { makeSingleton } from '@singletons/core.singletons'; - -const SYNC_TABLE = 'sync'; +import { SYNC_TABLE_NAME, CoreSyncRecord } from '@services/sync.db'; /* * Service that provides some features regarding synchronization. @@ -31,7 +30,7 @@ export class CoreSyncProvider { version: 1, tables: [ { - name: SYNC_TABLE, + name: SYNC_TABLE_NAME, columns: [ { name: 'component', @@ -61,8 +60,6 @@ export class CoreSyncProvider { protected blockedItems: { [siteId: string]: { [blockId: string]: { [operation: string]: boolean } } } = {}; constructor() { - CoreSites.instance.registerSiteSchema(this.siteSchema); - // Unblock all blocks on logout. CoreEvents.on(CoreEvents.LOGOUT, (data: {siteId: string}) => { this.clearAllBlocks(data.siteId); @@ -133,7 +130,7 @@ export class CoreSyncProvider { * @return Record if found or reject. */ getSyncRecord(component: string, id: string | number, siteId?: string): Promise { - return CoreSites.instance.getSiteDb(siteId).then((db) => db.getRecord(SYNC_TABLE, { component: component, id: id })); + return CoreSites.instance.getSiteDb(siteId).then((db) => db.getRecord(SYNC_TABLE_NAME, { component: component, id: id })); } /** @@ -151,7 +148,7 @@ export class CoreSyncProvider { data.component = component; data.id = id; - await db.insertRecord(SYNC_TABLE, data); + await db.insertRecord(SYNC_TABLE_NAME, data); } /** @@ -211,10 +208,3 @@ export class CoreSyncProvider { } export class CoreSync extends makeSingleton(CoreSyncProvider) {} - -export type CoreSyncRecord = { - component: string; - id: string; - time: number; - warnings: string; -}; diff --git a/src/app/services/tests/utils/url.test.ts b/src/app/services/tests/utils/url.test.ts new file mode 100644 index 000000000..50b3fea57 --- /dev/null +++ b/src/app/services/tests/utils/url.test.ts @@ -0,0 +1,79 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreUrlUtilsProvider } from '@services/utils/url'; + +describe('CoreUrlUtilsProvider', () => { + + let urlUtils: CoreUrlUtilsProvider; + + beforeEach(() => { + urlUtils = new CoreUrlUtilsProvider(); + }); + + it('adds www if missing', () => { + const originalUrl = 'https://moodle.org'; + const url = urlUtils.addOrRemoveWWW(originalUrl); + + expect(url).toEqual('https://www.moodle.org'); + }); + + it('removes www if present', () => { + const originalUrl = 'https://www.moodle.org'; + const url = urlUtils.addOrRemoveWWW(originalUrl); + + expect(url).toEqual('https://moodle.org'); + }); + + it('adds params to URL without params', () => { + const originalUrl = 'https://moodle.org'; + const params = { + first: '1', + second: '2', + }; + const url = urlUtils.addParamsToUrl(originalUrl, params); + + expect(url).toEqual('https://moodle.org?first=1&second=2'); + }); + + it('adds params to URL with existing params', () => { + const originalUrl = 'https://moodle.org?existing=1'; + const params = { + first: '1', + second: '2', + }; + const url = urlUtils.addParamsToUrl(originalUrl, params); + + expect(url).toEqual('https://moodle.org?existing=1&first=1&second=2'); + }); + + it('doesn\'t change URL if no params supplied', () => { + const originalUrl = 'https://moodle.org'; + const url = urlUtils.addParamsToUrl(originalUrl); + + expect(url).toEqual(originalUrl); + }); + + it('adds anchor to URL', () => { + const originalUrl = 'https://moodle.org'; + const params = { + first: '1', + second: '2', + }; + const url = urlUtils.addParamsToUrl(originalUrl, params, 'myanchor'); + + expect(url).toEqual('https://moodle.org?first=1&second=2#myanchor'); + }); + +}); diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts index 86b2ccf07..a8ae50b82 100644 --- a/src/app/services/utils/dom.ts +++ b/src/app/services/utils/dom.ts @@ -1014,7 +1014,7 @@ export class CoreDomUtilsProvider { * @deprecated since 3.9.5. Use directly the IonContent class. */ scrollTo(content: IonContent, x: number, y: number, duration?: number): Promise { - return content?.scrollByPoint(x, y, duration || 0); + return content?.scrollToPoint(x, y, duration || 0); } /** @@ -1104,7 +1104,7 @@ export class CoreDomUtilsProvider { return false; } - content?.scrollByPoint(position[0], position[1], duration || 0); + content?.scrollToPoint(position[0], position[1], duration || 0); return true; } @@ -1124,6 +1124,8 @@ export class CoreDomUtilsProvider { scrollParentClass?: string, duration?: number, ): Promise { + // @todo: This function is broken. Scroll element cannot be used because it uses shadow DOM so querySelector returns null. + // Also, traversing using parentElement doesn't work either, offsetParent isn't part of the parentElement tree. try { const scrollElement = await content.getScrollElement(); @@ -1132,7 +1134,7 @@ export class CoreDomUtilsProvider { return false; } - content?.scrollByPoint(position[0], position[1], duration || 0); + content?.scrollToPoint(position[0], position[1], duration || 0); return true; } catch (error) { @@ -1147,7 +1149,7 @@ export class CoreDomUtilsProvider { * @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll. * @return True if the element is found, false otherwise. */ - async scrollToInputError(content: IonContent, scrollParentClass?: string): Promise { + async scrollToInputError(content?: IonContent, scrollParentClass?: string): Promise { if (!content) { return false; } diff --git a/src/app/services/utils/iframe.ts b/src/app/services/utils/iframe.ts index eae0e490b..f419f21f1 100644 --- a/src/app/services/utils/iframe.ts +++ b/src/app/services/utils/iframe.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { NavController } from '@ionic/angular'; import { WKUserScriptWindow, WKUserScriptInjectionTime } from 'cordova-plugin-wkuserscript'; +import { WKWebViewCookiesWindow } from 'cordova-plugin-wkwebview-cookies'; import { CoreApp } from '@services/app'; import { CoreFile } from '@services/file'; @@ -476,6 +477,36 @@ export class CoreIframeUtilsProvider { window.addEventListener('message', this.handleIframeMessage.bind(this)); } + /** + * Fix cookies for an iframe URL. + * + * @param url URL of the iframe. + * @return Promise resolved when done. + */ + async fixIframeCookies(url: string): Promise { + if (!CoreApp.instance.isIOS() || !url || CoreUrlUtils.instance.isLocalFileUrl(url)) { + // No need to fix cookies. + return; + } + + // Save a "fake" cookie for the iframe's domain to fix a bug in WKWebView. + try { + const win = window; + const urlParts = CoreUrl.parse(url); + + if (urlParts?.domain && win.WKWebViewCookies) { + await win.WKWebViewCookies.setCookie({ + name: 'MoodleAppCookieForWKWebView', + value: '1', + domain: urlParts.domain, + }); + } + } catch (err) { + // Ignore errors. + this.logger.error('Error setting cookie', err); + } + } + } export class CoreIframeUtils extends makeSingleton(CoreIframeUtilsProvider) {} diff --git a/src/app/services/utils/utils.ts b/src/app/services/utils/utils.ts index e8eedbb87..676f9855a 100644 --- a/src/app/services/utils/utils.ts +++ b/src/app/services/utils/utils.ts @@ -618,7 +618,7 @@ export class CoreUtilsProvider { * * @return Promise resolved with the list of countries. */ - getCountryListSorted(): Promise<{ code: string; name: string }[]> { + getCountryListSorted(): Promise { // Get the keys of the countries. return this.getCountryList().then((countries) => { // Sort translations. @@ -1659,3 +1659,11 @@ export type OrderedPromiseData = { */ blocking?: boolean; }; + +/** + * Data about a country. + */ +export type CoreCountry = { + code: string; + name: string; +}; diff --git a/src/assets/img/login/faq_qrcode.png b/src/assets/img/login/faq_qrcode.png new file mode 100644 index 000000000..cc936b168 Binary files /dev/null and b/src/assets/img/login/faq_qrcode.png differ diff --git a/src/assets/img/login/faq_url.png b/src/assets/img/login/faq_url.png new file mode 100644 index 000000000..13d92cd50 Binary files /dev/null and b/src/assets/img/login/faq_url.png differ diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 5b7aed812..494c43ebf 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -300,12 +300,65 @@ "assets.mimetypes.text/rtf": "RTF document", "assets.mimetypes.text/vtt": "Web Video Text Track", "assets.mimetypes.video": "Video file ({{$a.EXT}})", + "core.accounts": "Accounts", + "core.add": "Add", + "core.agelocationverification": "Age and location verification", + "core.ago": "{{$a}} ago", + "core.all": "All", + "core.allgroups": "All groups", + "core.allparticipants": "All participants", + "core.answer": "Answer", + "core.answered": "Answered", + "core.areyousure": "Are you sure?", "core.back": "Back", "core.browser": "Browser", + "core.cancel": "Cancel", "core.cannotconnect": "Cannot connect", "core.cannotconnecttrouble": "We're having trouble connecting to your site.", "core.cannotconnectverify": "Please check the address is correct.", + "core.cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", + "core.cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?", + "core.cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?", + "core.captureaudio": "Record audio", + "core.capturedimage": "Taken picture.", + "core.captureimage": "Take picture", + "core.capturevideo": "Record video", + "core.category": "Category", + "core.choose": "Choose", + "core.choosedots": "Choose...", + "core.clearsearch": "Clear search", + "core.clearstoreddata": "Clear storage {{$a}}", + "core.clicktohideshow": "Click to expand or collapse", + "core.clicktoseefull": "Click to see full contents.", + "core.close": "Close", + "core.comments": "Comments", + "core.commentscount": "Comments ({{$a}})", + "core.completion-alt-auto-fail": "Completed: {{$a}} (did not achieve pass grade)", + "core.completion-alt-auto-n": "Not completed: {{$a}}", + "core.completion-alt-auto-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "core.completion-alt-auto-pass": "Completed: {{$a}} (achieved pass grade)", + "core.completion-alt-auto-y": "Completed: {{$a}}", + "core.completion-alt-auto-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "core.completion-alt-manual-n": "Not completed: {{$a}}. Select to mark as complete.", + "core.completion-alt-manual-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as complete.", + "core.completion-alt-manual-y": "Completed: {{$a}}. Select to mark as not complete.", + "core.completion-alt-manual-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as not complete.", + "core.confirmcanceledit": "Are you sure you want to leave this page? All changes will be lost.", + "core.confirmdeletefile": "Are you sure you want to delete this file?", + "core.confirmgotabroot": "Are you sure you want to go back to {{name}}?", + "core.confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", + "core.confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have unsaved changes they will be lost.", + "core.confirmloss": "Are you sure? All changes will be lost.", + "core.confirmopeninbrowser": "Do you want to open it in a web browser?", + "core.considereddigitalminor": "You are too young to create an account on this site.", + "core.content": "Content", + "core.contenteditingsynced": "The content you are editing has been synced.", + "core.continue": "Continue", "core.copiedtoclipboard": "Text copied to clipboard", + "core.copytoclipboard": "Copy to clipboard", + "core.course": "Course", + "core.coursedetails": "Course details", + "core.coursenogroups": "You are not a member of any group of this course.", "core.courses.addtofavourites": "Star this course", "core.courses.allowguests": "This course allows guest users to enter", "core.courses.availablecourses": "Available courses", @@ -343,7 +396,90 @@ "core.courses.sendpaymentbutton": "Send payment via PayPal", "core.courses.show": "Restore to view", "core.courses.totalcoursesearchresults": "Total courses: {{$a}}", + "core.currentdevice": "Current device", + "core.datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.", + "core.date": "Date", + "core.day": "day", + "core.days": "days", + "core.decsep": ".", + "core.defaultvalue": "Default ({{$a}})", + "core.delete": "Delete", + "core.deletedoffline": "Deleted offline", + "core.deleteduser": "Deleted user", + "core.deleting": "Deleting", + "core.description": "Description", + "core.desktop": "Desktop", + "core.dfdaymonthyear": "MM-DD-YYYY", + "core.dfdayweekmonth": "ddd, D MMM", + "core.dffulldate": "dddd, D MMMM YYYY h[:]mm A", + "core.dflastweekdate": "ddd", + "core.dfmediumdate": "LLL", + "core.dftimedate": "h[:]mm A", + "core.digitalminor": "Digital minor", + "core.digitalminor_desc": "Please ask your parent/guardian to contact:", + "core.discard": "Discard", + "core.dismiss": "Dismiss", + "core.displayoptions": "Display options", + "core.done": "Done", + "core.download": "Download", + "core.downloaded": "Downloaded", + "core.downloadfile": "Download file", + "core.downloading": "Downloading", + "core.edit": "Edit", + "core.emptysplit": "This page will appear blank if the left panel is empty or is loading.", + "core.error": "Error", + "core.errorchangecompletion": "An error occurred while changing the completion status. Please try again.", + "core.errordeletefile": "Error deleting the file. Please try again.", + "core.errordownloading": "Error downloading file.", + "core.errordownloadingsomefiles": "Error downloading files. Some files might be missing.", + "core.errorfileexistssamename": "A file with this name already exists.", + "core.errorinvalidform": "The form contains invalid data. Please check that all required fields are filled in and that the data is valid.", + "core.errorinvalidresponse": "Invalid response received. Please contact your site administrator if the error persists.", + "core.errorloadingcontent": "Error loading content.", + "core.errorofflinedisabled": "Offline browsing is disabled on your site. You need to be connected to the internet to use the app.", + "core.erroropenfilenoapp": "Error opening file: no app found to open this type of file.", + "core.erroropenfilenoextension": "Error opening file: the file doesn't have an extension.", + "core.erroropenpopup": "This activity is trying to open a popup. This is not supported in the app.", + "core.errorrenamefile": "Error renaming file. Please try again.", + "core.errorsomedatanotdownloaded": "If you downloaded this activity, please notice that some data isn't downloaded during the download process for performance and data usage reasons.", + "core.errorsync": "An error occurred while synchronising. Please try again.", + "core.errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.", + "core.errorurlschemeinvalidscheme": "This URL is meant to be used in another app: {{$a}}.", + "core.errorurlschemeinvalidsite": "This site URL cannot be opened in this app.", + "core.explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", + "core.favourites": "Starred", + "core.filename": "Filename", + "core.filenameexist": "File name already exists: {{$a}}", + "core.filenotfound": "File not found, sorry.", + "core.filter": "Filter", + "core.folder": "Folder", + "core.forcepasswordchangenotice": "You must change your password to proceed.", + "core.fulllistofcourses": "All courses", + "core.fullnameandsitename": "{{fullname}} ({{sitename}})", + "core.group": "Group", + "core.groupsseparate": "Separate groups", + "core.groupsvisible": "Visible groups", + "core.hasdatatosync": "This {{$a}} has offline data to be synchronised.", + "core.help": "Help", + "core.hide": "Hide", + "core.hour": "hour", + "core.hours": "hours", + "core.humanreadablesize": "{{size}} {{unit}}", + "core.image": "Image", + "core.imageviewer": "Image viewer", + "core.info": "Information", + "core.invalidformdata": "Incorrect form data", + "core.labelsep": ":", + "core.lastaccess": "Last access", + "core.lastdownloaded": "Last downloaded", + "core.lastmodified": "Last modified", + "core.lastsync": "Last synchronisation", + "core.layoutgrid": "Grid", + "core.list": "List", + "core.listsep": ",", "core.loading": "Loading", + "core.loadmore": "Load more", + "core.location": "Location", "core.login.auth_email": "Email-based self-registration", "core.login.authenticating": "Authenticating", "core.login.cancel": "Cancel", @@ -381,7 +517,7 @@ "core.login.faqsetupsitequestion": "I want to set up my own Moodle site.", "core.login.faqtestappanswer": "To test the app in a Moodle Demo Site, type \"teacher\" or \"student\" in the \"Your site\" field and click the \"Connect to your site\" button.", "core.login.faqtestappquestion": "I just want to test the app, what can I do?", - "core.login.faqwhatisurlanswer": "

    Every organisation has their own unique address or URL for their Moodle site. To find the address:

    1. Open a web browser and go to your Moodle site login page.
    2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
      {{$image}}
    3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
    4. Now you can log in to your site using your username and password.
    5. ", + "core.login.faqwhatisurlanswer": "

      Every organisation has their own unique address or URL for their Moodle site. To find the address:

      1. Open a web browser and go to your Moodle site login page.
      2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
        {{$image}}
      3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
      4. Now you can log in to your site using your username and password.
      ", "core.login.faqwhatisurlquestion": "What is my site address? How can I find my site URL?", "core.login.faqwhereisqrcode": "Where can I find the QR code?", "core.login.faqwhereisqrcodeanswer": "

      If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.

      {{$image}}", @@ -466,17 +602,116 @@ "core.login.webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.", "core.login.youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.", "core.login.yourenteredsite": "Connect to your site", + "core.lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", "core.mainmenu.changesite": "Change site", "core.mainmenu.help": "Help", "core.mainmenu.home": "Home", "core.mainmenu.logout": "Log out", "core.mainmenu.website": "Website", + "core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", + "core.min": "min", + "core.mins": "mins", + "core.misc": "Miscellaneous", + "core.mod_assign": "Assignment", + "core.mod_assignment": "Assignment 2.2 (Disabled)", + "core.mod_book": "Book", + "core.mod_chat": "Chat", + "core.mod_choice": "Choice", + "core.mod_data": "Database", + "core.mod_database": "Database", + "core.mod_external-tool": "External tool", + "core.mod_feedback": "Feedback", + "core.mod_file": "File", + "core.mod_folder": "Folder", + "core.mod_forum": "Forum", + "core.mod_glossary": "Glossary", + "core.mod_h5pactivity": "H5P", + "core.mod_ims": "IMS content package", + "core.mod_imscp": "IMS content package", + "core.mod_label": "Label", + "core.mod_lesson": "Lesson", + "core.mod_lti": "External tool", + "core.mod_page": "Page", + "core.mod_quiz": "Quiz", + "core.mod_resource": "File", + "core.mod_scorm": "SCORM package", + "core.mod_survey": "Survey", + "core.mod_url": "URL", + "core.mod_wiki": "Wiki", + "core.mod_workshop": "Workshop", + "core.moduleintro": "Description", + "core.more": "more", + "core.mygroups": "My groups", + "core.name": "Name", "core.needhelp": "Need help?", + "core.networkerroriframemsg": "This content is not available offline. Please connect to the internet and try again.", "core.networkerrormsg": "There was a problem connecting to the site. Please check your connection and try again.", + "core.never": "Never", + "core.next": "Next", "core.no": "No", + "core.nocomments": "No comments", + "core.nograde": "No grade", + "core.none": "None", + "core.nooptionavailable": "No option available", + "core.nopasswordchangeforced": "You cannot proceed without changing your password.", + "core.nopermissionerror": "Sorry, but you do not currently have permissions to do that", + "core.nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", + "core.noresults": "No results", + "core.noselection": "No selection", + "core.notapplicable": "n/a", + "core.notavailable": "Not available", + "core.notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", + "core.notice": "Notice", + "core.notingroup": "Sorry, but you need to be part of a group to see this page.", + "core.notsent": "Not sent", + "core.now": "now", + "core.nummore": "{{$a}} more", + "core.numwords": "{{$a}} words", "core.offline": "Offline", "core.ok": "OK", "core.online": "Online", + "core.openfile": "Open file", + "core.openfullimage": "Click here to display the full size image", + "core.openinbrowser": "Open in browser", + "core.openmodinbrowser": "Open {{$a}} in browser", + "core.othergroups": "Other groups", + "core.pagea": "Page {{$a}}", + "core.parentlanguage": "", + "core.paymentinstant": "Use the button below to pay and be enrolled within minutes!", + "core.percentagenumber": "{{$a}}%", + "core.phone": "Phone", + "core.pictureof": "Picture of {{$a}}", + "core.previous": "Previous", + "core.proceed": "Proceed", + "core.pulltorefresh": "Pull to refresh", + "core.qrscanner": "QR scanner", + "core.quotausage": "You have currently used {{$a.used}} of your {{$a.total}} limit.", + "core.redirectingtosite": "You will be redirected to the site.", + "core.refresh": "Refresh", + "core.remove": "Remove", + "core.removefiles": "Remove files {{$a}}", + "core.required": "Required", + "core.requireduserdatamissing": "This user lacks some required profile data. Please enter the data in your site and try again.
      {{$a}}", + "core.resourcedisplayopen": "Open", + "core.resources": "Resources", + "core.restore": "Restore", + "core.restricted": "Restricted", + "core.retry": "Retry", + "core.save": "Save", + "core.savechanges": "Save changes", + "core.scanqr": "Scan QR code", + "core.search": "Search", + "core.searching": "Searching", + "core.searchresults": "Search results", + "core.sec": "sec", + "core.secs": "secs", + "core.seemoredetail": "Click here to see more detail", + "core.selectacategory": "Please select a category", + "core.selectacourse": "Select a course", + "core.selectagroup": "Select a group", + "core.send": "Send", + "core.sending": "Sending", + "core.serverconnection": "Error connecting to the server", "core.settings.about": "About", "core.settings.appsettings": "App settings", "core.settings.appversion": "App version", @@ -548,7 +783,79 @@ "core.settings.syncsettings": "Synchronisation settings", "core.settings.total": "Total", "core.settings.wificonnection": "Wi-Fi connection", + "core.show": "Show", + "core.showless": "Show less...", + "core.showmore": "Show more...", + "core.site": "Site", + "core.sitemaintenance": "The site is undergoing maintenance and is currently not available", + "core.sizeb": "bytes", + "core.sizegb": "GB", + "core.sizekb": "KB", + "core.sizemb": "MB", + "core.sizetb": "TB", + "core.skip": "Skip", + "core.sorry": "Sorry...", + "core.sort": "Sort", + "core.sortby": "Sort by", + "core.start": "Start", + "core.storingfiles": "Storing files", + "core.strftimedate": "%d %B %Y", + "core.strftimedatefullshort": "%d/%m/%y", + "core.strftimedateshort": "%d %B", + "core.strftimedatetime": "%d %B %Y, %I:%M %p", + "core.strftimedatetimeshort": "%d/%m/%y, %H:%M", + "core.strftimedaydate": "%A, %d %B %Y", + "core.strftimedaydatetime": "%A, %d %B %Y, %I:%M %p", + "core.strftimedayshort": "%A, %d %B", + "core.strftimedaytime": "%a, %H:%M", + "core.strftimemonthyear": "%B %Y", + "core.strftimerecent": "%d %b, %H:%M", + "core.strftimerecentfull": "%a, %d %b %Y, %I:%M %p", + "core.strftimetime": "%I:%M %p", + "core.strftimetime12": "%I:%M %p", + "core.strftimetime24": "%H:%M", + "core.submit": "Submit", + "core.success": "Success", + "core.tablet": "Tablet", + "core.teachers": "Teachers", + "core.thereisdatatosync": "There are offline {{$a}} to be synchronised.", + "core.thisdirection": "ltr", + "core.time": "Time", + "core.timesup": "Time is up!", + "core.today": "Today", "core.tryagain": "Try again", + "core.twoparagraphs": "{{p1}}

      {{p2}}", + "core.uhoh": "Uh oh!", + "core.unexpectederror": "Unexpected error. Please close and reopen the application then try again.", + "core.unicodenotsupported": "Some emojis are not supported on this site. Such characters will be removed when the message is sent.", + "core.unicodenotsupportedcleanerror": "Empty text was found when cleaning Unicode chars.", "core.unknown": "Unknown", - "core.yes": "Yes" + "core.unlimited": "Unlimited", + "core.unzipping": "Unzipping", + "core.updaterequired": "App update required", + "core.updaterequireddesc": "Please update your app to version {{$a}}", + "core.upgraderunning": "Site is being upgraded, please retry later.", + "core.user": "User", + "core.userdeleted": "This user account has been deleted", + "core.userdetails": "User details", + "core.usernotfullysetup": "User not fully set-up", + "core.users": "Users", + "core.view": "View", + "core.viewcode": "View code", + "core.vieweditor": "View editor", + "core.viewembeddedcontent": "View embedded content", + "core.viewprofile": "View profile", + "core.warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}", + "core.whatisyourage": "What is your age?", + "core.wheredoyoulive": "In which country do you live?", + "core.whoissiteadmin": "\"Site Administrators\" are the people who manage the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.", + "core.whoops": "Oops!", + "core.whyisthishappening": "Why is this happening?", + "core.whyisthisrequired": "Why is this required?", + "core.wsfunctionnotavailable": "The web service function is not available.", + "core.year": "year", + "core.years": "years", + "core.yes": "Yes", + "core.youreoffline": "You are offline", + "core.youreonline": "You are back online" } \ No newline at end of file diff --git a/src/theme/app.scss b/src/theme/app.scss index 6a8b5c921..abf6df18c 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -70,3 +70,32 @@ ion-item-divider { ion-list.list-md { padding-bottom: 0; } + +// Modals. +.core-modal-fullscreen .modal-wrapper { + position: absolute; + // @todo @include position(0 !important, null, null, 0 !important); + display: block; + width: 100% !important; + height: 100% !important; +} + +.core-modal-force-on-top { + z-index: 100000 !important; +} + +// Hidden submit button. +.core-submit-hidden-enter { + position: absolute; + visibility: hidden; + left: -1000px; +} + +// Note on foot of ion-input. +.item .core-input-footnote { + width: 100%; + font-style: italic; + margin-top: 0; + margin-bottom: 10px; + font-size: 14px; +}