Merge pull request #2580 from dpalou/MOBILE-3565

Mobile 3565
main
Dani Palou 2020-10-29 16:00:57 +01:00 committed by GitHub
commit 8d377fa5cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 4337 additions and 978 deletions

View File

@ -37,7 +37,10 @@ const routes: Routes = [
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }), RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules,
relativeLinkResolution: 'corrected',
}),
], ],
exports: [RouterModule], exports: [RouterModule],
}) })

View File

@ -50,6 +50,11 @@ import { CoreTimeUtilsProvider } from '@services/utils/time';
import { CoreUrlUtilsProvider } from '@services/utils/url'; import { CoreUrlUtilsProvider } from '@services/utils/url';
import { CoreUtilsProvider } from '@services/utils/utils'; 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 core modules.
import { CoreEmulatorModule } from '@core/emulator/emulator.module'; import { CoreEmulatorModule } from '@core/emulator/emulator.module';
import { CoreLoginModule } from '@core/login/login.module'; import { CoreLoginModule } from '@core/login/login.module';
@ -121,6 +126,8 @@ export class AppModule {
// Set the injector. // Set the injector.
setSingletonsInjector(injector); setSingletonsInjector(injector);
this.initCoreServicesDB();
// Register a handler for platform ready. // Register a handler for platform ready.
CoreInit.instance.registerProcess({ CoreInit.instance.registerProcess({
name: 'CorePlatformReady', name: 'CorePlatformReady',
@ -154,4 +161,13 @@ export class AppModule {
CoreInit.instance.executeInitProcesses(); CoreInit.instance.executeInitProcesses();
} }
/**
* Init the DB of core services.
*/
protected initCoreServicesDB(): void {
initCoreFilepoolDB();
initCoreSitesDB();
initCoreSyncDB();
}
} }

View File

@ -36,7 +36,7 @@ import { CoreIonLoadingElement } from './ion-loading';
/** /**
* Class that represents a site (combination of site + user). * Class that represents a site (combination of site + user).
* It will have all the site data and provide utility functions regarding a site. * 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. * 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). * @todo: Refactor this class to improve "temporary" sites support (not fully authenticated).

View File

@ -18,7 +18,12 @@ import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { CoreIconComponent } from './icon/icon'; import { CoreIconComponent } from './icon/icon';
import { CoreIframeComponent } from './iframe/iframe';
import { CoreInputErrorsComponent } from './input-errors/input-errors';
import { CoreLoadingComponent } from './loading/loading'; 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 { CoreShowPasswordComponent } from './show-password/show-password';
import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreEmptyBoxComponent } from './empty-box/empty-box';
import { CoreDirectivesModule } from '@app/directives/directives.module'; import { CoreDirectivesModule } from '@app/directives/directives.module';
@ -27,7 +32,12 @@ import { CorePipesModule } from '@app/pipes/pipes.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
CoreIconComponent, CoreIconComponent,
CoreIframeComponent,
CoreInputErrorsComponent,
CoreLoadingComponent, CoreLoadingComponent,
CoreMarkRequiredComponent,
CoreRecaptchaComponent,
CoreRecaptchaModalComponent,
CoreShowPasswordComponent, CoreShowPasswordComponent,
CoreEmptyBoxComponent, CoreEmptyBoxComponent,
], ],
@ -40,7 +50,12 @@ import { CorePipesModule } from '@app/pipes/pipes.module';
], ],
exports: [ exports: [
CoreIconComponent, CoreIconComponent,
CoreIframeComponent,
CoreInputErrorsComponent,
CoreLoadingComponent, CoreLoadingComponent,
CoreMarkRequiredComponent,
CoreRecaptchaComponent,
CoreRecaptchaModalComponent,
CoreShowPasswordComponent, CoreShowPasswordComponent,
CoreEmptyBoxComponent, CoreEmptyBoxComponent,
], ],

View File

@ -0,0 +1,11 @@
<div [class.core-loading-container]="loading || !safeUrl" [ngStyle]="{'width': iframeWidth, 'height': iframeHeight}">
<!-- Don't add the iframe until safeUrl is set, adding an iframe with null as src causes the iframe to load the whole app. -->
<iframe #iframe *ngIf="safeUrl" [hidden]="loading" class="core-iframe"
[ngStyle]="{'width': iframeWidth, 'height': iframeHeight}" [src]="safeUrl"
[attr.allowfullscreen]="allowFullscreen ? 'allowfullscreen' : null">
</iframe>
<span class="core-loading-spinner">
<ion-spinner *ngIf="loading"></ion-spinner>
</span>
</div>

View File

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

View File

@ -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<HTMLIFrameElement> = new EventEmitter<HTMLIFrameElement>();
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<HTMLIFrameElement>();
}
/**
* 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<void> {
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();
});
}
}
}

View File

@ -0,0 +1,16 @@
<div class="core-input-error-container" role="alert" *ngIf="(control && control.dirty && !control.valid) || errorText">
<ng-container *ngIf="control && control.dirty && !control.valid">
<ng-container *ngFor="let error of errorKeys">
<div *ngIf="control.hasError(error)" class="core-input-error">
<span *ngIf="errorMessages[error]">{{errorMessages[error]}}</span>
<span *ngIf="!errorMessages[error] && error == 'max' && control.errors.max">
{{ 'core.login.invalidvaluemax' | translate:{$a: control.errors.max.max} }}
</span>
<span *ngIf="!errorMessages[error] && error == 'min' && control.errors.min">
{{ 'core.login.invalidvaluemin' | translate:{$a: control.errors.min.min} }}
</span>
</div>
</ng-container>
</ng-container>
<div *ngIf="errorText" class="core-input-error" aria-live="assertive">{{ errorText }}</div>
</div>

View File

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

View File

@ -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:
*
* <ion-item text-wrap>
* <ion-label stacked core-mark-required="true">{{ 'core.login.username' | translate }}</ion-label>
* <ion-input type="text" name="username" formControlName="username"></ion-input>
* <core-input-errors item-content [control]="myForm.controls.username" [errorMessages]="usernameErrors"></core-input-errors>
* </ion-item>
*/
@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<string, string>;
@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;
}
}
}

View File

@ -0,0 +1,3 @@
<ng-content></ng-content>
<ion-icon *ngIf="coreMarkRequired" class="core-input-required-asterisk" name="fas-asterisk" color="danger" [title]="requiredLabel">
</ion-icon>

View File

@ -0,0 +1,8 @@
:host {
.core-input-required-asterisk {
font-size: 8px;
--padding-start: 4px;
line-height: 100%;
vertical-align: top;
}
}

View File

@ -0,0 +1,78 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { 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:
*
* <ion-label core-mark-required="{{field.required}}">{{ 'core.login.username' | translate }}</ion-label>
*/
@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, ''));
}
}
}
}

View File

@ -0,0 +1,13 @@
<!-- ReCAPTCHA V2 -->
<div *ngIf="publicKey && siteUrl && model">
<!-- A button to open the recaptcha modal. -->
<ion-button expand="block" color="light" class="ion-margin" *ngIf="!model[modelValueName]" (click)="answerRecaptcha()">
{{ 'core.resourcedisplayopen' | translate }}
</ion-button>
<ion-item *ngIf="model[modelValueName]">
<ion-label color="success">{{ 'core.answered' | translate }}</ion-label>
</ion-item>
<ion-item *ngIf="expired">
<ion-label color="danger">{{ 'core.login.recaptchaexpired' | translate }}</ion-label>
</ion-item>
</div>

View File

@ -0,0 +1,14 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'core.login.security_question' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fa-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-iframe [src]="recaptchaUrl" (loaded)="loaded($event)"></core-iframe>
</ion-content>

View File

@ -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<string, string>; // 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<void> {
this.lang = await CoreLang.instance.getCurrentLanguage();
}
/**
* Open the recaptcha modal.
*/
async answerRecaptcha(): Promise<void> {
// 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;
}
}

View File

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

View File

@ -26,9 +26,10 @@ describe('CoreIconComponent', () => {
expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0); expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0);
const icon = fixture.nativeElement.querySelector('ion-icon'); const icon = fixture.nativeElement.querySelector('ion-icon');
const name = icon.getAttribute('name') || icon.getAttribute('ng-reflect-name') || '';
expect(icon).not.toBeNull(); expect(icon).not.toBeNull();
expect(icon.classList.contains('fa')).toBe(true); expect(name).toEqual('fa-thumbs-up');
expect(icon.classList.contains('fa-thumbs-up')).toBe(true);
expect(icon.getAttribute('role')).toEqual('presentation'); expect(icon.getAttribute('role')).toEqual('presentation');
}); });

View File

@ -0,0 +1,76 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'core.login.help' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeHelp()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fa-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-item class="ion-text-wrap">
<ion-label>
<h2><b>{{ 'core.login.faqcannotfindmysitequestion' | translate }}</b></h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'core.login.faqcannotfindmysiteanswer' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2><b>{{ 'core.login.faqwhatisurlquestion' | translate }}</b></h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap core-login-faqwhatisurlanswer">
<ion-label>
<p [innerHTML]="'core.login.faqwhatisurlanswer' | translate: {$image: urlImageHtml}"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2><b>{{ 'core.login.faqcannotconnectquestion' | translate }}</b></h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'core.login.faqcannotconnectanswer' | translate }} {{ 'core.whoissiteadmin' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2><b>{{ 'core.login.faqsetupsitequestion' | translate }}</b></h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p><core-format-text [text]="'core.login.faqsetupsiteanswer' | translate:{$link: setupLinkHtml}" [filter]="false">
</core-format-text></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2><b>{{ 'core.login.faqtestappquestion' | translate }}</b></h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'core.login.faqtestappanswer' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="canScanQR">
<ion-label>
<h2><b>{{ 'core.login.faqwhereisqrcode' | translate }}</b></h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="canScanQR" class="core-login-faqwhereisqrcodeanswer">
<ion-label>
<p [innerHTML]="'core.login.faqwhereisqrcodeanswer' | translate: {$image: qrCodeImageHtml}"></p>
</ion-label>
</ion-item>
</ion-list>
</ion-content>

View File

@ -0,0 +1,9 @@
.core-login-faqwhatisurlanswer img {
max-height: 50px;
}
.core-login-faqwhereisqrcodeanswer img {
max-height: 220px;
margin-top: 5px;
margin-bottom: 5px;
}

View File

@ -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 = '<a href="https://moodle.com/getstarted/" title="' +
Translate.instance.instant('core.login.faqsetupsitelinktitle') + '">https://moodle.com/getstarted/</a>';
}
/**
* Close help modal.
*/
closeHelp(): void {
ModalController.instance.dismiss();
}
}

View File

@ -0,0 +1,59 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="previous($event)" [attr.aria-label]="'core.back' | translate">
<ion-icon slot="icon-only" name="fa-arrow-left"></ion-icon>
</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button (click)="skip($event)" [attr.aria-label]="'core.skip' | translate">
{{'core.skip' | translate}}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div>
<div class="ion-text-center ion-padding core-login-site-logo">
<img src="assets/img/login_logo.png" class="avatar-full login-logo" role="presentation">
</div>
<h3 class="core-login-onboarding-step">
{{'core.login.onboardingwelcome' | translate}}
</h3>
<div *ngIf="step == 0" class="core-login-onboarding-step">
<ion-button expand="block" (click)="skip($event)" class="ion-margin-bottom" color="light">
{{'core.login.onboardingimalearner' | translate}}
</ion-button>
<ion-button expand="block" (click)="next($event)" class="ion-margin-bottom" color="light">
{{'core.login.onboardingimaneducator' | translate}}
</ion-button>
</div>
<div *ngIf="step == 1" class="core-login-onboarding-step">
<p class="core-login-onboarding-text">
{{ 'core.login.onboardingtoconnect' | translate }}
</p>
<ion-button expand="block" (click)="skip($event)" class="ion-margin-bottom" color="light">
{{ 'core.login.onboardingialreadyhaveasite' | translate }}
</ion-button>
<ion-button expand="block" (click)="next($event)" class="ion-margin-bottom">
{{ 'core.login.onboardingineedasite' | translate }}
</ion-button>
</div>
<div *ngIf="step == 2" class="core-login-onboarding-step">
<ul class="core-login-onboarding-text">
<li><ion-icon name="far-check-circle"></ion-icon> {{ 'core.login.onboardingcreatemanagecourses' | translate }}</li>
<li><ion-icon name="far-check-circle"></ion-icon> {{ 'core.login.onboardingenrolmanagestudents' | translate }}</li>
<li><ion-icon name="far-check-circle"></ion-icon> {{ 'core.login.onboardingprovidefeedback' | translate }}</li>
</ul>
<ion-button expand="block" (click)="gotoWeb($event)" class="ion-margin-bottom">
{{ 'core.login.onboardinggetstarted' | translate }}
</ion-button>
</div>
</div>
</ion-content>

View File

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

View File

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

View File

@ -36,7 +36,7 @@
"faqsetupsitequestion": "I want to set up my own Moodle site.", "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.", "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?", "faqtestappquestion": "I just want to test the app, what can I do?",
"faqwhatisurlanswer": "<p>Every organisation has their own unique address or URL for their Moodle site. To find the address:</p><ol><li>Open a web browser and go to your Moodle site login page.</li><li>At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"</li><li>Now you can log in to your site using your username and password.</li>", "faqwhatisurlanswer": "<p>Every organisation has their own unique address or URL for their Moodle site. To find the address:</p><ol><li>Open a web browser and go to your Moodle site login page.</li><li>At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"</li><li>Now you can log in to your site using your username and password.</li></ol>",
"faqwhatisurlquestion": "What is my site address? How can I find my site URL?", "faqwhatisurlquestion": "What is my site address? How can I find my site URL?",
"faqwhereisqrcode": "Where can I find the QR code?", "faqwhereisqrcode": "Where can I find the QR code?",
"faqwhereisqrcodeanswer": "<p>If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$image}}", "faqwhereisqrcodeanswer": "<p>If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$image}}",

View File

@ -37,6 +37,24 @@ const routes: Routes = [
path: 'sites', path: 'sites',
loadChildren: () => import('./pages/sites/sites.page.module').then( m => m.CoreLoginSitesPageModule), 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({ @NgModule({

View File

@ -13,12 +13,32 @@
// limitations under the License. // limitations under the License.
import { NgModule } from '@angular/core'; 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 { CoreLoginRoutingModule } from './login-routing.module';
import { CoreLoginSiteHelpComponent } from './components/site-help/site-help';
import { CoreLoginSiteOnboardingComponent } from './components/site-onboarding/site-onboarding';
@NgModule({ @NgModule({
imports: [ imports: [
CoreLoginRoutingModule, CoreLoginRoutingModule,
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
],
declarations: [
CoreLoginSiteHelpComponent,
CoreLoginSiteOnboardingComponent,
],
exports: [
CoreLoginSiteHelpComponent,
CoreLoginSiteOnboardingComponent,
], ],
declarations: [],
}) })
export class CoreLoginModule {} export class CoreLoginModule {}

View File

@ -21,4 +21,8 @@
max-width: 300px; max-width: 300px;
margin: 5px auto; margin: 5px auto;
} }
.core-login-forgotten-password {
text-decoration: underline;
}
} }

View File

@ -0,0 +1,48 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.login.changepassword' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="showHelp()" [attr.aria-label]="'core.help' | translate">
<ion-icon slot="icon-only" name="far-question-circle"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ng-container *ngIf="!changingPassword">
<ion-item class="ion-text-wrap" lines="none">
<ion-label>
<h2>{{ 'core.login.forcepasswordchangenotice' | translate }}</h2>
<p class="ion-padding-top">{{ 'core.login.changepasswordinstructions' | translate }}</p>
</ion-label>
</ion-item>
<ion-button class="ion-text-wrap ion-margin" expand="block" (click)="openChangePasswordPage()">
{{ 'core.login.changepasswordbutton' | translate }}
</ion-button>
</ng-container>
<ng-container *ngIf="changingPassword">
<ion-item class="ion-text-wrap" lines="none">
<ion-label>
<p>{{ 'core.login.changepasswordreconnectinstructions' | translate }}</p>
</ion-label>
</ion-item>
<ion-button class="ion-text-wrap ion-margin" expand="block" (click)="login()">
{{ 'core.login.reconnect' | translate }}
</ion-button>
</ng-container>
<ion-item class="ion-text-wrap" lines="none">
<ion-label>
<p>{{ 'core.login.changepasswordlogoutinstructions' | translate }}</p>
</ion-label>
</ion-item>
<ion-button class="ion-text-wrap ion-margin" expand="block" color="light" (click)="logout()">
{{ logoutLabel | translate }}
</ion-button>
</ion-list>
</ion-content>

View File

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

View File

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

View File

@ -14,65 +14,79 @@
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content padding> <ion-content>
<core-loading [hideUntil]="pageLoaded"> <core-loading [hideUntil]="pageLoaded">
<div text-wrap text-center margin-bottom> <div class="ion-text-wrap ion-text-center ion-margin-bottom">
<div class="core-login-site-logo"> <div class="core-login-site-logo">
<!-- Show site logo or a default image. --> <!-- Show site logo or a default image. -->
<img *ngIf="logoUrl" [src]="logoUrl" role="presentation" onError="this.src='assets/img/login_logo.png'"> <img *ngIf="logoUrl" [src]="logoUrl" role="presentation" onError="this.src='assets/img/login_logo.png'">
<img *ngIf="!logoUrl" src="assets/img/login_logo.png" role="presentation"> <img *ngIf="!logoUrl" src="assets/img/login_logo.png" role="presentation">
</div> </div>
<h3 *ngIf="siteName" padding class="core-sitename"><core-format-text [text]="siteName" [filter]="false"></core-format-text></h3> <h3 *ngIf="siteName" class="ion-padding core-sitename">
<core-format-text [text]="siteName" [filter]="false"></core-format-text>
</h3>
<p class="core-siteurl">{{siteUrl}}</p> <p class="core-siteurl">{{siteUrl}}</p>
</div> </div>
<form ion-list [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm> <form ion-list [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm>
<ion-item *ngIf="siteChecked && !isBrowserSSO"> <ion-item *ngIf="siteChecked && !isBrowserSSO">
<ion-input type="text" name="username" placeholder="{{ 'core.login.username' | translate }}" formControlName="username" autocapitalize="none" autocorrect="off"></ion-input> <ion-input type="text" name="username" placeholder="{{ 'core.login.username' | translate }}"
formControlName="username" autocapitalize="none" autocorrect="off"></ion-input>
</ion-item> </ion-item>
<ion-item *ngIf="siteChecked && !isBrowserSSO" margin-bottom> <ion-item *ngIf="siteChecked && !isBrowserSSO" class="ion-margin-bottom">
<core-show-password item-content [name]="'password'"> <core-show-password [name]="'password'">
<ion-input class="core-ioninput-password" name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" formControlName="password" core-show-password [clearOnEdit]="false"></ion-input> <ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
formControlName="password" [clearOnEdit]="false" autocomplete="current-password" enterkeyhint="go"
required="true">
</ion-input>
</core-show-password> </core-show-password>
</ion-item> </ion-item>
<div padding> <ion-button expand="block" type="submit" [disabled]="siteChecked && !isBrowserSSO && !credForm.valid"
<ion-button block type="submit" [disabled]="siteChecked && !isBrowserSSO && !credForm.valid" class="core-login-login-button">{{ 'core.login.loginbutton' | translate }}</ion-button> class="ion-margin core-login-login-button">
<input type="submit" className="core-submit-enter" /> <!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 --> {{ 'core.login.loginbutton' | translate }}
</div> </ion-button>
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
<input type="submit" class="core-submit-hidden-enter" />
<ng-container *ngIf="showScanQR"> <ng-container *ngIf="showScanQR">
<div class="core-login-site-qrcode-separator">{{ 'core.login.or' | translate }}</div> <div class="ion-text-center ion-padding">{{ 'core.login.or' | translate }}</div>
<ion-item class="core-login-site-qrcode" no-lines> <ion-button expand="block" color="light" class="ion-margin" lines="none" (click)="showInstructionsAndScanQR()">
<ion-button block color="light" margin-top icon-start text-wrap (click)="showInstructionsAndScanQR()"> <ion-icon slot="start" name="fa-qrcode" aria-hidden="true"></ion-icon>
<ion-icon name="fa-qrcode" aria-hidden="true"></ion-icon> <ion-label>{{ 'core.scanqr' | translate }}</ion-label>
{{ 'core.scanqr' | translate }}
</ion-button> </ion-button>
</ion-item>
</ng-container> </ng-container>
</form> </form>
<!-- Forgotten password button. --> <!-- Forgotten password option. -->
<ion-list no-lines *ngIf="showForgottenPassword" class="core-login-forgotten-password"> <ion-list lines="none" *ngIf="showForgottenPassword" class="core-login-forgotten-password ion-no-padding">
<ion-item text-center text-wrap (click)="forgottenPassword()" detail-none> <ion-item button class="ion-text-center ion-text-wrap" (click)="forgottenPassword()" detail="false">
{{ 'core.login.forgotten' | translate }} <ion-label>{{ 'core.login.forgotten' | translate }}</ion-label>
</ion-item> </ion-item>
</ion-list> </ion-list>
<ion-list *ngIf="identityProviders && identityProviders.length" padding-top class="core-login-identity-providers"> <ion-list *ngIf="identityProviders && identityProviders.length" class="ion-padding-top core-login-identity-providers">
<ion-item text-wrap no-lines><h3 class="item-heading">{{ 'core.login.potentialidps' | translate }}</h3></ion-item> <ion-item class="ion-text-wrap" lines="none">
<ion-item *ngFor="let provider of identityProviders" text-wrap class="core-oauth-icon" (click)="oauthClicked(provider)" title="{{provider.name}}"> <ion-label><h3 class="item-heading">{{ 'core.login.potentialidps' | translate }}</h3></ion-label>
<img [src]="provider.iconurl" alt="" width="32" height="32" item-start> </ion-item>
{{provider.name}} <ion-item button *ngFor="let provider of identityProviders" class="ion-text-wrap core-oauth-icon"
(click)="oauthClicked(provider)" title="{{provider.name}}">
<img [src]="provider.iconurl" alt="" width="32" height="32" slot="start">
<ion-label>{{provider.name}}</ion-label>
</ion-item> </ion-item>
</ion-list> </ion-list>
<ion-list *ngIf="canSignup" padding-top class="core-login-sign-up"> <ion-list *ngIf="canSignup" class="ion-padding-top core-login-sign-up">
<ion-item text-wrap no-lines><h3 class="item-heading">{{ 'core.login.firsttime' | translate }}</h3></ion-item> <ion-item class="ion-text-wrap" lines="none">
<ion-item no-lines text-wrap *ngIf="authInstructions"> <ion-label><h3 class="item-heading">{{ 'core.login.firsttime' | translate }}</h3></ion-label>
<p><core-format-text [text]="authInstructions" [filter]="false"></core-format-text></p>
</ion-item> </ion-item>
<ion-button block color="light" (onClick)="signup()">{{ 'core.login.startsignup' | translate }}</ion-button> <ion-item class="ion-text-wrap" lines="none" *ngIf="authInstructions">
<ion-label><p><core-format-text [text]="authInstructions" [filter]="false"></core-format-text></p></ion-label>
</ion-item>
<ion-button expand="block" class="ion-margin" color="light" router-direction="forward" routerLink="/login/emailsignup"
[queryParams]="{siteUrl: siteUrl}">
{{ 'core.login.startsignup' | translate }}
</ion-button>
</ion-list> </ion-list>
</core-loading> </core-loading>
</ion-content> </ion-content>

View File

@ -33,6 +33,7 @@ import { CoreEvents } from '@singletons/events';
@Component({ @Component({
selector: 'page-core-login-credentials', selector: 'page-core-login-credentials',
templateUrl: 'credentials.html', templateUrl: 'credentials.html',
styleUrls: ['../../login.scss'],
}) })
export class CoreLoginCredentialsPage implements OnInit, OnDestroy { export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
@ -264,12 +265,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
* Forgotten password button clicked. * Forgotten password button clicked.
*/ */
forgottenPassword(): void { forgottenPassword(): void {
CoreLoginHelper.instance.forgottenPasswordClicked( CoreLoginHelper.instance.forgottenPasswordClicked(this.siteUrl, this.credForm.value.username, this.siteConfig);
this.navCtrl,
this.siteUrl,
this.credForm.value.username,
this.siteConfig,
);
} }
/** /**
@ -283,13 +279,6 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
} }
} }
/**
* Signup button was clicked.
*/
signup(): void {
// @todo Go to signup.
}
/** /**
* Show instructions and scan QR code. * Show instructions and scan QR code.
*/ */

View File

@ -0,0 +1,238 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.login.newaccount' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button *ngIf="authInstructions" (click)="showAuthInstructions()"
[attr.aria-label]="'core.login.instructions' | translate">
<ion-icon slot="icon-only" name="far-question-circle"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!settingsLoaded || isMinor" (ionRefresh)="refreshSettings($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="settingsLoaded" *ngIf="!isMinor">
<!-- Site has an unsupported required field. -->
<ion-list *ngIf="!allRequiredSupported">
<ion-item class="ion-text-wrap">
<ion-label>
{{ 'core.login.signuprequiredfieldnotsupported' | translate }}
</ion-label>
</ion-item>
<ion-button expand="block" class="ion-margin" [href]="signupUrl" core-link autoLogin="no">
{{ 'core.openinbrowser' | translate }}
</ion-button>
</ion-list>
<!-- Age verification. -->
<form ion-list *ngIf="allRequiredSupported && settingsLoaded && settings && ageDigitalConsentVerification"
[formGroup]="ageVerificationForm" (ngSubmit)="verifyAge($event)" #ageForm>
<ion-item-divider class="ion-text-wrap">
<ion-label><h3>{{ 'core.agelocationverification' | translate }}</h3></ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<span core-mark-required="true">{{ 'core.whatisyourage' | translate }}</span>
</ion-label>
<ion-input type="number" name="age" placeholder="0" formControlName="age" autocapitalize="none" autocorrect="off">
</ion-input>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<span core-mark-required="true">{{ 'core.wheredoyoulive' | translate }}</span>
</ion-label>
<ion-select name="country" formControlName="country" [placeholder]="'core.login.selectacountry' | translate">
<ion-select-option value="">{{ 'core.login.selectacountry' | translate }}</ion-select-option>
<ion-select-option *ngFor="let country of countries" [value]="country.code">{{country.name}}</ion-select-option>
</ion-select>
</ion-item>
<!-- Submit button. -->
<ion-button expand="block" class="ion-margin" type="submit" [disabled]="!ageVerificationForm.valid">
{{ 'core.proceed' | translate }}
</ion-button>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'core.whyisthisrequired' | translate }}</p>
<p>{{ 'core.explanationdigitalminor' | translate }}</p>
</ion-label>
</ion-item>
</form>
<!-- Signup form. -->
<form ion-list *ngIf="allRequiredSupported && settingsLoaded && settings && !ageDigitalConsentVerification"
[formGroup]="signupForm" (ngSubmit)="create($event)" #signupFormEl>
<ion-item class="ion-text-wrap ion-text-center">
<ion-label>
<!-- If no sitename show big siteurl. -->
<p *ngIf="!siteName" class="ion-padding item-heading">{{siteUrl}}</p>
<!-- If sitename, show big sitename and small siteurl. -->
<p *ngIf="siteName" class="ion-padding item-heading">
<core-format-text [text]="siteName" [filter]="false"></core-format-text>
</p>
<p *ngIf="siteName">{{siteUrl}}</p>
</ion-label>
</ion-item>
<!-- Username and password. -->
<ion-item-divider class="ion-text-wrap">
<ion-label>{{ 'core.login.createuserandpass' | translate }}</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<span core-mark-required="true">{{ 'core.login.username' | translate }}</span>
</ion-label>
<ion-input type="text" name="username" placeholder="{{ 'core.login.username' | translate }}"
formControlName="username" autocapitalize="none" autocorrect="off">
</ion-input>
<core-input-errors [control]="signupForm.controls.username" [errorMessages]="usernameErrors"></core-input-errors>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<span core-mark-required="true">{{ 'core.login.password' | translate }}</span>
</ion-label>
<core-show-password [name]="'password'">
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
formControlName="password" [clearOnEdit]="false" autocomplete="new-password" required="true">
</ion-input>
</core-show-password>
<p *ngIf="settings.passwordpolicy" class="core-input-footnote">
{{settings.passwordpolicy}}
</p>
<core-input-errors [control]="signupForm.controls.password" [errorMessages]="passwordErrors"></core-input-errors>
</ion-item>
<!-- More details. -->
<ion-item-divider class="ion-text-wrap">
<ion-label>
{{ 'core.login.supplyinfo' | translate }}
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<span core-mark-required="true">{{ 'core.user.email' | translate }}</span>
</ion-label>
<ion-input type="email" name="email" placeholder="{{ 'core.user.email' | translate }}" formControlName="email"
autocapitalize="none" autocorrect="off">
</ion-input>
<core-input-errors [control]="signupForm.controls.email" [errorMessages]="emailErrors"></core-input-errors>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">
<span core-mark-required="true">{{ 'core.user.emailagain' | translate }}</span>
</ion-label>
<ion-input type="email" name="email2" placeholder="{{ 'core.user.emailagain' | translate }}" autocapitalize="none"
formControlName="email2" autocorrect="off" [pattern]="escapeMail(signupForm.controls.email.value)">
</ion-input>
<core-input-errors [control]="signupForm.controls.email2" [errorMessages]="email2Errors"></core-input-errors>
</ion-item>
<ion-item *ngFor="let nameField of settings.namefields" class="ion-text-wrap">
<ion-label position="stacked">
<span core-mark-required="true">{{ 'core.user.' + nameField | translate }}</span>
</ion-label>
<ion-input type="text" name="nameField" placeholder="{{ 'core.user.' + nameField | translate }}"
formControlName="{{nameField}}" autocorrect="off">
</ion-input>
<core-input-errors [control]="signupForm.controls[nameField]" [errorMessages]="namefieldsErrors[nameField]">
</core-input-errors>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">{{ 'core.user.city' | translate }}</ion-label>
<ion-input type="text" name="city" placeholder="{{ 'core.user.city' | translate }}" formControlName="city"
autocorrect="off">
</ion-input>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label position="stacked" id="core-login-signup-country">{{ 'core.user.country' | translate }}</ion-label>
<ion-select name="country" formControlName="country" aria-labelledby="core-login-signup-country"
[placeholder]="'core.login.selectacountry' | translate">
<ion-select-option value="">{{ 'core.login.selectacountry' | translate }}</ion-select-option>
<ion-select-option *ngFor="let country of countries" [value]="country.code">{{country.name}}</ion-select-option>
</ion-select>
</ion-item>
<!-- Other categories. -->
<ng-container *ngFor="let category of categories">
<ion-item-divider class="ion-text-wrap">
<ion-label>{{ category.name }}</ion-label>
</ion-item-divider>
<!-- @todo <core-user-profile-field *ngFor="let field of category.fields" [field]="field" edit="true" signup="true"
registerAuth="email" [form]="signupForm"></core-user-profile-field> -->
</ng-container>
<!-- ReCAPTCHA -->
<ng-container *ngIf="settings.recaptchapublickey">
<ion-item-divider class="ion-text-wrap">
<ion-label>
<span [core-mark-required]="true">{{ 'core.login.security_question' | translate }}</span>
</ion-label>
</ion-item-divider>
<core-recaptcha [publicKey]="settings.recaptchapublickey" [model]="captcha" [siteUrl]="siteUrl"></core-recaptcha>
</ng-container>
<!-- Site policy (if any). -->
<ng-container *ngIf="settings.sitepolicy">
<ion-item-divider class="ion-text-wrap">
<ion-label>{{ 'core.login.policyagreement' | translate }}</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>
<a [href]="settings.sitepolicy" core-link capture="false">
{{ 'core.login.policyagreementclick' | translate }}
</a>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<span [core-mark-required]="true">{{ 'core.login.policyaccept' | translate }}</span>
<core-input-errors [control]="signupForm.controls.policyagreed" [errorMessages]="policyErrors">
</core-input-errors>
</ion-label>
<ion-checkbox slot="end" name="policyagreed" formControlName="policyagreed"></ion-checkbox>
</ion-item>
</ng-container>
<!-- Submit button. -->
<ion-button expand="block" class="ion-margin" type="submit">{{ 'core.login.createaccount' | translate }}</ion-button>
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
<input type="submit" class="core-submit-hidden-enter" />
</form>
</core-loading>
<ion-list *ngIf="allRequiredSupported && isMinor">
<ion-item-divider class="ion-text-wrap">
<ion-label>
<p *ngIf="siteName" class="item-heading ion-padding">
<core-format-text [text]="siteName" [filter]="false"></core-format-text>
</p>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" lines="none">
<ion-label>
<p class="item-heading">{{ 'core.considereddigitalminor' | translate }}</p>
<p>{{ 'core.digitalminor_desc' | translate }}</p>
<p *ngIf="supportName">{{ supportName }}</p>
<p *ngIf="supportEmail">{{ supportEmail }}</p>
</ion-label>
</ion-item>
<ion-button *ngIf="!supportName && !supportEmail" expand="block" class="ion-margin" (click)="showContactOnSite()">
{{ 'core.openinbrowser' | translate }}
</ion-button>
</ion-list>
</ion-content>

View File

@ -0,0 +1,50 @@
// (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 { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule, Routes } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreLoginEmailSignupPage } from './email-signup.page';
const routes: Routes = [
{
path: '',
component: CoreLoginEmailSignupPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
FormsModule,
ReactiveFormsModule,
CoreComponentsModule,
CoreDirectivesModule,
],
declarations: [
CoreLoginEmailSignupPage,
],
exports: [RouterModule],
})
export class CoreLoginEmailSignupPageModule {}

View File

@ -0,0 +1,419 @@
// (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, ViewChild, ElementRef, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { NavController, IonContent, IonRefresher } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreCountry, CoreUtils } from '@services/utils/utils';
import { CoreWS, CoreWSExternalWarning } from '@services/ws';
import { AuthEmailSignupProfileFieldsCategory, AuthEmailSignupSettings, CoreLoginHelper } from '@core/login/services/helper';
import { CoreConstants } from '@core/constants';
import { Translate } from '@singletons/core.singletons';
import { CoreSitePublicConfigResponse } from '@classes/site';
/**
* Page to signup using email.
*/
@Component({
selector: 'page-core-login-email-signup',
templateUrl: 'email-signup.html',
styleUrls: ['../../login.scss'],
})
export class CoreLoginEmailSignupPage implements OnInit {
@ViewChild(IonContent) content?: IonContent;
@ViewChild('ageForm') ageFormElement?: ElementRef;
@ViewChild('signupFormEl') signupFormElement?: ElementRef;
signupForm: FormGroup;
siteUrl!: string;
siteConfig?: CoreSitePublicConfigResponse;
siteName?: string;
authInstructions?: string;
settings?: AuthEmailSignupSettings;
countries?: CoreCountry[];
categories?: AuthEmailSignupProfileFieldsCategory[];
settingsLoaded = false;
allRequiredSupported = true;
signupUrl?: string;
captcha = {
recaptcharesponse: '',
};
// Data for age verification.
ageVerificationForm: FormGroup;
countryControl: FormControl;
signUpCountryControl?: FormControl;
isMinor = false; // Whether the user is minor age.
ageDigitalConsentVerification?: boolean; // Whether the age verification is enabled.
supportName?: string;
supportEmail?: string;
// Validation errors.
usernameErrors: Record<string, string>;
passwordErrors: Record<string, string>;
emailErrors: Record<string, string>;
email2Errors: Record<string, string>;
policyErrors: Record<string, string>;
namefieldsErrors?: Record<string, Record<string, string>>;
constructor(
protected navCtrl: NavController,
protected fb: FormBuilder,
protected route: ActivatedRoute,
) {
// Create the ageVerificationForm.
this.ageVerificationForm = this.fb.group({
age: ['', Validators.required],
});
this.countryControl = this.fb.control('', Validators.required);
this.ageVerificationForm.addControl('country', this.countryControl);
// Create the signupForm with the basic controls. More controls will be added later.
this.signupForm = this.fb.group({
username: ['', Validators.required],
password: ['', Validators.required],
email: ['', Validators.compose([Validators.required, Validators.email])],
email2: ['', Validators.compose([Validators.required, Validators.email])],
});
// Setup validation errors.
this.usernameErrors = CoreLoginHelper.instance.getErrorMessages('core.login.usernamerequired');
this.passwordErrors = CoreLoginHelper.instance.getErrorMessages('core.login.passwordrequired');
this.emailErrors = CoreLoginHelper.instance.getErrorMessages('core.login.missingemail');
this.policyErrors = CoreLoginHelper.instance.getErrorMessages('core.login.policyagree');
this.email2Errors = CoreLoginHelper.instance.getErrorMessages(
'core.login.missingemail',
undefined,
'core.login.emailnotmatch',
);
}
/**
* Component initialized.
*/
ngOnInit(): void {
this.siteUrl = this.route.snapshot.queryParams['siteUrl'];
// Fetch the data.
this.fetchData().finally(() => {
this.settingsLoaded = true;
});
}
/**
* Complete the FormGroup using the settings received from server.
*/
protected completeFormGroup(): void {
this.signupForm.addControl('city', this.fb.control(this.settings?.defaultcity || ''));
this.signUpCountryControl = this.fb.control(this.settings?.country || '');
this.signupForm.addControl('country', this.signUpCountryControl);
// Add the name fields.
for (const i in this.settings?.namefields) {
this.signupForm.addControl(this.settings?.namefields[i], this.fb.control('', Validators.required));
}
if (this.settings?.sitepolicy) {
this.signupForm.addControl('policyagreed', this.fb.control(false, Validators.requiredTrue));
}
}
/**
* Fetch the required data from the server.
*
* @return Promise resolved when done.
*/
protected async fetchData(): Promise<void> {
try {
// Get site config.
this.siteConfig = await CoreSites.instance.getSitePublicConfig(this.siteUrl);
this.signupUrl = CoreTextUtils.instance.concatenatePaths(this.siteConfig.httpswwwroot, 'login/signup.php');
if (this.treatSiteConfig()) {
// Check content verification.
if (typeof this.ageDigitalConsentVerification == 'undefined') {
const result = await CoreUtils.instance.ignoreErrors(
CoreWS.instance.callAjax<IsAgeVerificationEnabledResponse>(
'core_auth_is_age_digital_consent_verification_enabled',
{},
{ siteUrl: this.siteUrl },
),
);
this.ageDigitalConsentVerification = !!result?.status;
}
await this.getSignupSettings();
}
this.completeFormGroup();
} catch (error) {
if (this.allRequiredSupported) {
CoreDomUtils.instance.showErrorModal(error);
}
}
}
/**
* Get signup settings from server.
*
* @return Promise resolved when done.
*/
protected async getSignupSettings(): Promise<void> {
this.settings = await CoreWS.instance.callAjax<AuthEmailSignupSettings>(
'auth_email_get_signup_settings',
{},
{ siteUrl: this.siteUrl },
);
// @todo userProfileFieldDelegate
this.categories = CoreLoginHelper.instance.formatProfileFieldsForSignup(this.settings.profilefields);
if (this.settings.recaptchapublickey) {
this.captcha.recaptcharesponse = ''; // Reset captcha.
}
if (!this.countryControl.value) {
this.countryControl.setValue(this.settings.country || '');
}
this.namefieldsErrors = {};
if (this.settings.namefields) {
this.settings.namefields.forEach((field) => {
this.namefieldsErrors![field] = CoreLoginHelper.instance.getErrorMessages('core.login.missing' + field);
});
}
this.countries = await CoreUtils.instance.getCountryListSorted();
}
/**
* Treat the site config, checking if it's valid and extracting the data we're interested in.
*
* @return True if success.
*/
protected treatSiteConfig(): boolean {
if (this.siteConfig?.registerauth == 'email' && !CoreLoginHelper.instance.isEmailSignupDisabled(this.siteConfig)) {
this.siteName = CoreConstants.CONFIG.sitename ? CoreConstants.CONFIG.sitename : this.siteConfig.sitename;
this.authInstructions = this.siteConfig.authinstructions;
this.ageDigitalConsentVerification = this.siteConfig.agedigitalconsentverification;
this.supportName = this.siteConfig.supportname;
this.supportEmail = this.siteConfig.supportemail;
this.countryControl.setValue(this.siteConfig.country || '');
return true;
} else {
CoreDomUtils.instance.showErrorModal(
Translate.instance.instant(
'core.login.signupplugindisabled',
{ $a: Translate.instance.instant('core.login.auth_email') },
),
);
this.navCtrl.pop();
return false;
}
}
/**
* Pull to refresh.
*
* @param event Event.
*/
refreshSettings(event?: CustomEvent<IonRefresher>): void {
this.fetchData().finally(() => {
event?.detail.complete();
});
}
/**
* Create account.
*
* @param e Event.
* @return Promise resolved when done.
*/
async create(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
if (!this.signupForm.valid || (this.settings?.recaptchapublickey && !this.captcha.recaptcharesponse)) {
// Form not valid. Scroll to the first element with errors.
const errorFound = await CoreDomUtils.instance.scrollToInputError(this.content);
if (!errorFound) {
// Input not found, show an error modal.
CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true);
}
return;
}
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
const params: Record<string, unknown> = {
username: this.signupForm.value.username.trim().toLowerCase(),
password: this.signupForm.value.password,
firstname: CoreTextUtils.instance.cleanTags(this.signupForm.value.firstname),
lastname: CoreTextUtils.instance.cleanTags(this.signupForm.value.lastname),
email: this.signupForm.value.email.trim(),
city: CoreTextUtils.instance.cleanTags(this.signupForm.value.city),
country: this.signupForm.value.country,
};
if (this.siteConfig?.launchurl) {
const service = CoreSites.instance.determineService(this.siteUrl);
params.redirect = CoreLoginHelper.instance.prepareForSSOLogin(this.siteUrl, service, this.siteConfig.launchurl);
}
// Get the recaptcha response (if needed).
if (this.settings?.recaptchapublickey && this.captcha.recaptcharesponse) {
params.recaptcharesponse = this.captcha.recaptcharesponse;
}
try {
// @todo Get the data for the custom profile fields.
const result = await CoreWS.instance.callAjax<SignupUserResult>(
'auth_email_signup_user',
params,
{ siteUrl: this.siteUrl },
);
if (result.success) {
CoreDomUtils.instance.triggerFormSubmittedEvent(this.signupFormElement, true);
// Show alert and ho back.
const message = Translate.instance.instant('core.login.emailconfirmsent', { $a: params.email });
CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), message);
this.navCtrl.pop();
} else {
if (result.warnings && result.warnings.length) {
let error = result.warnings[0].message;
if (error == 'incorrect-captcha-sol') {
error = Translate.instance.instant('core.login.recaptchaincorrect');
}
CoreDomUtils.instance.showErrorModal(error);
} else {
CoreDomUtils.instance.showErrorModal('core.login.usernotaddederror', true);
}
}
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.login.usernotaddederror', true);
} finally {
modal.dismiss();
}
}
/**
* Escape mail to avoid special characters to be treated as a RegExp.
*
* @param text Initial mail.
* @return Escaped mail.
*/
escapeMail(text: string): string {
return CoreTextUtils.instance.escapeForRegex(text);
}
/**
* Show authentication instructions.
*/
protected showAuthInstructions(): void {
CoreTextUtils.instance.viewText(Translate.instance.instant('core.login.instructions'), this.authInstructions!);
}
/**
* Show contact information on site (we have to display again the age verification form).
*/
showContactOnSite(): void {
CoreUtils.instance.openInBrowser(CoreTextUtils.instance.concatenatePaths(this.siteUrl, '/login/verify_age_location.php'));
}
/**
* Verify Age.
*
* @param e Event.
* @return Promise resolved when done.
*/
async verifyAge(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
if (!this.ageVerificationForm.valid) {
CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true);
return;
}
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
const params = this.ageVerificationForm.value;
params.age = parseInt(params.age, 10); // Use just the integer part.
try {
const result = await CoreWS.instance.callAjax<IsMinorResult>('core_auth_is_minor', params, { siteUrl: this.siteUrl });
CoreDomUtils.instance.triggerFormSubmittedEvent(this.ageFormElement, true);
if (!result.status) {
if (this.countryControl.value) {
this.signUpCountryControl!.setValue(this.countryControl.value);
}
// Not a minor, go ahead.
this.ageDigitalConsentVerification = false;
} else {
// Is a minor.
this.isMinor = true;
}
} catch (error) {
// Something wrong, redirect to the site.
CoreDomUtils.instance.showErrorModal('There was an error verifying your age, please try again using the browser.');
} finally {
modal.dismiss();
}
}
}
/**
* Result of WS core_auth_is_age_digital_consent_verification_enabled.
*/
export type IsAgeVerificationEnabledResponse = {
status: boolean; // True if digital consent verification is enabled, false otherwise.
};
/**
* Result of WS auth_email_signup_user.
*/
export type SignupUserResult = {
success: boolean; // True if the user was created false otherwise.
warnings?: CoreWSExternalWarning[];
};
/**
* Result of WS core_auth_is_minor.
*/
export type IsMinorResult = {
status: boolean; // True if the user is considered to be a digital minor, false if not.
};

View File

@ -0,0 +1,41 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.login.passwordforgotten' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list lines="none">
<ion-item class="ion-text-wrap">
{{ 'core.login.passwordforgotteninstructions2' | translate }}
</ion-item>
</ion-list>
<ion-card>
<form ion-list [formGroup]="myForm" (ngSubmit)="resetPassword($event)" #resetPasswordForm>
<ion-item-divider class="ion-text-wrap">
{{ 'core.login.searchby' | translate }}
</ion-item-divider>
<ion-radio-group formControlName="field">
<ion-item>
<ion-label>{{ 'core.login.username' | translate }}</ion-label>
<ion-radio slot="start" value="username"></ion-radio>
</ion-item>
<ion-item>
<ion-label>{{ 'core.user.email' | translate }}</ion-label>
<ion-radio slot="start" value="email"></ion-radio>
</ion-item>
</ion-radio-group>
<ion-item>
<ion-input type="text" name="value" placeholder="{{ 'core.login.usernameoremail' | translate }}"
formControlName="value" autocapitalize="none" autocorrect="off" [core-auto-focus]="autoFocus">
</ion-input>
</ion-item>
<ion-button type="submit" class="ion-margin" expand="block" [disabled]="!myForm.valid">
{{ 'core.courses.search' | translate }}
</ion-button>
</form>
</ion-card>
</ion-content>

View File

@ -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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule, Routes } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreLoginForgottenPasswordPage } from './forgotten-password.page';
const routes: Routes = [
{
path: '',
component: CoreLoginForgottenPasswordPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
FormsModule,
ReactiveFormsModule,
CoreDirectivesModule,
],
declarations: [
CoreLoginForgottenPasswordPage,
],
exports: [RouterModule],
})
export class CoreLoginForgottenPasswordPageModule {}

View File

@ -0,0 +1,120 @@
// (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, ViewChild, ElementRef, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { NavController } from '@ionic/angular';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreLoginHelper } from '@core/login/services/helper';
import { Translate, Platform } from '@singletons/core.singletons';
import { CoreWSExternalWarning } from '@services/ws';
/**
* Page to recover a forgotten password.
*/
@Component({
selector: 'page-core-login-forgotten-password',
templateUrl: 'forgotten-password.html',
})
export class CoreLoginForgottenPasswordPage implements OnInit {
@ViewChild('resetPasswordForm') formElement?: ElementRef;
myForm!: FormGroup;
siteUrl!: string;
autoFocus!: boolean;
constructor(
protected navCtrl: NavController,
protected formBuilder: FormBuilder,
protected route: ActivatedRoute,
) {
}
/**
* Initialize the component.
*/
ngOnInit(): void {
const params = this.route.snapshot.queryParams;
this.siteUrl = params['siteUrl'];
this.autoFocus = Platform.instance.is('tablet');
this.myForm = this.formBuilder.group({
field: ['username', Validators.required],
value: [params['username'] || '', Validators.required],
});
}
/**
* Request to reset the password.
*
* @param e Event.
*/
async resetPassword(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
const field = this.myForm.value.field;
const value = this.myForm.value.value;
if (!value) {
CoreDomUtils.instance.showErrorModal('core.login.usernameoremail', true);
return;
}
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
const isMail = field == 'email';
try {
const response = await CoreLoginHelper.instance.requestPasswordReset(
this.siteUrl,
isMail ? '' : value,
isMail ? value : '',
);
if (response.status == 'dataerror') {
// Error in the data sent.
this.showError(isMail, response.warnings!);
} else if (response.status == 'emailpasswordconfirmnotsent' || response.status == 'emailpasswordconfirmnoemail') {
// Error, not found.
CoreDomUtils.instance.showErrorModal(response.notice);
} else {
// Success.
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true);
CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), response.notice);
this.navCtrl.pop();
}
} catch (error) {
CoreDomUtils.instance.showErrorModal(error);
} finally {
modal.dismiss();
}
}
// Show an error from the warnings.
protected showError(isMail: boolean, warnings: CoreWSExternalWarning[]): void {
for (let i = 0; i < warnings.length; i++) {
const warning = warnings[i];
if ((warning.item == 'email' && isMail) || (warning.item == 'username' && !isMail)) {
CoreDomUtils.instance.showErrorModal(warning.message);
break;
}
}
}
}

View File

@ -88,7 +88,7 @@ export class CoreLoginInitPage implements OnInit {
// Site doesn't exist. // Site doesn't exist.
return this.loadPage(); return this.loadPage();
} }
} else { } else if (redirectData.page) {
// No site to load, open the page. // No site to load, open the page.
return CoreLoginHelper.instance.goToNoSitePage(redirectData.page, redirectData.params); return CoreLoginHelper.instance.goToNoSitePage(redirectData.page, redirectData.params);
} }

View File

@ -0,0 +1,32 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.login.policyagreement' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="policyLoaded">
<ion-list *ngIf="sitePolicy">
<ion-item class="ion-text-wrap">
<ion-label><p>{{ 'core.login.policyagree' | translate }}</p></ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label><p>
<a [href]="sitePolicy" core-link [capture]="false">{{ 'core.login.policyagreementclick' | translate }}</a>
</p></ion-label>
</ion-item>
<ion-card *ngIf="showInline">
<core-iframe [src]="sitePolicy"></core-iframe>
</ion-card>
<ion-button class="ion-text-wrap ion-margin" expand="block" (click)="accept()">
{{ 'core.login.policyaccept' | translate }}
</ion-button>
<ion-button class="ion-text-wrap ion-margin" expand="block" color="light" (click)="cancel()">
{{ 'core.login.cancel' | translate }}
</ion-button>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,46 @@
// (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 { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreLoginSitePolicyPage } from './site-policy.page';
const routes: Routes = [
{
path: '',
component: CoreLoginSitePolicyPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
],
declarations: [
CoreLoginSitePolicyPage,
],
exports: [RouterModule],
})
export class CoreLoginSitePolicyPageModule {}

View File

@ -0,0 +1,139 @@
// (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, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NavController } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreLoginHelper } from '@core/login/services/helper';
import { CoreSite } from '@classes/site';
/**
* Page to accept a site policy.
*/
@Component({
selector: 'page-core-login-site-policy',
templateUrl: 'site-policy.html',
})
export class CoreLoginSitePolicyPage implements OnInit {
sitePolicy?: string;
showInline?: boolean;
policyLoaded?: boolean;
protected siteId?: string;
protected currentSite?: CoreSite;
constructor(
protected navCtrl: NavController,
protected route: ActivatedRoute,
) {
}
/**
* Component initialized.
*/
ngOnInit(): void {
const params = this.route.snapshot.queryParams;
this.siteId = params['siteId'];
this.currentSite = CoreSites.instance.getCurrentSite();
if (!this.currentSite) {
// Not logged in, stop.
this.cancel();
return;
}
const currentSiteId = this.currentSite.id;
this.siteId = this.siteId || currentSiteId;
if (this.siteId != currentSiteId || !this.currentSite.wsAvailable('core_user_agree_site_policy')) {
// Not current site or WS not available, stop.
this.cancel();
return;
}
this.fetchSitePolicy();
}
/**
* Fetch the site policy URL.
*
* @return Promise resolved when done.
*/
protected async fetchSitePolicy(): Promise<void> {
try {
this.sitePolicy = await CoreLoginHelper.instance.getSitePolicy(this.siteId);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting site policy.');
this.cancel();
return;
}
// Try to get the mime type.
try {
const mimeType = await CoreUtils.instance.getMimeTypeFromUrl(this.sitePolicy);
const extension = CoreMimetypeUtils.instance.getExtension(mimeType, this.sitePolicy);
this.showInline = extension == 'html' || extension == 'htm';
} catch (error) {
// Unable to get mime type, assume it's not supported.
this.showInline = false;
} finally {
this.policyLoaded = true;
}
}
/**
* Cancel.
*
* @return Promise resolved when done.
*/
async cancel(): Promise<void> {
await CoreUtils.instance.ignoreErrors(CoreSites.instance.logout());
await this.navCtrl.navigateRoot('/login/sites');
}
/**
* Accept the site policy.
*
* @return Promise resolved when done.
*/
async accept(): Promise<void> {
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
try {
await CoreLoginHelper.instance.acceptSitePolicy(this.siteId);
// Success accepting, go to site initial page.
// Invalidate cache since some WS don't return error if site policy is not accepted.
await CoreUtils.instance.ignoreErrors(this.currentSite!.invalidateWsCache());
await CoreLoginHelper.instance.goToSiteInitialPage();
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error accepting site policy.');
} finally {
modal.dismiss();
}
}
}

View File

@ -7,7 +7,8 @@
<ion-title>{{ 'core.login.connecttomoodle' | translate }}</ion-title> <ion-title>{{ 'core.login.connecttomoodle' | translate }}</ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button router-direction="forward" routerLink="/settings/app" [attr.aria-label]="'core.settings.appsettings' | translate"> <ion-button router-direction="forward" routerLink="/settings/app"
[attr.aria-label]="'core.settings.appsettings' | translate">
<ion-icon slot="icon-only" name="fa-cog"></ion-icon> <ion-icon slot="icon-only" name="fa-cog"></ion-icon>
</ion-button> </ion-button>
</ion-buttons> </ion-buttons>
@ -25,7 +26,9 @@
<ion-label position="stacked"> <ion-label position="stacked">
<h2>{{ 'core.login.siteaddress' | translate }}</h2> <h2>{{ 'core.login.siteaddress' | translate }}</h2>
</ion-label> </ion-label>
<ion-input name="url" type="url" placeholder="{{ 'core.login.siteaddressplaceholder' | translate }}" formControlName="siteUrl" [core-auto-focus]="showKeyboard && !showScanQR"></ion-input> <ion-input name="url" type="url" placeholder="{{ 'core.login.siteaddressplaceholder' | translate }}"
formControlName="siteUrl" [core-auto-focus]="showKeyboard && !showScanQR">
</ion-input>
</ion-item> </ion-item>
</ng-container> </ng-container>
<ng-container *ngIf="siteSelector != 'url'"> <ng-container *ngIf="siteSelector != 'url'">
@ -33,7 +36,9 @@
<ion-label position="stacked"> <ion-label position="stacked">
<h2>{{ 'core.login.siteaddress' | translate }}</h2> <h2>{{ 'core.login.siteaddress' | translate }}</h2>
</ion-label> </ion-label>
<ion-input name="url" placeholder="{{ 'core.login.siteaddressplaceholder' | translate }}" formControlName="siteUrl" [core-auto-focus]="showKeyboard && !showScanQR" (ionChange)="searchSite($event, siteForm.value.siteUrl)"></ion-input> <ion-input name="url" placeholder="{{ 'core.login.siteaddressplaceholder' | translate }}" formControlName="siteUrl"
[core-auto-focus]="showKeyboard && !showScanQR" (ionChange)="searchSite($event, siteForm.value.siteUrl)">
</ion-input>
</ion-item> </ion-item>
<ion-list [class.hidden]="!hasSites && !enteredSiteUrl" class="core-login-site-list"> <ion-list [class.hidden]="!hasSites && !enteredSiteUrl" class="core-login-site-list">
@ -42,7 +47,8 @@
<h2 class="item-heading">{{ 'core.login.selectsite' | translate }}</h2> <h2 class="item-heading">{{ 'core.login.selectsite' | translate }}</h2>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item *ngIf="enteredSiteUrl" (click)="connect($event, enteredSiteUrl.url)" [attr.aria-label]="'core.login.connect' | translate" detail-push class="core-login-entered-site"> <ion-item button *ngIf="enteredSiteUrl" (click)="connect($event, enteredSiteUrl.url)"
[attr.aria-label]="'core.login.connect' | translate" detail-push class="core-login-entered-site">
<ion-thumbnail slot="start"> <ion-thumbnail slot="start">
<ion-icon name="fa-pencil-alt"></ion-icon> <ion-icon name="fa-pencil-alt"></ion-icon>
</ion-thumbnail> </ion-thumbnail>
@ -57,7 +63,7 @@
<ion-spinner></ion-spinner> <ion-spinner></ion-spinner>
</div> </div>
<ng-container *ngFor="let site of sites"> <ng-container *ngFor="let site of sites">
<ng-container *ngTemplateOutlet="site"></ng-container> <ng-container *ngTemplateOutlet="site; context: {site: site}"></ng-container>
</ng-container> </ng-container>
</div> </div>
</ion-list> </ion-list>
@ -80,7 +86,9 @@
<h2 class="item-heading">{{ 'core.login.selectsite' | translate }}</h2> <h2 class="item-heading">{{ 'core.login.selectsite' | translate }}</h2>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-searchbar *ngIf="fixedSites.length > 4" [(ngModel)]="filter" (ionInput)="filterChanged($event)" (ionCancel)="filterChanged()" [placeholder]="'core.login.findyoursite' | translate"></ion-searchbar> <ion-searchbar *ngIf="fixedSites.length > 4" [(ngModel)]="filter" (ionInput)="filterChanged($event)"
(ionCancel)="filterChanged()" [placeholder]="'core.login.findyoursite' | translate">
</ion-searchbar>
<ng-container *ngFor="let site of filteredSites"> <ng-container *ngFor="let site of filteredSites">
<ng-container *ngTemplateOutlet="site"></ng-container> <ng-container *ngTemplateOutlet="site"></ng-container>
</ng-container> </ng-container>
@ -88,26 +96,24 @@
</ng-container> </ng-container>
<ng-container *ngIf="showScanQR && !hasSites && !enteredSiteUrl"> <ng-container *ngIf="showScanQR && !hasSites && !enteredSiteUrl">
<div class="core-login-site-qrcode-separator">{{ 'core.login.or' | translate }}</div> <div class="ion-text-center ion-padding ion-margin-top">{{ 'core.login.or' | translate }}</div>
<ion-item class="core-login-site-qrcode" lines="none"> <ion-button expand="block" color="light" class="ion-margin" lines="none" (click)="showInstructionsAndScanQR()">
<ion-button expand="block" class="ion-margin-top ion-text-wrap" (click)="showInstructionsAndScanQR()"> <ion-icon slot="start" name="fa-qrcode" aria-hidden="true"></ion-icon>
<ion-icon name="fa-qrcode" aria-hidden="true" slot="start"></ion-icon> <ion-label>{{ 'core.scanqr' | translate }}</ion-label>
{{ 'core.scanqr' | translate }}
</ion-button> </ion-button>
</ion-item>
</ng-container> </ng-container>
<!-- Help. --> <!-- Help. -->
<ion-list lines="none" class="ion-margin-top"> <ion-list lines="none" class="ion-margin-top">
<ion-item class="ion-text-center ion-text-wrap core-login-need-help" (click)="showHelp()" detail="false"> <ion-item button class="ion-text-center ion-text-wrap core-login-need-help" (click)="showHelp()" detail="false">
<ion-label>{{ 'core.needhelp' | translate }}</ion-label> <ion-label>{{ 'core.needhelp' | translate }}</ion-label>
</ion-item> </ion-item>
</ion-list> </ion-list>
</ion-content> </ion-content>
<!-- Template site selector. --> <!-- Template site selector. -->
<ng-template #site> <ng-template #site let-site="site">
<ion-item (click)="connect($event, site.url, site)" [title]="site.name" detail> <ion-item button (click)="connect($event, site.url, site)" [title]="site.name" detail>
<ion-thumbnail *ngIf="siteFinderSettings.displayimage" slot="start"> <ion-thumbnail *ngIf="siteFinderSettings.displayimage" slot="start">
<img [src]="site.imageurl" *ngIf="site.imageurl" onError="this.src='assets/icon/icon.png'"> <img [src]="site.imageurl" *ngIf="site.imageurl" onError="this.src='assets/icon/icon.png'">
<img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon"> <img src="assets/icon/icon.png" *ngIf="!site.imageurl" class="core-login-default-icon">

View File

@ -15,6 +15,7 @@
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms';
import { NavController } from '@ionic/angular';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreConfig } from '@services/config'; import { CoreConfig } from '@services/config';
@ -25,10 +26,11 @@ import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/h
import { CoreSite } from '@classes/site'; import { CoreSite } from '@classes/site';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { CoreConstants } from '@core/constants'; import { CoreConstants } from '@core/constants';
import { Translate } from '@singletons/core.singletons'; import { Translate, ModalController } from '@singletons/core.singletons';
import { CoreUrl } from '@singletons/url'; import { CoreUrl } from '@singletons/url';
import { CoreUrlUtils } from '@services/utils/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. * 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. * Show a help modal.
*/ */
showHelp(): void { async showHelp(): Promise<void> {
// @todo const modal = await ModalController.instance.create({
component: CoreLoginSiteHelpComponent,
cssClass: 'core-modal-fullscreen',
});
await modal.present();
} }
/** /**
* Show an onboarding modal. * Show an onboarding modal.
*/ */
showOnboarding(): void { async showOnboarding(): Promise<void> {
// @todo 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; pageParams['logoUrl'] = foundSite.imageurl;
} }
// @todo Navigate to credentials.
this.navCtrl.navigateForward('/login/credentials', { this.navCtrl.navigateForward('/login/credentials', {
queryParams: pageParams, queryParams: pageParams,
}); });

View File

@ -19,9 +19,10 @@
</ion-header> </ion-header>
<ion-content> <ion-content>
<ion-list> <ion-list>
<ion-item (click)="login(site.id)" *ngFor="let site of sites"> <ion-item button (click)="login(site.id)" *ngFor="let site of sites">
<ion-avatar slot="start"> <ion-avatar slot="start">
<img [src]="site.avatar" core-external-content [siteId]="site.id" alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}" role="presentation" onError="this.src='assets/img/user-avatar.png'"> <img [src]="site.avatar" core-external-content [siteId]="site.id" role="presentation"
alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}" onError="this.src='assets/img/user-avatar.png'">
</ion-avatar> </ion-avatar>
<ion-label> <ion-label>
<h2>{{site.fullName}}</h2> <h2>{{site.fullName}}</h2>

View File

@ -52,7 +52,7 @@ export class CoreLoginHelperProvider {
protected logger: CoreLogger; protected logger: CoreLogger;
protected isSSOConfirmShown = false; protected isSSOConfirmShown = false;
protected isOpenEditAlertShown = 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; protected isOpeningReconnect = false;
waitingForBrowser = 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. * Function called when an SSO InAppBrowser is closed or the app is resumed. Check if user needs to be logged out.
*/ */
checkLogout(): void { 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 username Username.
* @param siteConfig Site config. * @param siteConfig Site config.
*/ */
async forgottenPasswordClicked( async forgottenPasswordClicked(siteUrl: string, username: string, siteConfig?: CoreSitePublicConfigResponse): Promise<void> {
navCtrl: NavController,
siteUrl: string,
username: string,
siteConfig?: CoreSitePublicConfigResponse,
): Promise<void> {
if (siteConfig && siteConfig.forgottenpasswordurl) { if (siteConfig && siteConfig.forgottenpasswordurl) {
// URL set, open it. // URL set, open it.
CoreUtils.instance.openInApp(siteConfig.forgottenpasswordurl); CoreUtils.instance.openInApp(siteConfig.forgottenpasswordurl);
@ -183,7 +184,7 @@ export class CoreLoginHelperProvider {
const canReset = await this.canRequestPasswordReset(siteUrl); const canReset = await this.canRequestPasswordReset(siteUrl);
if (canReset) { if (canReset) {
await navCtrl.navigateForward(['/login/forgottenpassword'], { await this.navCtrl.navigateForward(['/login/forgottenpassword'], {
queryParams: { queryParams: {
siteUrl, siteUrl,
username, username,
@ -203,7 +204,7 @@ export class CoreLoginHelperProvider {
* @param profileFields Profile fields to format. * @param profileFields Profile fields to format.
* @return Categories with the fields to show in each one. * @return Categories with the fields to show in each one.
*/ */
formatProfileFieldsForSignup(profileFields: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] { formatProfileFieldsForSignup(profileFields?: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] {
if (!profileFields) { if (!profileFields) {
return []; return [];
} }
@ -268,8 +269,8 @@ export class CoreLoginHelperProvider {
maxlengthMsg?: string, maxlengthMsg?: string,
minMsg?: string, minMsg?: string,
maxMsg?: string, maxMsg?: string,
): any { ): Record<string, string> {
const errors: any = {}; const errors: Record<string, string> = {};
if (requiredMsg) { if (requiredMsg) {
errors.required = errors.requiredTrue = Translate.instance.instant(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. * Open a page that doesn't belong to any site.
* *
* @param navCtrl Nav Controller.
* @param page Page to open. * @param page Page to open.
* @param params Params of the page. * @param params Params of the page.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars async goToNoSitePage(page: string, params?: Params): Promise<void> {
goToNoSitePage(page?: string, params?: Params): Promise<any> { const currentPage = CoreApp.instance.getCurrentPage();
// @todo
return Promise.resolve(); 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. * 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 page Name of the page to load.
* @param params Params to pass to the page. * @param params Params to pass to the page.
* @param siteId Site to load.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars protected async loadSiteAndPage(siteId: string, page: string, params?: Params): Promise<void> {
protected loadSiteAndPage(page: string, params: Params, siteId: string): Promise<any> { if (siteId == CoreConstants.NO_SITE_ID) {
// @todo // Page doesn't belong to a site, just load the page.
return Promise.resolve(); 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 page Name of the page to load.
* @param params Params to pass to the page. * @param params Params to pass to the page.
*/ */
loadPageInMainMenu(page: string, params: Params): void { loadPageInMainMenu(page: string, params?: Params): void {
if (!CoreApp.instance.isMainMenuOpen()) { if (!CoreApp.instance.isMainMenuOpen()) {
// Main menu not open. Store the page to be loaded later. // Main menu not open. Store the page to be loaded later.
this.pageToLoad = { this.pageToLoad = {
@ -833,9 +884,20 @@ export class CoreLoginHelperProvider {
* *
* @param siteId The site ID. * @param siteId The site ID.
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars async passwordChangeForced(siteId: string): Promise<void> {
passwordChangeForced(siteId: string): void { const currentSite = CoreSites.instance.getCurrentSite();
// @todo 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. * @param siteId Site to load. If not defined, current site.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async redirect(page: string, params?: Params, siteId?: string): Promise<void> { async redirect(page: string, params?: Params, siteId?: string): Promise<void> {
// @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(); const info = currentSite.getInfo();
if (typeof info != 'undefined' && typeof info.username != 'undefined' && !this.isOpeningReconnect) { 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) { } catch (error) {
@ -1172,7 +1269,12 @@ export class CoreLoginHelperProvider {
return; 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 } });
} }
/** /**

View File

@ -13,7 +13,10 @@
<ion-avatar slot="start"></ion-avatar> <!-- @todo core-user-avatar [user]="siteInfo" --> <ion-avatar slot="start"></ion-avatar> <!-- @todo core-user-avatar [user]="siteInfo" -->
<ion-label> <ion-label>
<h2>{{siteInfo.fullname}}</h2> <h2>{{siteInfo.fullname}}</h2>
<p><core-format-text [text]="siteName" contextLevel="system" [contextInstanceId]="0" [wsNotFiltered]="true"></core-format-text></p> <p>
<core-format-text [text]="siteName" contextLevel="system" [contextInstanceId]="0" [wsNotFiltered]="true">
</core-format-text>
</p>
<p>{{ siteUrl }}</p> <p>{{ siteUrl }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -27,7 +30,8 @@
<ion-label> <ion-label>
<h2>{{ handler.title | translate}}</h2> <h2>{{ handler.title | translate}}</h2>
</ion-label> </ion-label>
<ion-badge slot="end" *ngIf="handler.showBadge" [hidden]="handler.loading || !handler.badge">{{handler.badge}}</ion-badge> <ion-badge slot="end" *ngIf="handler.showBadge" [hidden]="handler.loading || !handler.badge">{{handler.badge}}
</ion-badge>
<ion-spinner slot="end" *ngIf="handler.showBadge && handler.loading"></ion-spinner> <ion-spinner slot="end" *ngIf="handler.showBadge && handler.loading"></ion-spinner>
</ion-item> </ion-item>
<ng-container *ngFor="let item of customItems"> <ng-container *ngFor="let item of customItems">

View File

@ -98,7 +98,7 @@ export class CoreMainMenuProvider {
const id = url + '#' + type; const id = url + '#' + type;
if (!icon) { if (!icon) {
// Icon not defined, use default one. // 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]) { if (!map[id]) {

View File

@ -16,7 +16,6 @@ import { CoreSites } from '@services/sites';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { CoreConstants } from '@core/constants'; import { CoreConstants } from '@core/constants';
import { CoreApp } from '@services/app';
@Component({ @Component({
selector: 'settings-about', selector: 'settings-about',

View File

@ -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", "back": "Back",
"browser": "Browser", "browser": "Browser",
"cancel": "Cancel",
"cannotconnect": "Cannot connect", "cannotconnect": "Cannot connect",
"cannotconnecttrouble": "We're having trouble connecting to your site.", "cannotconnecttrouble": "We're having trouble connecting to your site.",
"cannotconnectverify": "<strong>Please check the address is correct.</strong>", "cannotconnectverify": "<strong>Please check the address is correct.</strong>",
"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", "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", "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?", "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.", "networkerrormsg": "There was a problem connecting to the site. Please check your connection and try again.",
"never": "Never",
"next": "Next",
"no": "No", "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", "offline": "Offline",
"ok": "OK", "ok": "OK",
"online": "Online", "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.<br>{{$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", "tryagain": "Try again",
"twoparagraphs": "{{p1}}<br><br>{{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", "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"
} }

View File

@ -0,0 +1,41 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { 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;
};

View File

@ -13,20 +13,19 @@
// limitations under the License. // limitations under the License.
import { Injectable, NgZone, ApplicationRef } from '@angular/core'; 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 { Connection } from '@ionic-native/network/ngx';
import { CoreDB } from '@services/db'; import { CoreDB } from '@services/db';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { CoreUtils, PromiseDefer } from '@services/utils/utils'; import { CoreUtils, PromiseDefer } from '@services/utils/utils';
import { CoreUrlUtils } from '@services/utils/url';
import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb';
import { CoreConstants } from '@core/constants'; import { CoreConstants } from '@core/constants';
import { makeSingleton, Keyboard, Network, StatusBar, Platform } from '@singletons/core.singletons'; import { makeSingleton, Keyboard, Network, StatusBar, Platform } from '@singletons/core.singletons';
import { CoreLogger } from '@singletons/logger'; import { CoreLogger } from '@singletons/logger';
import { DBNAME, SCHEMA_VERSIONS_TABLE_NAME, SCHEMA_VERSIONS_TABLE_SCHEMA, SchemaVersionsDBEntry } from '@services/app.db';
const DBNAME = 'MoodleMobile';
const SCHEMA_VERSIONS_TABLE = 'schema_versions';
/** /**
* Factory to provide some global functionalities, like access to the global app database. * Factory to provide some global functionalities, like access to the global app database.
@ -57,27 +56,17 @@ export class CoreAppProvider {
// Variables for DB. // Variables for DB.
protected createVersionsTableReady: Promise<void>; protected createVersionsTableReady: Promise<void>;
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.logger = CoreLogger.getInstance('CoreAppProvider');
this.db = CoreDB.instance.getDB(DBNAME); this.db = CoreDB.instance.getDB(DBNAME);
// Create the schema versions table. // 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) => { Keyboard.instance.onKeyboardShow().subscribe((data) => {
// Execute the callback in the Angular zone, so change detection doesn't stop working. // Execute the callback in the Angular zone, so change detection doesn't stop working.
@ -175,7 +164,7 @@ export class CoreAppProvider {
await this.createVersionsTableReady; await this.createVersionsTableReady;
// Fetch installed version of the schema. // Fetch installed version of the schema.
const entry = await this.db.getRecord<SchemaVersionsDBEntry>(SCHEMA_VERSIONS_TABLE, { name: schema.name }); const entry = await this.db.getRecord<SchemaVersionsDBEntry>(SCHEMA_VERSIONS_TABLE_NAME, { name: schema.name });
oldVersion = entry.version; oldVersion = entry.version;
} catch (error) { } catch (error) {
@ -198,7 +187,16 @@ export class CoreAppProvider {
} }
// Set installed version. // 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; appProvider?: CoreAppProvider;
appRef?: ApplicationRef; appRef?: ApplicationRef;
}; };
type SchemaVersionsDBEntry = {
name: string;
version: number;
};

View File

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

View File

@ -14,11 +14,10 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreApp, CoreAppSchema } from '@services/app'; import { CoreApp } from '@services/app';
import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDB } from '@classes/sqlitedb';
import { makeSingleton } from '@singletons/core.singletons'; import { makeSingleton } from '@singletons/core.singletons';
import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/config.db';
const TABLE_NAME = 'core_config';
/** /**
* Factory to provide access to dynamic and permanent config and settings. * Factory to provide access to dynamic and permanent config and settings.
@ -28,32 +27,11 @@ const TABLE_NAME = 'core_config';
export class CoreConfigProvider { export class CoreConfigProvider {
protected appDB: SQLiteDB; 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<void>; // Promise resolved when the app DB is initialized. protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized.
constructor() { constructor() {
this.appDB = CoreApp.instance.getDB(); this.appDB = CoreApp.instance.getDB();
this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => {
// Ignore errors. // Ignore errors.
}); });
} }
@ -67,7 +45,7 @@ export class CoreConfigProvider {
async delete(name: string): Promise<void> { async delete(name: string): Promise<void> {
await this.dbReady; 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; await this.dbReady;
try { try {
const entry = await this.appDB.getRecord<ConfigDBEntry>(TABLE_NAME, { name }); const entry = await this.appDB.getRecord<ConfigDBEntry>(CONFIG_TABLE_NAME, { name });
return entry.value; return entry.value;
} catch (error) { } catch (error) {
@ -103,15 +81,9 @@ export class CoreConfigProvider {
async set(name: string, value: number | string): Promise<void> { async set(name: string, value: number | string): Promise<void> {
await this.dbReady; 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) {} export class CoreConfig extends makeSingleton(CoreConfigProvider) {}
type ConfigDBEntry = {
name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
};

View File

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

View File

@ -14,7 +14,7 @@
import { Injectable, NgZone } from '@angular/core'; 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 { CoreConfig } from '@services/config';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreConstants } from '@core/constants'; import { CoreConstants } from '@core/constants';
@ -23,8 +23,7 @@ import { CoreError } from '@classes/errors/error';
import { makeSingleton, Network } from '@singletons/core.singletons'; import { makeSingleton, Network } from '@singletons/core.singletons';
import { CoreLogger } from '@singletons/logger'; import { CoreLogger } from '@singletons/logger';
import { APP_SCHEMA, CRON_TABLE_NAME, CronDBEntry } from '@services/cron.db';
const CRON_TABLE = 'cron';
/* /*
* Service to handle cron processes. The registered processes will be executed every certain time. * 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 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. 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 logger: CoreLogger;
protected appDB: SQLiteDB; protected appDB: SQLiteDB;
protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized. protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized.
@ -69,7 +46,7 @@ export class CoreCronDelegate {
this.logger = CoreLogger.getInstance('CoreCronDelegate'); this.logger = CoreLogger.getInstance('CoreCronDelegate');
this.appDB = CoreApp.instance.getDB(); this.appDB = CoreApp.instance.getDB();
this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => {
// Ignore errors. // Ignore errors.
}); });
@ -268,7 +245,7 @@ export class CoreCronDelegate {
const id = this.getHandlerLastExecutionId(name); const id = this.getHandlerLastExecutionId(name);
try { try {
const entry = await this.appDB.getRecord<CronDBEntry>(CRON_TABLE, { id }); const entry = await this.appDB.getRecord<CronDBEntry>(CRON_TABLE_NAME, { id });
const time = Number(entry.value); const time = Number(entry.value);
@ -431,7 +408,7 @@ export class CoreCronDelegate {
value: time, 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 & { export type WindowForAutomatedTests = Window & {
cronProvider?: CoreCronDelegate; cronProvider?: CoreCronDelegate;
}; };
type CronDBEntry = {
id: string;
value: number;
};

View File

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

View File

@ -15,12 +15,12 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Md5 } from 'ts-md5/dist/md5'; import { Md5 } from 'ts-md5/dist/md5';
import { CoreApp, CoreAppSchema } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
import { CoreInit } from '@services/init'; import { CoreInit } from '@services/init';
import { CorePluginFile } from '@services/plugin-file-delegate'; 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 { CoreWS, CoreWSExternalFile } from '@services/ws';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreMimetypeUtils } from '@services/utils/mimetype';
@ -33,6 +33,20 @@ import { CoreError } from '@classes/errors/error';
import { CoreConstants } from '@core/constants'; import { CoreConstants } from '@core/constants';
import { makeSingleton, Network, NgZone, Translate } from '@singletons/core.singletons'; import { makeSingleton, Network, NgZone, Translate } from '@singletons/core.singletons';
import { CoreLogger } from '@singletons/logger'; 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. * Factory for handling downloading files and retrieve downloaded files.
@ -60,182 +74,6 @@ export class CoreFilepoolProvider {
protected static readonly FILE_UPDATE_UNKNOWN_WHERE_CLAUSE = protected static readonly FILE_UPDATE_UNKNOWN_WHERE_CLAUSE =
'isexternalfile = 1 OR ((revision IS NULL OR revision = 0) AND (timemodified IS NULL OR timemodified = 0))'; '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 logger: CoreLogger;
protected appDB: SQLiteDB; protected appDB: SQLiteDB;
protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized. protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized.
@ -258,12 +96,10 @@ export class CoreFilepoolProvider {
this.logger = CoreLogger.getInstance('CoreFilepoolProvider'); this.logger = CoreLogger.getInstance('CoreFilepoolProvider');
this.appDB = CoreApp.instance.getDB(); this.appDB = CoreApp.instance.getDB();
this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => {
// Ignore errors. // Ignore errors.
}); });
CoreSites.instance.registerSiteSchema(this.siteSchema);
this.init(); this.init();
} }
@ -308,7 +144,7 @@ export class CoreFilepoolProvider {
componentId: componentId || '', 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); 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`); this.logger.debug(`Adding ${fileId} to the queue`);
await this.appDB.insertRecord(CoreFilepoolProvider.QUEUE_TABLE, { await this.appDB.insertRecord(QUEUE_TABLE_NAME, {
siteId, siteId,
fileId, fileId,
url, url,
@ -563,7 +399,7 @@ export class CoreFilepoolProvider {
// Update only when required. // Update only when required.
this.logger.debug(`Updating file ${fileId} which is already in queue`); 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)); this.getQueuePromise(siteId, fileId, true, onProgress));
} }
@ -692,9 +528,9 @@ export class CoreFilepoolProvider {
const site = await CoreSites.instance.getSite(siteId); const site = await CoreSites.instance.getSite(siteId);
// Get all the packages to be able to "notify" the change in the status. // 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. // Delete all the entries.
await site.getDb().deleteRecords(CoreFilepoolProvider.PACKAGES_TABLE); await site.getDb().deleteRecords(PACKAGES_TABLE_NAME);
entries.forEach((entry) => { entries.forEach((entry) => {
// Trigger module status changed, setting it as not downloaded. // Trigger module status changed, setting it as not downloaded.
@ -712,8 +548,8 @@ export class CoreFilepoolProvider {
const db = await CoreSites.instance.getSiteDb(siteId); const db = await CoreSites.instance.getSiteDb(siteId);
await Promise.all([ await Promise.all([
db.deleteRecords(CoreFilepoolProvider.FILES_TABLE), db.deleteRecords(FILES_TABLE_NAME),
db.deleteRecords(CoreFilepoolProvider.LINKS_TABLE), db.deleteRecords(LINKS_TABLE_NAME),
]); ]);
} }
@ -732,7 +568,7 @@ export class CoreFilepoolProvider {
componentId: this.fixComponentId(componentId), 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) { if (count <= 0) {
throw new CoreError('Component doesn\'t have files'); 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. // Minor problem: file will remain in the filesystem once downloaded again.
this.logger.debug('Staled file with no extension ' + entry.fileId); 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; return;
} }
@ -1267,7 +1103,7 @@ export class CoreFilepoolProvider {
entry.fileId = CoreMimetypeUtils.instance.removeExtension(fileId); entry.fileId = CoreMimetypeUtils.instance.removeExtension(fileId);
entry.extension = extension; entry.extension = extension;
await db.updateRecords(CoreFilepoolProvider.FILES_TABLE, entry, { fileId }); await db.updateRecords(FILES_TABLE_NAME, entry, { fileId });
if (entry.fileId == fileId) { if (entry.fileId == fileId) {
// File ID hasn't changed, we're done. // File ID hasn't changed, we're done.
this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId);
@ -1276,7 +1112,7 @@ export class CoreFilepoolProvider {
} }
// Now update the links. // 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), componentId: this.fixComponentId(componentId),
}; };
const items = await db.getRecords<CoreFilepoolLinksRecord>(CoreFilepoolProvider.LINKS_TABLE, conditions); const items = await db.getRecords<CoreFilepoolLinksRecord>(LINKS_TABLE_NAME, conditions);
items.forEach((item) => { items.forEach((item) => {
item.componentId = this.fixComponentId(item.componentId); item.componentId = this.fixComponentId(item.componentId);
}); });
@ -1449,7 +1285,7 @@ export class CoreFilepoolProvider {
*/ */
protected async getFileLinks(siteId: string, fileId: string): Promise<CoreFilepoolLinksRecord[]> { protected async getFileLinks(siteId: string, fileId: string): Promise<CoreFilepoolLinksRecord[]> {
const db = await CoreSites.instance.getSiteDb(siteId); const db = await CoreSites.instance.getSiteDb(siteId);
const items = await db.getRecords<CoreFilepoolLinksRecord>(CoreFilepoolProvider.LINKS_TABLE, { fileId }); const items = await db.getRecords<CoreFilepoolLinksRecord>(LINKS_TABLE_NAME, { fileId });
items.forEach((item) => { items.forEach((item) => {
item.componentId = this.fixComponentId(item.componentId); item.componentId = this.fixComponentId(item.componentId);
@ -1527,7 +1363,7 @@ export class CoreFilepoolProvider {
await Promise.all(items.map(async (item) => { await Promise.all(items.map(async (item) => {
try { try {
const fileEntry = await db.getRecord<CoreFilepoolFileEntry>( const fileEntry = await db.getRecord<CoreFilepoolFileEntry>(
CoreFilepoolProvider.FILES_TABLE, FILES_TABLE_NAME,
{ fileId: item.fileId }, { fileId: item.fileId },
); );
@ -1808,7 +1644,7 @@ export class CoreFilepoolProvider {
const site = await CoreSites.instance.getSite(siteId); const site = await CoreSites.instance.getSite(siteId);
const packageId = this.getPackageId(component, componentId); 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<CoreFilepoolFileEntry> { protected async hasFileInPool(siteId: string, fileId: string): Promise<CoreFilepoolFileEntry> {
const db = await CoreSites.instance.getSiteDb(siteId); const db = await CoreSites.instance.getSiteDb(siteId);
const entry = await db.getRecord<CoreFilepoolFileEntry>(CoreFilepoolProvider.FILES_TABLE, { fileId }); const entry = await db.getRecord<CoreFilepoolFileEntry>(FILES_TABLE_NAME, { fileId });
if (typeof entry === 'undefined') { if (typeof entry === 'undefined') {
throw new CoreError('File not found in filepool.'); throw new CoreError('File not found in filepool.');
@ -2277,7 +2113,7 @@ export class CoreFilepoolProvider {
protected async hasFileInQueue(siteId: string, fileId: string): Promise<CoreFilepoolQueueEntry> { protected async hasFileInQueue(siteId: string, fileId: string): Promise<CoreFilepoolQueueEntry> {
await this.dbReady; await this.dbReady;
const entry = await this.appDB.getRecord<CoreFilepoolQueueEntry>(CoreFilepoolProvider.QUEUE_TABLE, { siteId, fileId }); const entry = await this.appDB.getRecord<CoreFilepoolQueueEntry>(QUEUE_TABLE_NAME, { siteId, fileId });
if (typeof entry === 'undefined') { if (typeof entry === 'undefined') {
throw new CoreError('File not found in queue.'); 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; 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); 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 + ')'; 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 { try {
items = await this.appDB.getRecords<CoreFilepoolQueueEntry>( items = await this.appDB.getRecords<CoreFilepoolQueueEntry>(
CoreFilepoolProvider.QUEUE_TABLE, QUEUE_TABLE_NAME,
undefined, undefined,
'priority DESC, added ASC', 'priority DESC, added ASC',
undefined, undefined,
@ -2760,7 +2596,7 @@ export class CoreFilepoolProvider {
protected async removeFromQueue(siteId: string, fileId: string): Promise<void> { protected async removeFromQueue(siteId: string, fileId: string): Promise<void> {
await this.dbReady; 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<unknown>[] = []; const promises: Promise<unknown>[] = [];
// Remove entry from filepool store. // Remove entry from filepool store.
promises.push(db.deleteRecords(CoreFilepoolProvider.FILES_TABLE, conditions)); promises.push(db.deleteRecords(FILES_TABLE_NAME, conditions));
// Remove links. // Remove links.
promises.push(db.deleteRecords(CoreFilepoolProvider.LINKS_TABLE, conditions)); promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions));
// Remove the file. // Remove the file.
if (CoreFile.instance.isAvailable()) { if (CoreFile.instance.isAvailable()) {
@ -2885,7 +2721,7 @@ export class CoreFilepoolProvider {
const packageId = this.getPackageId(component, componentId); const packageId = this.getPackageId(component, componentId);
// Get current stored data, we'll only update 'status' and 'updated' fields. // Get current stored data, we'll only update 'status' and 'updated' fields.
const entry = <CoreFilepoolPackageEntry> site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); const entry = <CoreFilepoolPackageEntry> site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId });
const newData: CoreFilepoolPackageEntry = {}; const newData: CoreFilepoolPackageEntry = {};
if (entry.status == CoreConstants.DOWNLOADING) { if (entry.status == CoreConstants.DOWNLOADING) {
// Going back from downloading to previous status, restore previous download time. // Going back from downloading to previous status, restore previous download time.
@ -2895,7 +2731,7 @@ export class CoreFilepoolProvider {
newData.updated = Date.now(); newData.updated = Date.now();
this.logger.debug(`Set previous status '${entry.status}' for package ${component} ${componentId}`); 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. // Success updating, trigger event.
this.triggerPackageStatusChanged(site.id!, newData.status, component, componentId); this.triggerPackageStatusChanged(site.id!, newData.status, component, componentId);
@ -2973,7 +2809,7 @@ export class CoreFilepoolProvider {
let previousStatus: string | undefined; let previousStatus: string | undefined;
// Search current status to set it as previous status. // Search current status to set it as previous status.
try { try {
const entry = <CoreFilepoolPackageEntry> site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); const entry = <CoreFilepoolPackageEntry> site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId });
if (typeof extra == 'undefined' || extra === null) { if (typeof extra == 'undefined' || extra === null) {
extra = entry.extra; extra = entry.extra;
} }
@ -3008,7 +2844,7 @@ export class CoreFilepoolProvider {
return; return;
} }
await site.getDb().insertRecord(CoreFilepoolProvider.PACKAGES_TABLE, packageEntry); await site.getDb().insertRecord(PACKAGES_TABLE_NAME, packageEntry);
// Success inserting, trigger event. // Success inserting, trigger event.
this.triggerPackageStatusChanged(siteId, status, component, componentId); this.triggerPackageStatusChanged(siteId, status, component, componentId);
@ -3132,7 +2968,7 @@ export class CoreFilepoolProvider {
const packageId = this.getPackageId(component, componentId); const packageId = this.getPackageId(component, componentId);
await site.getDb().updateRecords( await site.getDb().updateRecords(
CoreFilepoolProvider.PACKAGES_TABLE, PACKAGES_TABLE_NAME,
{ downloadTime: CoreTimeUtils.instance.timestamp() }, { downloadTime: CoreTimeUtils.instance.timestamp() },
{ id: packageId }, { id: packageId },
); );
@ -3142,166 +2978,6 @@ export class CoreFilepoolProvider {
export class CoreFilepool extends makeSingleton(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. * File actions.
*/ */
@ -3359,14 +3035,5 @@ type CoreFilepoolPromiseDefer = PromiseDefer<void> & {
onProgress?: CoreFilepoolOnProgressCallback; // On Progress function. 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 = type AnchorOrMediaElement =
HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement; HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement;

View File

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

View File

@ -16,11 +16,11 @@ import { Injectable } from '@angular/core';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { ILocalNotification } from '@ionic-native/local-notifications'; import { ILocalNotification } from '@ionic-native/local-notifications';
import { CoreApp, CoreAppSchema } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreConfig } from '@services/config'; import { CoreConfig } from '@services/config';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreTextUtils } from '@services/utils/text'; 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 { SQLiteDB } from '@classes/sqlitedb';
import { CoreSite } from '@classes/site'; import { CoreSite } from '@classes/site';
import { CoreQueueRunner } from '@classes/queue-runner'; import { CoreQueueRunner } from '@classes/queue-runner';
@ -28,6 +28,13 @@ import { CoreError } from '@classes/errors/error';
import { CoreConstants } from '@core/constants'; import { CoreConstants } from '@core/constants';
import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons'; import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons';
import { CoreLogger } from '@singletons/logger'; 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. * Service to handle local notifications.
@ -35,62 +42,6 @@ import { CoreLogger } from '@singletons/logger';
@Injectable() @Injectable()
export class CoreLocalNotificationsProvider { 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 logger: CoreLogger;
protected appDB: SQLiteDB; protected appDB: SQLiteDB;
protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized. protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized.
@ -111,7 +62,7 @@ export class CoreLocalNotificationsProvider {
this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider'); this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider');
this.queueRunner = new CoreQueueRunner(10); this.queueRunner = new CoreQueueRunner(10);
this.appDB = CoreApp.instance.getDB(); this.appDB = CoreApp.instance.getDB();
this.dbReady = CoreApp.instance.createTablesFromSchema(this.tablesSchema).catch(() => { this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => {
// Ignore errors. // Ignore errors.
}); });
@ -301,7 +252,7 @@ export class CoreLocalNotificationsProvider {
* @return Promise resolved when the component code is retrieved. * @return Promise resolved when the component code is retrieved.
*/ */
protected getComponentCode(component: string): Promise<number> { protected getComponentCode(component: string): Promise<number> {
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. * @return Promise resolved when the site code is retrieved.
*/ */
protected getSiteCode(siteId: string): Promise<number> { protected getSiteCode(siteId: string): Promise<number> {
return this.requestCode(CoreLocalNotificationsProvider.SITES_TABLE, siteId); return this.requestCode(SITES_TABLE_NAME, siteId);
} }
/** /**
@ -377,7 +328,7 @@ export class CoreLocalNotificationsProvider {
try { try {
const stored = await this.appDB.getRecord<{ id: number; at: number }>( const stored = await this.appDB.getRecord<{ id: number; at: number }>(
CoreLocalNotificationsProvider.TRIGGERED_TABLE, TRIGGERED_TABLE_NAME,
{ id: notification.id }, { id: notification.id },
); );
@ -532,7 +483,7 @@ export class CoreLocalNotificationsProvider {
async removeTriggered(id: number): Promise<void> { async removeTriggered(id: number): Promise<void> {
await this.dbReady; 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(), 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<void> { async updateComponentName(oldName: string, newName: string): Promise<void> {
await this.dbReady; await this.dbReady;
const oldId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + oldName; const oldId = COMPONENTS_TABLE_NAME + '#' + oldName;
const newId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + newName; 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 class CoreLocalNotifications extends makeSingleton(CoreLocalNotificationsProvider) {}
export type CoreLocalNotificationsClickCallback<T = unknown> = (value: T) => void; export type CoreLocalNotificationsClickCallback<T = unknown> = (value: T) => void;
type CodeRequestsQueueItem = {
table: string;
id: string;
deferreds: PromiseDefer<number>[];
};

View File

@ -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<void> {
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<SiteDBEntry>(oldTable);
const promises: Promise<number>[] = [];
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<void> {
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);
};

View File

@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
import { Md5 } from 'ts-md5/dist/md5'; import { Md5 } from 'ts-md5/dist/md5';
import { timeout } from 'rxjs/operators'; import { timeout } from 'rxjs/operators';
import { CoreApp, CoreAppSchema, CoreStoreConfig } from '@services/app'; import { CoreApp, CoreStoreConfig } from '@services/app';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { CoreWS } from '@services/ws'; import { CoreWS } from '@services/ws';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
@ -38,114 +38,36 @@ import { CoreError } from '@classes/errors/error';
import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreSiteError } from '@classes/errors/siteerror';
import { makeSingleton, Translate, Http } from '@singletons/core.singletons'; import { makeSingleton, Translate, Http } from '@singletons/core.singletons';
import { CoreLogger } from '@singletons/logger'; 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'; // Schemas for site tables. Other providers can add schemas in here using the registerSiteSchema function.
const SCHEMA_VERSIONS_TABLE = 'schema_versions'; const siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {};
export const registerSiteSchema = (schema: CoreSiteSchema): void => {
siteSchemas[schema.name] = schema;
};
/* /*
* Service to manage and interact with sites. * 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 * It allows creating tables in the databases of all sites. Each service or component should be responsible of creating
* their own database tables. Example: * their own database tables. Example:
* *
* constructor(sitesProvider: CoreSitesProvider) { * import { registerSiteSchema } from '@services/sites';
* this.sitesProvider.registerSiteSchema(this.tableSchema);
* *
* This provider will automatically create the tables in the databases of all the instantiated sites, and also to the * registerSiteSchema(tableSchema);
* databases of sites instantiated from now on.
*/ */
@Injectable() @Injectable()
export class CoreSitesProvider { 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<void> {
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<SiteDBEntry>(oldTable);
const promises: Promise<number>[] = [];
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. // Constants to validate a site version.
protected readonly WORKPLACE_APP = 3; protected readonly WORKPLACE_APP = 3;
protected readonly MOODLE_APP = 2; protected readonly MOODLE_APP = 2;
@ -162,112 +84,15 @@ export class CoreSitesProvider {
protected appDB: SQLiteDB; protected appDB: SQLiteDB;
protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized. protected dbReady: Promise<void>; // Promise resolved when the app DB is initialized.
protected siteSchemasMigration: { [siteId: string]: Promise<void> } = {}; protected siteSchemasMigration: { [siteId: string]: Promise<void> } = {};
protected pluginsSiteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {};
// 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<void> {
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.
}
}
},
};
constructor() { constructor() {
this.logger = CoreLogger.getInstance('CoreSitesProvider'); this.logger = CoreLogger.getInstance('CoreSitesProvider');
this.appDB = CoreApp.instance.getDB(); this.appDB = CoreApp.instance.getDB();
this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => {
// Ignore errors. // Ignore errors.
}); });
this.registerSiteSchema(this.siteSchema);
} }
/** /**
@ -857,7 +682,7 @@ export class CoreSitesProvider {
oauthId, 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]; delete this.sites[siteId];
try { try {
await this.appDB.deleteRecords(SITES_TABLE, { id: siteId }); await this.appDB.deleteRecords(SITES_TABLE_NAME, { id: siteId });
} catch (err) { } catch (err) {
// DB remove shouldn't fail, but we'll go ahead even if it does. // DB remove shouldn't fail, but we'll go ahead even if it does.
} }
@ -1103,7 +928,7 @@ export class CoreSitesProvider {
async hasSites(): Promise<boolean> { async hasSites(): Promise<boolean> {
await this.dbReady; await this.dbReady;
const count = await this.appDB.countRecords(SITES_TABLE); const count = await this.appDB.countRecords(SITES_TABLE_NAME);
return count > 0; return count > 0;
} }
@ -1129,7 +954,7 @@ export class CoreSitesProvider {
return this.sites[siteId]; return this.sites[siteId];
} else { } else {
// Retrieve and create the site. // Retrieve and create the site.
const data = await this.appDB.getRecord<SiteDBEntry>(SITES_TABLE, { id: siteId }); const data = await this.appDB.getRecord<SiteDBEntry>(SITES_TABLE_NAME, { id: siteId });
return this.makeSiteFromSiteListEntry(data); return this.makeSiteFromSiteListEntry(data);
} }
@ -1202,7 +1027,7 @@ export class CoreSitesProvider {
async getSites(ids?: string[]): Promise<CoreSiteBasicInfo[]> { async getSites(ids?: string[]): Promise<CoreSiteBasicInfo[]> {
await this.dbReady; await this.dbReady;
const sites = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE); const sites = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE_NAME);
const formattedSites: CoreSiteBasicInfo[] = []; const formattedSites: CoreSiteBasicInfo[] = [];
sites.forEach((site) => { sites.forEach((site) => {
@ -1266,7 +1091,7 @@ export class CoreSitesProvider {
async getLoggedInSitesIds(): Promise<string[]> { async getLoggedInSitesIds(): Promise<string[]> {
await this.dbReady; await this.dbReady;
const sites = await this.appDB.getRecords<SiteDBEntry>(SITES_TABLE, { loggedOut : 0 }); const sites = await this.appDB.getRecords<SiteDBEntry>(SITES_TABLE_NAME, { loggedOut : 0 });
return sites.map((site) => site.id); return sites.map((site) => site.id);
} }
@ -1279,7 +1104,7 @@ export class CoreSitesProvider {
async getSitesIds(): Promise<string[]> { async getSitesIds(): Promise<string[]> {
await this.dbReady; await this.dbReady;
const sites = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE); const sites = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE_NAME);
return sites.map((site) => site.id); return sites.map((site) => site.id);
} }
@ -1298,7 +1123,7 @@ export class CoreSitesProvider {
siteId, siteId,
}; };
await this.appDB.insertRecord(CURRENT_SITE_TABLE, entry); await this.appDB.insertRecord(CURRENT_SITE_TABLE_NAME, entry);
CoreEvents.trigger(CoreEvents.LOGIN, {}, siteId); CoreEvents.trigger(CoreEvents.LOGIN, {}, siteId);
} }
@ -1324,7 +1149,7 @@ export class CoreSitesProvider {
promises.push(this.setSiteLoggedOut(siteId, true)); 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 { try {
@ -1349,7 +1174,7 @@ export class CoreSitesProvider {
this.sessionRestored = true; this.sessionRestored = true;
try { try {
const currentSite = await this.appDB.getRecord<CurrentSiteDBEntry>(CURRENT_SITE_TABLE, { id: 1 }); const currentSite = await this.appDB.getRecord<CurrentSiteDBEntry>(CURRENT_SITE_TABLE_NAME, { id: 1 });
const siteId = currentSite.siteId; const siteId = currentSite.siteId;
this.logger.debug(`Restore session in site ${siteId}`); this.logger.debug(`Restore session in site ${siteId}`);
@ -1377,7 +1202,7 @@ export class CoreSitesProvider {
site.setLoggedOut(loggedOut); 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.privateToken = privateToken;
site.setLoggedOut(false); // Token updated means the user authenticated again, not logged out anymore. 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 { try {
await this.appDB.updateRecords(SITES_TABLE, newValues, { id: siteId }); await this.appDB.updateRecords(SITES_TABLE_NAME, newValues, { id: siteId });
} finally { } finally {
CoreEvents.trigger(CoreEvents.SITE_UPDATED, info, siteId); CoreEvents.trigger(CoreEvents.SITE_UPDATED, info, siteId);
} }
@ -1529,7 +1354,7 @@ export class CoreSitesProvider {
} }
try { try {
const siteEntries = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE); const siteEntries = await this.appDB.getAllRecords<SiteDBEntry>(SITES_TABLE_NAME);
const ids: string[] = []; const ids: string[] = [];
const promises: Promise<unknown>[] = []; const promises: Promise<unknown>[] = [];
@ -1562,7 +1387,7 @@ export class CoreSitesProvider {
async getStoredCurrentSiteId(): Promise<string> { async getStoredCurrentSiteId(): Promise<string> {
await this.dbReady; await this.dbReady;
const currentSite = await this.appDB.getRecord<CurrentSiteDBEntry>(CURRENT_SITE_TABLE, { id: 1 }); const currentSite = await this.appDB.getRecord<CurrentSiteDBEntry>(CURRENT_SITE_TABLE_NAME, { id: 1 });
return currentSite.siteId; return currentSite.siteId;
} }
@ -1605,32 +1430,6 @@ export class CoreSitesProvider {
return this.getSite(siteId).then((site) => site.isFeatureDisabled(name)); 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. * 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. * @param schema The schema to register.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async registerSiteSchema(schema: CoreSiteSchema): Promise<void> { async registerSiteSchema(schema: CoreSiteSchema): Promise<void> {
if (this.currentSite) { if (!this.currentSite) {
return;
}
try { try {
// Site has already been created, apply the schema directly. // Site has already been created, apply the schema directly.
const schemas: {[name: string]: CoreRegisteredSiteSchema} = {}; const schemas: {[name: string]: CoreRegisteredSiteSchema} = {};
schemas[schema.name] = schema; schemas[schema.name] = schema;
if (!schema.onlyCurrentSite) {
// Apply it to all sites.
const siteIds = await this.getSitesIds();
await Promise.all(siteIds.map(async (siteId) => {
const site = await this.getSite(siteId);
return this.applySiteSchemas(site, schemas);
}));
} else {
// Apply it to the specified site only. // Apply it to the specified site only.
(schema as CoreRegisteredSiteSchema).siteId = this.currentSite.getId(); (schema as CoreRegisteredSiteSchema).siteId = this.currentSite.getId();
await this.applySiteSchemas(this.currentSite, schemas); await this.applySiteSchemas(this.currentSite, schemas);
}
} finally { } finally {
// Add the schema to the list. It's done in the end to prevent a schema being applied twice. this.pluginsSiteSchemas[schema.name] = schema;
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;
} }
} }
@ -1700,8 +1488,8 @@ export class CoreSitesProvider {
this.logger.debug(`Migrating all schemas of ${site.id}`); this.logger.debug(`Migrating all schemas of ${site.id}`);
// First create tables not registerd with name/version. // First create tables not registerd with name/version.
const promise = site.getDb().createTablesFromSchema(this.siteTablesSchemas) const promise = site.getDb().createTableFromSchema(SCHEMA_VERSIONS_TABLE_SCHEMA)
.then(() => this.applySiteSchemas(site, this.siteSchemas)); .then(() => this.applySiteSchemas(site, siteSchemas));
this.siteSchemasMigration[site.id] = promise; this.siteSchemasMigration[site.id] = promise;
@ -1721,7 +1509,7 @@ export class CoreSitesProvider {
const db = site.getDb(); const db = site.getDb();
// Fetch installed versions of the schema. // Fetch installed versions of the schema.
const records = await db.getAllRecords<SchemaVersionsDBEntry>(SCHEMA_VERSIONS_TABLE); const records = await db.getAllRecords<SchemaVersionsDBEntry>(SCHEMA_VERSIONS_TABLE_NAME);
const versions: {[name: string]: number} = {}; const versions: {[name: string]: number} = {};
records.forEach((record) => { records.forEach((record) => {
@ -1768,7 +1556,7 @@ export class CoreSitesProvider {
} }
// Set installed version. // 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[] { getSiteTableSchemasToClear(site: CoreSite): string[] {
let reset: string[] = []; let reset: string[] = [];
for (const name in this.siteSchemas) { const schemas = Object.values(siteSchemas).concat(Object.values(this.pluginsSiteSchemas));
const schema = this.siteSchemas[name];
schemas.forEach((schema) => {
if (schema.canBeCleared && (!schema.siteId || site.getId() == schema.siteId)) { if (schema.canBeCleared && (!schema.siteId || site.getId() == schema.siteId)) {
reset = reset.concat(schema.canBeCleared); reset = reset.concat(schema.canBeCleared);
} }
} });
return reset; return reset;
} }
@ -1980,12 +1768,6 @@ export type CoreSiteSchema = {
*/ */
canBeCleared?: string[]; 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. * Tables to create when installing or upgrading the schema.
*/ */
@ -2088,24 +1870,3 @@ export type CoreSitesLoginTokenResponse = {
debuginfo?: string; debuginfo?: string;
reproductionlink?: 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;
};

View File

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

View File

@ -16,8 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { CoreSites, CoreSiteSchema } from '@services/sites'; import { CoreSites, CoreSiteSchema } from '@services/sites';
import { makeSingleton } from '@singletons/core.singletons'; import { makeSingleton } from '@singletons/core.singletons';
import { SYNC_TABLE_NAME, CoreSyncRecord } from '@services/sync.db';
const SYNC_TABLE = 'sync';
/* /*
* Service that provides some features regarding synchronization. * Service that provides some features regarding synchronization.
@ -31,7 +30,7 @@ export class CoreSyncProvider {
version: 1, version: 1,
tables: [ tables: [
{ {
name: SYNC_TABLE, name: SYNC_TABLE_NAME,
columns: [ columns: [
{ {
name: 'component', name: 'component',
@ -61,8 +60,6 @@ export class CoreSyncProvider {
protected blockedItems: { [siteId: string]: { [blockId: string]: { [operation: string]: boolean } } } = {}; protected blockedItems: { [siteId: string]: { [blockId: string]: { [operation: string]: boolean } } } = {};
constructor() { constructor() {
CoreSites.instance.registerSiteSchema(this.siteSchema);
// Unblock all blocks on logout. // Unblock all blocks on logout.
CoreEvents.on(CoreEvents.LOGOUT, (data: {siteId: string}) => { CoreEvents.on(CoreEvents.LOGOUT, (data: {siteId: string}) => {
this.clearAllBlocks(data.siteId); this.clearAllBlocks(data.siteId);
@ -133,7 +130,7 @@ export class CoreSyncProvider {
* @return Record if found or reject. * @return Record if found or reject.
*/ */
getSyncRecord(component: string, id: string | number, siteId?: string): Promise<CoreSyncRecord> { getSyncRecord(component: string, id: string | number, siteId?: string): Promise<CoreSyncRecord> {
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.component = component;
data.id = id; 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 class CoreSync extends makeSingleton(CoreSyncProvider) {}
export type CoreSyncRecord = {
component: string;
id: string;
time: number;
warnings: string;
};

View File

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

View File

@ -1014,7 +1014,7 @@ export class CoreDomUtilsProvider {
* @deprecated since 3.9.5. Use directly the IonContent class. * @deprecated since 3.9.5. Use directly the IonContent class.
*/ */
scrollTo(content: IonContent, x: number, y: number, duration?: number): Promise<void> { scrollTo(content: IonContent, x: number, y: number, duration?: number): Promise<void> {
return content?.scrollByPoint(x, y, duration || 0); return content?.scrollToPoint(x, y, duration || 0);
} }
/** /**
@ -1104,7 +1104,7 @@ export class CoreDomUtilsProvider {
return false; return false;
} }
content?.scrollByPoint(position[0], position[1], duration || 0); content?.scrollToPoint(position[0], position[1], duration || 0);
return true; return true;
} }
@ -1124,6 +1124,8 @@ export class CoreDomUtilsProvider {
scrollParentClass?: string, scrollParentClass?: string,
duration?: number, duration?: number,
): Promise<boolean> { ): Promise<boolean> {
// @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 { try {
const scrollElement = await content.getScrollElement(); const scrollElement = await content.getScrollElement();
@ -1132,7 +1134,7 @@ export class CoreDomUtilsProvider {
return false; return false;
} }
content?.scrollByPoint(position[0], position[1], duration || 0); content?.scrollToPoint(position[0], position[1], duration || 0);
return true; return true;
} catch (error) { } catch (error) {
@ -1147,7 +1149,7 @@ export class CoreDomUtilsProvider {
* @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll. * @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll.
* @return True if the element is found, false otherwise. * @return True if the element is found, false otherwise.
*/ */
async scrollToInputError(content: IonContent, scrollParentClass?: string): Promise<boolean> { async scrollToInputError(content?: IonContent, scrollParentClass?: string): Promise<boolean> {
if (!content) { if (!content) {
return false; return false;
} }

View File

@ -15,6 +15,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { NavController } from '@ionic/angular'; import { NavController } from '@ionic/angular';
import { WKUserScriptWindow, WKUserScriptInjectionTime } from 'cordova-plugin-wkuserscript'; import { WKUserScriptWindow, WKUserScriptInjectionTime } from 'cordova-plugin-wkuserscript';
import { WKWebViewCookiesWindow } from 'cordova-plugin-wkwebview-cookies';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
@ -476,6 +477,36 @@ export class CoreIframeUtilsProvider {
window.addEventListener('message', this.handleIframeMessage.bind(this)); 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<void> {
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 = <WKWebViewCookiesWindow> 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) {} export class CoreIframeUtils extends makeSingleton(CoreIframeUtilsProvider) {}

View File

@ -618,7 +618,7 @@ export class CoreUtilsProvider {
* *
* @return Promise resolved with the list of countries. * @return Promise resolved with the list of countries.
*/ */
getCountryListSorted(): Promise<{ code: string; name: string }[]> { getCountryListSorted(): Promise<CoreCountry[]> {
// Get the keys of the countries. // Get the keys of the countries.
return this.getCountryList().then((countries) => { return this.getCountryList().then((countries) => {
// Sort translations. // Sort translations.
@ -1659,3 +1659,11 @@ export type OrderedPromiseData = {
*/ */
blocking?: boolean; blocking?: boolean;
}; };
/**
* Data about a country.
*/
export type CoreCountry = {
code: string;
name: string;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -300,12 +300,65 @@
"assets.mimetypes.text/rtf": "RTF document", "assets.mimetypes.text/rtf": "RTF document",
"assets.mimetypes.text/vtt": "Web Video Text Track", "assets.mimetypes.text/vtt": "Web Video Text Track",
"assets.mimetypes.video": "Video file ({{$a.EXT}})", "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.back": "Back",
"core.browser": "Browser", "core.browser": "Browser",
"core.cancel": "Cancel",
"core.cannotconnect": "Cannot connect", "core.cannotconnect": "Cannot connect",
"core.cannotconnecttrouble": "We're having trouble connecting to your site.", "core.cannotconnecttrouble": "We're having trouble connecting to your site.",
"core.cannotconnectverify": "<strong>Please check the address is correct.</strong>", "core.cannotconnectverify": "<strong>Please check the address is correct.</strong>",
"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.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.addtofavourites": "Star this course",
"core.courses.allowguests": "This course allows guest users to enter", "core.courses.allowguests": "This course allows guest users to enter",
"core.courses.availablecourses": "Available courses", "core.courses.availablecourses": "Available courses",
@ -343,7 +396,90 @@
"core.courses.sendpaymentbutton": "Send payment via PayPal", "core.courses.sendpaymentbutton": "Send payment via PayPal",
"core.courses.show": "Restore to view", "core.courses.show": "Restore to view",
"core.courses.totalcoursesearchresults": "Total courses: {{$a}}", "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.loading": "Loading",
"core.loadmore": "Load more",
"core.location": "Location",
"core.login.auth_email": "Email-based self-registration", "core.login.auth_email": "Email-based self-registration",
"core.login.authenticating": "Authenticating", "core.login.authenticating": "Authenticating",
"core.login.cancel": "Cancel", "core.login.cancel": "Cancel",
@ -381,7 +517,7 @@
"core.login.faqsetupsitequestion": "I want to set up my own Moodle site.", "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.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.faqtestappquestion": "I just want to test the app, what can I do?",
"core.login.faqwhatisurlanswer": "<p>Every organisation has their own unique address or URL for their Moodle site. To find the address:</p><ol><li>Open a web browser and go to your Moodle site login page.</li><li>At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"</li><li>Now you can log in to your site using your username and password.</li>", "core.login.faqwhatisurlanswer": "<p>Every organisation has their own unique address or URL for their Moodle site. To find the address:</p><ol><li>Open a web browser and go to your Moodle site login page.</li><li>At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"</li><li>Now you can log in to your site using your username and password.</li></ol>",
"core.login.faqwhatisurlquestion": "What is my site address? How can I find my site URL?", "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.faqwhereisqrcode": "Where can I find the QR code?",
"core.login.faqwhereisqrcodeanswer": "<p>If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$image}}", "core.login.faqwhereisqrcodeanswer": "<p>If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$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.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.youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.",
"core.login.yourenteredsite": "Connect to your site", "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.changesite": "Change site",
"core.mainmenu.help": "Help", "core.mainmenu.help": "Help",
"core.mainmenu.home": "Home", "core.mainmenu.home": "Home",
"core.mainmenu.logout": "Log out", "core.mainmenu.logout": "Log out",
"core.mainmenu.website": "Website", "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.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.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.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.offline": "Offline",
"core.ok": "OK", "core.ok": "OK",
"core.online": "Online", "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.<br>{{$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.about": "About",
"core.settings.appsettings": "App settings", "core.settings.appsettings": "App settings",
"core.settings.appversion": "App version", "core.settings.appversion": "App version",
@ -548,7 +783,79 @@
"core.settings.syncsettings": "Synchronisation settings", "core.settings.syncsettings": "Synchronisation settings",
"core.settings.total": "Total", "core.settings.total": "Total",
"core.settings.wificonnection": "Wi-Fi connection", "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.tryagain": "Try again",
"core.twoparagraphs": "{{p1}}<br><br>{{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.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"
} }

View File

@ -70,3 +70,32 @@ ion-item-divider {
ion-list.list-md { ion-list.list-md {
padding-bottom: 0; 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;
}