Merge pull request #2603 from crazyserver/MOBILE-3565

Mobile 3565
main
Dani Palou 2020-11-12 11:58:11 +01:00 committed by GitHub
commit 67eb694737
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 3562 additions and 1280 deletions

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ npm-debug.log*
/www
/config/config.*.json
/src/assets/lang/*

View File

@ -0,0 +1,14 @@
/**
* Application config.
*
* You can create your own environment files such as "config.prod.json" and "config.dev.json"
* to override some values. The values will be merged, so you don't need to duplicate everything
* in this file.
*/
{
// @todo This could be read from package.json.
"versionname": "3.9.3-dev",
// Override default language here.
"default_lang": "es"
}

View File

@ -1,20 +1,8 @@
/**
* Application config.
*
* You can create your own environment files such as "config.prod.json" and "config.dev.json"
* to override some values. The values will be merged, so you don't need to duplicate everything
* in this file.
*/
{
"app_id": "com.moodle.moodlemobile",
"appname": "Moodle Mobile",
"desktopappname": "Moodle Desktop",
"versioncode": 3930,
// @todo This could be read from package.json.
"versionname": "3.9.3-dev",
"cache_update_frequency_usually": 420000,
"cache_update_frequency_often": 1200000,
"cache_update_frequency_sometimes": 3600000,
@ -113,9 +101,6 @@
"ioswebviewscheme": "moodleappfs",
"appstores": {
"android": "com.moodle.moodlemobile",
"ios": "id633359593",
"windows": "moodle-desktop\/9p9bwvhdc8c8",
"mac": "id1255924440",
"linux": "https:\/\/download.moodle.org\/desktop\/download.php?platform=linux&arch=64"
"ios": "id633359593"
}
}

View File

@ -0,0 +1,48 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Script for getting the PHP structure of a WS returns or params.
*
* The first parameter (required) is the path to the Moodle installation to use.
* The second parameter (required) is the name to the WS to convert.
* The third parameter (optional) is a number: 1 to convert the params structure,
* 0 to convert the returns structure. Defaults to 0.
*/
if (!isset($argv[1])) {
echo "ERROR: Please pass the Moodle path as the first parameter.\n";
die();
}
$moodlepath = $argv[1];
define('CLI_SCRIPT', true);
require($moodlepath . '/config.php');
require($CFG->dirroot . '/webservice/lib.php');
require_once('ws_to_ts_functions.php');
$structures = get_all_ws_structures();
foreach ($structures as $wsname => $structure) {
remove_default_closures($structure->parameters_desc);
print_ws_structure($wsname, $structure->parameters_desc, true);
remove_default_closures($structure->returns_desc);
print_ws_structure($wsname, $structure->returns_desc, false);
}

View File

@ -0,0 +1,105 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Script for detecting changes in a WS params or return data, version by version.
*
* The first parameter (required) is the path to the Moodle installation to use.
* The second parameter (required) is the name to the WS to convert.
* The third parameter (optional) is a number: 1 to convert the params structure,
* 0 to convert the returns structure. Defaults to 0.
*/
if (!isset($argv[1])) {
echo "ERROR: Please pass the path to the folder containing the Moodle installations as the first parameter.\n";
die();
}
if (!isset($argv[2])) {
echo "ERROR: Please pass the WS name as the second parameter.\n";
die();
}
define('CLI_SCRIPT', true);
define('CACHE_DISABLE_ALL', true);
define('SERIALIZED', true);
require_once('ws_to_ts_functions.php');
$versions = array('master', '310', '39', '38', '37', '36', '35', '34', '33', '32', '31');
$moodlespath = $argv[1];
$wsname = $argv[2];
$useparams = !!(isset($argv[3]) && $argv[3]);
$pathseparator = '/';
// Get the path to the script.
$index = strrpos(__FILE__, $pathseparator);
if ($index === false) {
$pathseparator = '\\';
$index = strrpos(__FILE__, $pathseparator);
}
$scriptfolder = substr(__FILE__, 0, $index);
$scriptpath = concatenate_paths($scriptfolder, 'get_ws_structure.php', $pathseparator);
$previousstructure = null;
$previousversion = null;
$libsloaded = false;
foreach ($versions as $version) {
$moodlepath = concatenate_paths($moodlespath, 'stable_' . $version . '/moodle', $pathseparator);
if (!file_exists($moodlepath)) {
echo "Folder does not exist for version $version, skipping...\n";
continue;
}
if (!$libsloaded) {
$libsloaded = true;
require($moodlepath . '/config.php');
require($CFG->dirroot . '/webservice/lib.php');
}
// Get the structure in this Moodle version.
$structure = shell_exec("php $scriptpath $moodlepath $wsname " . ($useparams ? 'true' : ''));
if (strpos($structure, 'ERROR:') === 0) {
echo "WS not found in version $version. Stop.\n";
break;
}
$structure = unserialize($structure);
if ($previousstructure != null) {
echo "*** Check changes from version $version to $previousversion ***\n";
$messages = detect_ws_changes($previousstructure, $structure);
if (count($messages) > 0) {
$haschanged = true;
foreach($messages as $message) {
echo "$message\n";
}
} else {
echo "No changes found.\n";
}
echo "\n";
}
$previousstructure = $structure;
$previousversion = $version;
}

View File

@ -0,0 +1,63 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Script for getting the PHP structure of a WS returns or params.
*
* The first parameter (required) is the path to the Moodle installation to use.
* The second parameter (required) is the name to the WS to convert.
* The third parameter (optional) is a number: 1 to convert the params structure,
* 0 to convert the returns structure. Defaults to 0.
*/
if (!isset($argv[1])) {
echo "ERROR: Please pass the Moodle path as the first parameter.\n";
die();
}
if (!isset($argv[2])) {
echo "ERROR: Please pass the WS name as the second parameter.\n";
die();
}
if (!defined('SERIALIZED')) {
define('SERIALIZED', false);
}
$moodlepath = $argv[1];
$wsname = $argv[2];
$useparams = !!(isset($argv[3]) && $argv[3]);
define('CLI_SCRIPT', true);
require($moodlepath . '/config.php');
require($CFG->dirroot . '/webservice/lib.php');
require_once('ws_to_ts_functions.php');
$structure = get_ws_structure($wsname, $useparams);
if ($structure === false) {
echo "ERROR: The WS wasn't found in this Moodle installation.\n";
die();
}
remove_default_closures($structure);
if (SERIALIZED) {
echo serialize($structure);
} else {
print_ws_structure($wsname, $structure, $useparams);
}

View File

@ -0,0 +1,288 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Helper functions for converting a Moodle WS structure to a TS type.
*/
/**
* Get the structure of a WS params or returns.
*/
function get_ws_structure($wsname, $useparams) {
global $DB;
// get all the function descriptions
$function = $DB->get_record('external_functions', array('services' => 'moodle_mobile_app', 'name' => $wsname));
if (!$function) {
return false;
}
$functiondesc = external_api::external_function_info($function);
if ($useparams) {
return $functiondesc->parameters_desc;
} else {
return $functiondesc->returns_desc;
}
}
/**
* Return all WS structures.
*/
function get_all_ws_structures() {
global $DB;
// get all the function descriptions
$functions = $DB->get_records('external_functions', array('services' => 'moodle_mobile_app'), 'name');
$functiondescs = array();
foreach ($functions as $function) {
$functiondescs[$function->name] = external_api::external_function_info($function);
}
return $functiondescs;
}
/**
* Fix a comment: make sure first letter is uppercase and add a dot at the end if needed.
*/
function fix_comment($desc) {
$desc = trim($desc);
$desc = ucfirst($desc);
if (substr($desc, -1) !== '.') {
$desc .= '.';
}
$lines = explode("\n", $desc);
if (count($lines) > 1) {
$desc = array_shift($lines)."\n";
foreach ($lines as $i => $line) {
$spaces = strlen($line) - strlen(ltrim($line));
$desc .= str_repeat(' ', $spaces - 3) . '// '. ltrim($line)."\n";
}
}
return $desc;
}
/**
* Get an inline comment based on a certain text.
*/
function get_inline_comment($desc) {
if (empty($desc)) {
return '';
}
return ' // ' . fix_comment($desc);
}
/**
* Add the TS documentation of a certain element.
*/
function get_ts_doc($type, $desc, $indentation) {
if (empty($desc)) {
// If no key, it's probably in an array. We only document object properties.
return '';
}
return $indentation . "/**\n" .
$indentation . " * " . fix_comment($desc) . "\n" .
(!empty($type) ? ($indentation . " * @type {" . $type . "}\n") : '') .
$indentation . " */\n";
}
/**
* Specify a certain type, with or without a key.
*/
function convert_key_type($key, $type, $required, $indentation) {
if ($key) {
// It has a key, it's inside an object.
return $indentation . "$key" . ($required == VALUE_OPTIONAL || $required == VALUE_DEFAULT ? '?' : '') . ": $type";
} else {
// No key, it's probably in an array. Just include the type.
return $type;
}
}
/**
* Convert a certain element into a TS structure.
*/
function convert_to_ts($key, $value, $boolisnumber = false, $indentation = '', $arraydesc = '') {
if ($value instanceof external_value || $value instanceof external_warnings || $value instanceof external_files) {
// It's a basic field or a pre-defined type like warnings.
$type = 'string';
if ($value instanceof external_warnings) {
$type = 'CoreWSExternalWarning[]';
} else if ($value instanceof external_files) {
$type = 'CoreWSExternalFile[]';
} else if ($value->type == PARAM_BOOL && !$boolisnumber) {
$type = 'boolean';
} else if (($value->type == PARAM_BOOL && $boolisnumber) || $value->type == PARAM_INT || $value->type == PARAM_FLOAT ||
$value->type == PARAM_LOCALISEDFLOAT || $value->type == PARAM_PERMISSION || $value->type == PARAM_INTEGER ||
$value->type == PARAM_NUMBER) {
$type = 'number';
}
$result = convert_key_type($key, $type, $value->required, $indentation);
return $result;
} else if ($value instanceof external_single_structure) {
// It's an object.
$result = convert_key_type($key, '{', $value->required, $indentation);
if ($arraydesc) {
// It's an array of objects. Print the array description now.
$result .= get_inline_comment($arraydesc);
}
$result .= "\n";
foreach ($value->keys as $key => $value) {
$result .= convert_to_ts($key, $value, $boolisnumber, $indentation . ' ') . ';';
if (!$value instanceof external_multiple_structure || !$value->content instanceof external_single_structure) {
// Add inline comments after the field, except for arrays of objects where it's added at the start.
$result .= get_inline_comment($value->desc);
}
$result .= "\n";
}
$result .= "$indentation}";
return $result;
} else if ($value instanceof external_multiple_structure) {
// It's an array.
$result = convert_key_type($key, '', $value->required, $indentation);
$result .= convert_to_ts(null, $value->content, $boolisnumber, $indentation, $value->desc);
$result .= "[]";
return $result;
} else if ($value == null) {
return "{}; // WARNING: Null structure found";
} else {
return "{}; // WARNING: Unknown structure: $key " . get_class($value);
}
}
/**
* Print structure ready to use.
*/
function print_ws_structure($name, $structure, $useparams) {
if ($useparams) {
$type = implode('', array_map('ucfirst', explode('_', $name))) . 'WSParams';
$comment = "Params of $name WS.";
} else {
$type = implode('', array_map('ucfirst', explode('_', $name))) . 'WSResponse';
$comment = "Data returned by $name WS.";
}
echo "
/**
* $comment
*/
export type $type = ".convert_to_ts(null, $structure).";\n";
}
/**
* Concatenate two paths.
*/
function concatenate_paths($left, $right, $separator = '/') {
if (!is_string($left) || $left == '') {
return $right;
} else if (!is_string($right) || $right == '') {
return $left;
}
$lastCharLeft = substr($left, -1);
$firstCharRight = $right[0];
if ($lastCharLeft === $separator && $firstCharRight === $separator) {
return $left . substr($right, 1);
} else if ($lastCharLeft !== $separator && $firstCharRight !== '/') {
return $left . '/' . $right;
} else {
return $left . $right;
}
}
/**
* Detect changes between 2 WS structures. We only detect fields that have been added or modified, not removed fields.
*/
function detect_ws_changes($new, $old, $key = '', $path = '') {
$messages = [];
if (gettype($new) != gettype($old)) {
// The type has changed.
$messages[] = "Property '$key' has changed type, from '" . gettype($old) . "' to '" . gettype($new) .
($path != '' ? "' inside $path." : "'.");
} else if ($new instanceof external_value && $new->type != $old->type) {
// The type has changed.
$messages[] = "Property '$key' has changed type, from '" . $old->type . "' to '" . $new->type .
($path != '' ? "' inside $path." : "'.");
} else if ($new instanceof external_warnings || $new instanceof external_files) {
// Ignore these types.
} else if ($new instanceof external_single_structure) {
// Check each subproperty.
$newpath = ($path != '' ? "$path." : '') . $key;
foreach ($new->keys as $subkey => $value) {
if (!isset($old->keys[$subkey])) {
// New property.
$messages[] = "New property '$subkey' found" . ($newpath != '' ? " inside '$newpath'." : '.');
} else {
$messages = array_merge($messages, detect_ws_changes($value, $old->keys[$subkey], $subkey, $newpath));
}
}
} else if ($new instanceof external_multiple_structure) {
// Recursive call with the content.
$messages = array_merge($messages, detect_ws_changes($new->content, $old->content, $key, $path));
}
return $messages;
}
/**
* Remove all closures (anonymous functions) in the default values so the object can be serialized.
*/
function remove_default_closures($value) {
if ($value instanceof external_warnings || $value instanceof external_files) {
// Ignore these types.
} else if ($value instanceof external_value) {
if ($value->default instanceof Closure) {
$value->default = null;
}
} else if ($value instanceof external_single_structure) {
foreach ($value->keys as $key => $subvalue) {
remove_default_closures($subvalue);
}
} else if ($value instanceof external_multiple_structure) {
remove_default_closures($value->content);
}
}

View File

@ -94,9 +94,11 @@ export class AppComponent implements OnInit {
parts[1] = parts[1] || '0';
parts[2] = parts[2] || '0';
document.body.classList.add('version-' + parts[0],
document.body.classList.add(
'version-' + parts[0],
'version-' + parts[0] + '-' + parts[1],
'version-' + parts[0] + '-' + parts[1] + '-' + parts[2]);
'version-' + parts[0] + '-' + parts[1] + '-' + parts[2],
);
}
/**

View File

@ -37,7 +37,7 @@ import { CoreGroupsProvider } from '@services/groups';
import { CoreInitDelegate, CoreInit } from '@services/init';
import { CoreLangProvider } from '@services/lang';
import { CoreLocalNotificationsProvider } from '@services/local-notifications';
import { CorePluginFileDelegate } from '@services/plugin-file-delegate';
import { CorePluginFileDelegate } from '@services/plugin-file.delegate';
import { CoreSitesProvider, CoreSites } from '@services/sites';
import { CoreSyncProvider } from '@services/sync';
import { CoreUpdateManagerProvider, CoreUpdateManager } from '@services/update-manager';
@ -54,6 +54,7 @@ import { CoreUtilsProvider } from '@services/utils/utils';
import { initCoreFilepoolDB } from '@services/filepool.db';
import { initCoreSitesDB } from '@services/sites.db';
import { initCoreSyncDB } from '@services/sync.db';
import { initCoreSearchHistoryDB } from '@core/search/services/search.history.db';
// Import core modules.
import { CoreEmulatorModule } from '@core/emulator/emulator.module';
@ -170,6 +171,7 @@ export class AppModule {
initCoreFilepoolDB();
initCoreSitesDB();
initCoreSyncDB();
initCoreSearchHistoryDB();
}
}

View File

@ -0,0 +1,104 @@
// (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 { BehaviorSubject, Subject } from 'rxjs';
import { CoreEvents } from '../singletons/events';
import { CoreDelegate, CoreDelegateDisplayHandler, CoreDelegateToDisplay } from './delegate';
/**
* Superclass to help creating sorted delegates.
*/
export class CoreSortedDelegate<
DisplayType extends CoreDelegateToDisplay,
HandlerType extends CoreDelegateDisplayHandler<DisplayType>>
extends CoreDelegate<HandlerType> {
protected loaded = false;
protected sortedHandlersRxJs: Subject<DisplayType[]> = new BehaviorSubject<DisplayType[]>([]);
protected sortedHandlers: DisplayType[] = [];
/**
* Constructor of the Delegate.
*
* @param delegateName Delegate name used for logging purposes.
* @param listenSiteEvents Whether to update the handler when a site event occurs (login, site updated, ...).
*/
constructor(delegateName: string) {
super(delegateName, true);
CoreEvents.on(CoreEvents.LOGOUT, this.clearSortedHandlers.bind(this));
}
/**
* Check if handlers are loaded.
*
* @return True if handlers are loaded, false otherwise.
*/
areHandlersLoaded(): boolean {
return this.loaded;
}
/**
* Clear current site handlers. Reserved for core use.
*/
protected clearSortedHandlers(): void {
this.loaded = false;
this.sortedHandlersRxJs.next([]);
this.sortedHandlers = [];
}
/**
* Get the handlers for the current site.
*
* @return An observable that will receive the handlers.
*/
getHandlers(): DisplayType[] {
return this.sortedHandlers;
}
/**
* Get the handlers for the current site.
*
* @return An observable that will receive the handlers.
*/
getHandlersObservable(): Subject<DisplayType[]> {
return this.sortedHandlersRxJs;
}
/**
* Update handlers Data.
*/
updateData(): void {
const displayData: DisplayType[] = [];
for (const name in this.enabledHandlers) {
const handler = this.enabledHandlers[name];
const data = <DisplayType> handler.getDisplayData();
data.priority = handler.priority;
data.name = handler.name;
displayData.push(data);
}
// Sort them by priority.
displayData.sort((a, b) => (b.priority || 0) - (a.priority || 0));
this.loaded = true;
this.sortedHandlersRxJs.next(displayData);
this.sortedHandlers = displayData;
}
}

View File

@ -20,7 +20,7 @@ import { CoreLogger } from '@singletons/logger';
/**
* Superclass to help creating delegates
*/
export class CoreDelegate {
export class CoreDelegate<HandlerType extends CoreDelegateHandler> {
/**
* Logger instance.
@ -30,17 +30,17 @@ export class CoreDelegate {
/**
* List of registered handlers.
*/
protected handlers: { [s: string]: CoreDelegateHandler } = {};
protected handlers: { [s: string]: HandlerType } = {};
/**
* List of registered handlers enabled for the current site.
*/
protected enabledHandlers: { [s: string]: CoreDelegateHandler } = {};
protected enabledHandlers: { [s: string]: HandlerType } = {};
/**
* Default handler
*/
protected defaultHandler?: CoreDelegateHandler;
protected defaultHandler?: HandlerType;
/**
* Time when last updateHandler functions started.
@ -136,7 +136,7 @@ export class CoreDelegate {
* @param params Parameters to pass to the function.
* @return Function returned value or default value.
*/
private execute<T = unknown>(handler: CoreDelegateHandler, fnName: string, params?: unknown[]): T | undefined {
private execute<T = unknown>(handler: HandlerType, fnName: string, params?: unknown[]): T | undefined {
if (handler && handler[fnName]) {
return handler[fnName].apply(handler, params);
} else if (this.defaultHandler && this.defaultHandler[fnName]) {
@ -151,7 +151,7 @@ export class CoreDelegate {
* @param enabled Only enabled, or any.
* @return Handler.
*/
protected getHandler(handlerName: string, enabled: boolean = false): CoreDelegateHandler {
protected getHandler(handlerName: string, enabled: boolean = false): HandlerType {
return enabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName];
}
@ -218,7 +218,7 @@ export class CoreDelegate {
* @param handler The handler delegate object to register.
* @return True when registered, false if already registered.
*/
registerHandler(handler: CoreDelegateHandler): boolean {
registerHandler(handler: HandlerType): boolean {
const key = handler[this.handlerNameProperty] || handler.name;
if (typeof this.handlers[key] !== 'undefined') {
@ -240,7 +240,7 @@ export class CoreDelegate {
* @param time Time this update process started.
* @return Resolved when done.
*/
protected updateHandler(handler: CoreDelegateHandler): Promise<void> {
protected updateHandler(handler: HandlerType): Promise<void> {
const siteId = CoreSites.instance.getCurrentSiteId();
const currentSite = CoreSites.instance.getCurrentSite();
let promise: Promise<boolean>;
@ -287,7 +287,7 @@ export class CoreDelegate {
* @param site Site to check.
* @return Whether is enabled or disabled in site.
*/
protected isFeatureDisabled(handler: CoreDelegateHandler, site: CoreSite): boolean {
protected isFeatureDisabled(handler: HandlerType, site: CoreSite): boolean {
return typeof this.featurePrefix != 'undefined' && site.isFeatureDisabled(this.featurePrefix + handler.name);
}
@ -334,6 +334,9 @@ export class CoreDelegate {
}
/**
* Base interface for any delegate.
*/
export interface CoreDelegateHandler {
/**
* Name of the handler, or name and sub context (AddonMessages, AddonMessages:blockContact, ...).
@ -348,3 +351,35 @@ export interface CoreDelegateHandler {
*/
isEnabled(): Promise<boolean>;
}
/**
* Data returned by the delegate for each handler to be displayed.
*/
export interface CoreDelegateToDisplay {
/**
* Name of the handler.
*/
name?: string;
/**
* Priority of the handler.
*/
priority?: number;
}
/**
* Base interface for a core delegate needed to be displayed.
*/
export interface CoreDelegateDisplayHandler<HandlerData extends CoreDelegateToDisplay> extends CoreDelegateHandler {
/**
* The highest priority is displayed first.
*/
priority?: number;
/**
* Returns the data needed to render the handler.
*
* @return Data.
*/
getDisplayData(): HandlerData;
}

View File

@ -16,7 +16,7 @@ import { Component, Input, Output, OnInit, OnDestroy, EventEmitter } from '@angu
import { CoreApp } from '@services/app';
import { CoreFilepool } from '@services/filepool';
import { CoreFileHelper } from '@services/file-helper';
import { CorePluginFileDelegate } from '@services/plugin-file-delegate';
import { CorePluginFileDelegate } from '@services/plugin-file.delegate';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreMimetypeUtils } from '@services/utils/mimetype';

View File

@ -38,6 +38,6 @@
}
&.core-loading-loaded {
position: relative;
position: unset;
}
}

View File

@ -0,0 +1,116 @@
// (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 { Params } from '@angular/router';
import { CoreContentLinksHandler, CoreContentLinksAction } from '../services/contentlinks.delegate';
/**
* Base handler to be registered in CoreContentLinksHandler. It is useful to minimize the amount of
* functions that handlers need to implement.
*
* It allows you to specify a "pattern" (RegExp) that will be used to check if the handler handles a URL and to get its site URL.
*/
export class CoreContentLinksHandlerBase implements CoreContentLinksHandler {
/**
* A name to identify the handler.
*/
name = 'CoreContentLinksHandlerBase';
/**
* Handler's priority. The highest priority is treated first.
*/
priority = 0;
/**
* Whether the isEnabled function should be called for all the users in a site. It should be true only if the isEnabled call
* can return different values for different users in same site.
*/
checkAllUsers = false;
/**
* Name of the feature this handler is related to.
* It will be used to check if the feature is disabled (@see CoreSite.isFeatureDisabled).
*/
featureName = '';
/**
* A pattern to use to detect if the handler handles a URL and to get its site URL. Required if "handles" and
* "getSiteUrl" functions aren't overridden.
*/
pattern?: RegExp;
/**
* Get the list of actions for a link (url).
*
* @param siteIds List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return List of (or promise resolved with list of) actions.
*/
getActions(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
siteIds: string[],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
url: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
params: Params,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
courseId?: number,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [];
}
/**
* Check if a URL is handled by this handler.
*
* @param url The URL to check.
* @return Whether the URL is handled by this handler
*/
handles(url: string): boolean {
return !!this.pattern && url.search(this.pattern) >= 0;
}
/**
* If the URL is handled by this handler, return the site URL.
*
* @param url The URL to check.
* @return Site URL if it is handled, undefined otherwise.
*/
getSiteUrl(url: string): string | undefined {
if (this.pattern) {
const position = url.search(this.pattern);
if (position > -1) {
return url.substr(0, position);
}
}
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param siteId The site ID.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise<boolean> {
return true;
}
}

View File

@ -0,0 +1,114 @@
// (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 { CoreContentLinksAction } from '../services/contentlinks.delegate';
import { CoreContentLinksHandlerBase } from './base-handler';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
// import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { Params } from '@angular/router';
/**
* Handler to handle URLs pointing to the grade of a module.
*/
export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerBase {
/**
* Whether the module can be reviewed in the app. If true, the handler needs to implement the goToReview function.
*/
canReview = false;
/**
* If this boolean is set to true, the app will retrieve all modules with this modName with a single WS call.
* This reduces the number of WS calls, but it isn't recommended for modules that can return a lot of contents.
*/
protected useModNameToGetModule = false;
/**
* Construct the handler.
*
* @param addon Name of the addon as it's registered in course delegate. It'll be used to check if it's disabled.
* @param modName Name of the module (assign, book, ...).
*/
constructor(
public addon: string,
public modName: string,
) {
super();
// Match the grade.php URL with an id param.
this.pattern = new RegExp('/mod/' + modName + '/grade.php.*([&?]id=\\d+)');
this.featureName = 'CoreCourseModuleDelegate_' + addon;
}
/**
* Get the list of actions for a link (url).
*
* @param siteIds Unused. List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return List of (or promise resolved with list of) actions.
*/
getActions(
siteIds: string[],
url: string,
params: Params,
courseId?: number,
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = courseId || params.courseid || params.cid;
return [{
action: async (siteId): Promise<void> => {
// Check if userid is the site's current user.
const modal = await CoreDomUtils.instance.showModalLoading();
const site = await CoreSites.instance.getSite(siteId);
if (!params.userid || params.userid == site.getUserId()) {
// No user specified or current user. Navigate to module.
// @todo this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined,
// this.useModNameToGetModule ? this.modName : undefined, undefined, navCtrl);
} else if (this.canReview) {
// Use the goToReview function.
this.goToReview(url, params, courseId!, siteId);
} else {
// Not current user and cannot review it in the app, open it in browser.
site.openInBrowserWithAutoLogin(url);
}
modal.dismiss();
},
}];
}
/**
* Go to the page to review.
*
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL.
* @param siteId Site to use.
* @return Promise resolved when done.
*/
protected async goToReview(
url: string, // eslint-disable-line @typescript-eslint/no-unused-vars
params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars
courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars
siteId: string, // eslint-disable-line @typescript-eslint/no-unused-vars
): Promise<void> {
// This function should be overridden.
return;
}
}

View File

@ -0,0 +1,106 @@
// (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 { CoreContentLinksHandlerBase } from './base-handler';
import { Params } from '@angular/router';
import { CoreContentLinksAction } from '../services/contentlinks.delegate';
/**
* Handler to handle URLs pointing to the index of a module.
*/
export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerBase {
/**
* If this boolean is set to true, the app will retrieve all modules with this modName with a single WS call.
* This reduces the number of WS calls, but it isn't recommended for modules that can return a lot of contents.
*/
protected useModNameToGetModule = false;
/**
* Construct the handler.
*
* @param addon Name of the addon as it's registered in course delegate. It'll be used to check if it's disabled.
* @param modName Name of the module (assign, book, ...).
* @param instanceIdParam Param name for instance ID gathering. Only if set.
*/
constructor(
public addon: string,
public modName: string,
protected instanceIdParam?: string,
) {
super();
const pattern = instanceIdParam ?
'/mod/' + modName + '/view.php.*([&?](' + instanceIdParam + '|id)=\\d+)' :
'/mod/' + modName + '/view.php.*([&?]id=\\d+)';
// Match the view.php URL with an id param.
this.pattern = new RegExp(pattern);
this.featureName = 'CoreCourseModuleDelegate_' + addon;
}
/**
* Get the mod params necessary to open an activity.
*
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return List of params to pass to navigateToModule / navigateToModuleByInstance.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getPageParams(url: string, params: Params, courseId?: number): Params {
return [];
}
/**
* Get the list of actions for a link (url).
*
* @param siteIds List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return List of (or promise resolved with list of) actions.
*/
getActions(
siteIds: string[], // eslint-disable-line @typescript-eslint/no-unused-vars
url: string, // eslint-disable-line @typescript-eslint/no-unused-vars
params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars
courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars
): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [];
/*
courseId = courseId || params.courseid || params.cid;
const pageParams = this.getPageParams(url, params, courseId);
if (this.instanceIdParam && typeof params[this.instanceIdParam] != 'undefined') {
const instanceId = parseInt(params[this.instanceIdParam], 10);
return [{
action: (siteId): void => {
this.courseHelper.navigateToModuleByInstance(instanceId, this.modName, siteId, courseId, undefined,
this.useModNameToGetModule, pageParams);
},
}];
}
return [{
action: (siteId): void => {
this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined,
this.useModNameToGetModule ? this.modName : undefined, pageParams);
},
}];
*/
}
}

View File

@ -0,0 +1,73 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreContentLinksHelper } from '../services/contentlinks.helper';
import { CoreContentLinksHandlerBase } from './base-handler';
import { Translate } from '@/app/singletons/core.singletons';
import { Params } from '@angular/router';
import { CoreContentLinksAction } from '../services/contentlinks.delegate';
/**
* Handler to handle URLs pointing to a list of a certain type of modules.
*/
export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBase {
/**
* The title to use in the new page. If not defined, the app will try to calculate it.
*/
protected title = '';
/**
* Construct the handler.
*
* @param linkHelper The CoreContentLinksHelperProvider instance.
* @param translate The TranslateService instance.
* @param addon Name of the addon as it's registered in course delegate. It'll be used to check if it's disabled.
* @param modName Name of the module (assign, book, ...).
*/
constructor(
public addon: string,
public modName: string,
) {
super();
// Match the view.php URL with an id param.
this.pattern = new RegExp('/mod/' + modName + '/index.php.*([&?]id=\\d+)');
this.featureName = 'CoreCourseModuleDelegate_' + addon;
}
/**
* Get the list of actions for a link (url).
*
* @param siteIds List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
return [{
action: (siteId): void => {
const stateParams = {
courseId: params.id,
modName: this.modName,
title: this.title || Translate.instance.instant('addon.mod_' + this.modName + '.modulenameplural'),
};
CoreContentLinksHelper.instance.goInSite('CoreCourseListModTypePage @todo', stateParams, siteId);
},
}];
}
}

View File

@ -0,0 +1,8 @@
{
"chooseaccount": "Choose account",
"chooseaccounttoopenlink": "Choose an account to open the link with.",
"confirmurlothersite": "This link belongs to another site. Do you want to open it?",
"errornoactions": "Couldn't find an action to perform with this link.",
"errornosites": "Couldn't find any site to handle this link.",
"errorredirectothersite": "The redirect URL cannot point to a different site."
}

View File

@ -0,0 +1,31 @@
<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.contentlinks.chooseaccount' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<ion-list>
<ion-item class="ion-text-wrap">
<p class="item-heading">{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}</p>
<p>{{ url }}</p>
</ion-item>
<ion-item *ngFor="let site of sites" (click)="siteClicked(site.id)" detail="false">
<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'">
</ion-avatar>
<h2>{{site.fullName}}</h2>
<p><core-format-text [text]="site.siteName" clean="true" [siteId]="site.id"></core-format-text></p>
<p>{{site.siteUrl}}</p>
</ion-item>
<ion-item>
<ion-button expand="block" (click)="cancel()">{{ 'core.login.cancel' | translate }}</ion-button>
</ion-item>
</ion-list>
</core-loading>
</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 { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreContentLinksChooseSitePage } from './choose-site.page';
const routes: Routes = [
{
path: '',
component: CoreContentLinksChooseSitePage,
},
];
@NgModule({
declarations: [
CoreContentLinksChooseSitePage,
],
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
],
})
export class CoreContentLinksChooseSitePageModule {}

View File

@ -0,0 +1,122 @@
// (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 { NavController } from '@ionic/angular';
import { CoreSiteBasicInfo, CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { Translate } from '@singletons/core.singletons';
import { CoreLoginHelper } from '@core/login/services/helper';
import { CoreContentLinksAction } from '../../services/contentlinks.delegate';
import { CoreContentLinksHelper } from '../../services/contentlinks.helper';
import { ActivatedRoute } from '@angular/router';
import { CoreError } from '@classes/errors/error';
/**
* Page to display the list of sites to choose one to perform a content link action.
*
* @todo Include routing and testing.
*/
@Component({
selector: 'page-core-content-links-choose-site',
templateUrl: 'choose-site.html',
})
export class CoreContentLinksChooseSitePage implements OnInit {
url: string;
sites: CoreSiteBasicInfo[] = [];
loaded = false;
protected action?: CoreContentLinksAction;
protected isRootURL = false;
constructor(
route: ActivatedRoute,
protected navCtrl: NavController,
) {
this.url = route.snapshot.queryParamMap.get('url')!;
}
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
if (!this.url) {
return this.leaveView();
}
let siteIds: string[] | undefined = [];
try {
// Check if it's the root URL.
const data = await CoreSites.instance.isStoredRootURL(this.url);
if (data.site) {
// It's the root URL.
this.isRootURL = true;
siteIds = data.siteIds;
} else if (data.siteIds.length) {
// Not root URL, but the URL belongs to at least 1 site. Check if there is any action to treat the link.
this.action = await CoreContentLinksHelper.instance.getFirstValidActionFor(this.url);
if (!this.action) {
throw new CoreError(Translate.instance.instant('core.contentlinks.errornoactions'));
}
siteIds = this.action.sites;
} else {
// No sites to treat the URL.
throw new CoreError(Translate.instance.instant('core.contentlinks.errornosites'));
}
// Get the sites that can perform the action.
this.sites = await CoreSites.instance.getSites(siteIds);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.contentlinks.errornosites', true);
this.leaveView();
}
this.loaded = true;
}
/**
* Cancel.
*/
cancel(): void {
this.leaveView();
}
/**
* Perform the action on a certain site.
*
* @param siteId Site ID.
*/
siteClicked(siteId: string): void {
if (this.isRootURL) {
CoreLoginHelper.instance.redirect('', {}, siteId);
} else if (this.action) {
this.action.action(siteId);
}
}
/**
* Cancel and leave the view.
*/
protected async leaveView(): Promise<void> {
try {
await CoreSites.instance.logout();
} finally {
await this.navCtrl.navigateRoot('/login/sites');
}
}
}

View File

@ -0,0 +1,309 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreLogger } from '@singletons/logger';
import { CoreSites } from '@services/sites';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { Params } from '@angular/router';
/**
* Interface that all handlers must implement.
*/
export interface CoreContentLinksHandler {
/**
* A name to identify the handler.
*/
name: string;
/**
* Handler's priority. The highest priority is treated first.
*/
priority?: number;
/**
* Whether the isEnabled function should be called for all the users in a site. It should be true only if the isEnabled call
* can return different values for different users in same site.
*/
checkAllUsers?: boolean;
/**
* Name of the feature this handler is related to.
* It will be used to check if the feature is disabled (@see CoreSite.isFeatureDisabled).
*/
featureName?: string;
/**
* Get the list of actions for a link (url).
*
* @param siteIds List of sites the URL belongs to.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @param data Extra data to handle the URL.
* @return List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: Params, courseId?: number, data?: unknown):
CoreContentLinksAction[] | Promise<CoreContentLinksAction[]>;
/**
* Check if a URL is handled by this handler.
*
* @param url The URL to check.
* @return Whether the URL is handled by this handler
*/
handles(url: string): boolean;
/**
* If the URL is handled by this handler, return the site URL.
*
* @param url The URL to check.
* @return Site URL if it is handled, undefined otherwise.
*/
getSiteUrl(url: string): string | undefined;
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param siteId The site ID.
* @param url The URL to treat.
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param courseId Course ID related to the URL. Optional but recommended.
* @return Whether the handler is enabled for the URL and site.
*/
isEnabled?(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise<boolean>;
}
/**
* Action to perform when a link is clicked.
*/
export interface CoreContentLinksAction {
/**
* A message to identify the action. Default: 'core.view'.
*/
message?: string;
/**
* Name of the icon of the action. Default: 'fas-eye'.
*/
icon?: string;
/**
* IDs of the sites that support the action.
*/
sites?: string[];
/**
* Action to perform when the link is clicked.
*
* @param siteId The site ID.
*/
action(siteId: string): void;
}
/**
* Actions and priority for a handler and URL.
*/
export interface CoreContentLinksHandlerActions {
/**
* Handler's priority.
*/
priority: number;
/**
* List of actions.
*/
actions: CoreContentLinksAction[];
}
/**
* Delegate to register handlers to handle links.
*/
@Injectable({
providedIn: 'root',
})
export class CoreContentLinksDelegate {
protected logger: CoreLogger;
protected handlers: { [s: string]: CoreContentLinksHandler } = {}; // All registered handlers.
constructor() {
this.logger = CoreLogger.getInstance('CoreContentLinksDelegate');
}
/**
* Get the list of possible actions to do for a URL.
*
* @param url URL to handle.
* @param courseId Course ID related to the URL. Optional but recommended.
* @param username Username to use to filter sites.
* @param data Extra data to handle the URL.
* @return Promise resolved with the actions.
*/
async getActionsFor(url: string, courseId?: number, username?: string, data?: unknown): Promise<CoreContentLinksAction[]> {
if (!url) {
return [];
}
// Get the list of sites the URL belongs to.
const siteIds = await CoreSites.instance.getSiteIdsFromUrl(url, true, username);
const linkActions: CoreContentLinksHandlerActions[] = [];
const promises: Promise<void>[] = [];
const params = CoreUrlUtils.instance.extractUrlParams(url);
for (const name in this.handlers) {
const handler = this.handlers[name];
const checkAll = handler.checkAllUsers;
const isEnabledFn = this.isHandlerEnabled.bind(this, handler, url, params, courseId);
if (!handler.handles(url)) {
// Invalid handler or it doesn't handle the URL. Stop.
continue;
}
// Filter the site IDs using the isEnabled function.
promises.push(CoreUtils.instance.filterEnabledSites(siteIds, isEnabledFn, checkAll).then(async (siteIds) => {
if (!siteIds.length) {
// No sites supported, no actions.
return;
}
const actions = await handler.getActions(siteIds, url, params, courseId, data);
if (actions && actions.length) {
// Set default values if any value isn't supplied.
actions.forEach((action) => {
action.message = action.message || 'core.view';
action.icon = action.icon || 'fas-eye';
action.sites = action.sites || siteIds;
});
// Add them to the list.
linkActions.push({
priority: handler.priority || 0,
actions: actions,
});
}
return;
}));
}
try {
await CoreUtils.instance.allPromises(promises);
} catch {
// Ignore errors.
}
// Sort link actions by priority.
return this.sortActionsByPriority(linkActions);
}
/**
* Get the site URL if the URL is supported by any handler.
*
* @param url URL to handle.
* @return Site URL if the URL is supported by any handler, undefined otherwise.
*/
getSiteUrl(url: string): string | void {
if (!url) {
return;
}
// Check if any handler supports this URL.
for (const name in this.handlers) {
const handler = this.handlers[name];
const siteUrl = handler.getSiteUrl(url);
if (siteUrl) {
return siteUrl;
}
}
}
/**
* Check if a handler is enabled for a certain site and URL.
*
* @param handler Handler to check.
* @param url The URL to check.
* @param params The params of the URL
* @param courseId Course ID the URL belongs to (can be undefined).
* @param siteId The site ID to check.
* @return Promise resolved with boolean: whether the handler is enabled.
*/
protected async isHandlerEnabled(
handler: CoreContentLinksHandler,
url: string,
params: Params,
courseId: number,
siteId: string,
): Promise<boolean> {
let disabled = false;
if (handler.featureName) {
// Check if the feature is disabled.
disabled = await CoreSites.instance.isFeatureDisabled(handler.featureName, siteId);
}
if (disabled) {
return false;
}
if (!handler.isEnabled) {
// Handler doesn't implement isEnabled, assume it's enabled.
return true;
}
return handler.isEnabled(siteId, url, params, courseId);
}
/**
* Register a handler.
*
* @param handler The handler to register.
* @return True if registered successfully, false otherwise.
*/
registerHandler(handler: CoreContentLinksHandler): boolean {
if (typeof this.handlers[handler.name] !== 'undefined') {
this.logger.log(`Addon '${handler.name}' already registered`);
return false;
}
this.logger.log(`Registered addon '${handler.name}'`);
this.handlers[handler.name] = handler;
return true;
}
/**
* Sort actions by priority.
*
* @param actions Actions to sort.
* @return Sorted actions.
*/
protected sortActionsByPriority(actions: CoreContentLinksHandlerActions[]): CoreContentLinksAction[] {
let sorted: CoreContentLinksAction[] = [];
// Sort by priority.
actions = actions.sort((a, b) => (a.priority || 0) <= (b.priority || 0) ? 1 : -1);
// Fill result array.
actions.forEach((entry) => {
sorted = sorted.concat(entry.actions);
});
return sorted;
}
}

View File

@ -0,0 +1,246 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { NavController } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreLoginHelper } from '@core/login/services/helper';
import { CoreContentLinksDelegate, CoreContentLinksAction } from './contentlinks.delegate';
import { CoreSite } from '@classes/site';
import { CoreMainMenu } from '@core/mainmenu/services/mainmenu';
import { makeSingleton, NgZone, Translate } from '@singletons/core.singletons';
import { Params } from '@angular/router';
/**
* Service that provides some features regarding content links.
*/
@Injectable({
providedIn: 'root',
})
export class CoreContentLinksHelperProvider {
constructor(
protected contentLinksDelegate: CoreContentLinksDelegate,
protected navCtrl: NavController,
) { }
/**
* Check whether a link can be handled by the app.
*
* @param url URL to handle.
* @param courseId Unused param: Course ID related to the URL.
* @param username Username to use to filter sites.
* @param checkRoot Whether to check if the URL is the root URL of a site.
* @return Promise resolved with a boolean: whether the URL can be handled.
*/
async canHandleLink(url: string, courseId?: number, username?: string, checkRoot?: boolean): Promise<boolean> {
try {
if (checkRoot) {
const data = await CoreSites.instance.isStoredRootURL(url, username);
if (data.site) {
// URL is the root of the site, can handle it.
return true;
}
}
const action = await this.getFirstValidActionFor(url, undefined, username);
return !!action;
} catch {
return false;
}
}
/**
* Get the first valid action in the list of possible actions to do for a URL.
*
* @param url URL to handle.
* @param courseId Course ID related to the URL. Optional but recommended.
* @param username Username to use to filter sites.
* @param data Extra data to handle the URL.
* @return Promise resolved with the first valid action. Returns undefined if no valid action found..
*/
async getFirstValidActionFor(
url: string,
courseId?: number,
username?: string,
data?: unknown,
): Promise<CoreContentLinksAction | undefined> {
const actions = await this.contentLinksDelegate.getActionsFor(url, courseId, username, data);
if (!actions) {
return;
}
return actions.find((action) => action && action.sites && action.sites.length);
}
/**
* Goes to a certain page in a certain site. If the site is current site it will perform a regular navigation,
* otherwise it will 'redirect' to the other site.
*
* @param pageName Name of the page to go.
* @param pageParams Params to send to the page.
* @param siteId Site ID. If not defined, current site.
* @param checkMenu If true, check if the root page of a main menu tab. Only the page name will be checked.
* @return Promise resolved when done.
*/
goInSite(
pageName: string,
pageParams: Params,
siteId?: string,
checkMenu?: boolean,
): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const deferred = CoreUtils.instance.promiseDefer<void>();
// Execute the code in the Angular zone, so change detection doesn't stop working.
NgZone.instance.run(async () => {
try {
if (siteId == CoreSites.instance.getCurrentSiteId()) {
if (checkMenu) {
let isInMenu = false;
// Check if the page is in the main menu.
try {
isInMenu = await CoreMainMenu.instance.isCurrentMainMenuHandler(pageName);
} catch {
isInMenu = false;
}
if (isInMenu) {
// Just select the tab. @todo test.
CoreLoginHelper.instance.loadPageInMainMenu(pageName, pageParams);
} else {
await this.navCtrl.navigateForward(pageName, { queryParams: pageParams });
}
} else {
await this.navCtrl.navigateForward(pageName, { queryParams: pageParams });
}
} else {
await CoreLoginHelper.instance.redirect(pageName, pageParams, siteId);
}
deferred.resolve();
} catch (error) {
deferred.reject(error);
}
});
return deferred.promise;
}
/**
* Go to the page to choose a site.
*
* @param url URL to treat.
* @todo set correct root.
*/
async goToChooseSite(url: string): Promise<void> {
await this.navCtrl.navigateRoot('CoreContentLinksChooseSitePage @todo', { queryParams: { url } });
}
/**
* Handle a link.
*
* @param url URL to handle.
* @param username Username related with the URL. E.g. in 'http://myuser@m.com', url would be 'http://m.com' and
* the username 'myuser'. Don't use it if you don't want to filter by username.
* @param checkRoot Whether to check if the URL is the root URL of a site.
* @param openBrowserRoot Whether to open in browser if it's root URL and it belongs to current site.
* @return Promise resolved with a boolean: true if URL was treated, false otherwise.
*/
async handleLink(
url: string,
username?: string,
checkRoot?: boolean,
openBrowserRoot?: boolean,
): Promise<boolean> {
try {
if (checkRoot) {
const data = await CoreSites.instance.isStoredRootURL(url, username);
if (data.site) {
// URL is the root of the site.
this.handleRootURL(data.site, openBrowserRoot);
return true;
}
}
// Check if the link should be treated by some component/addon.
const action = await this.getFirstValidActionFor(url, undefined, username);
if (!action) {
return false;
}
if (!CoreSites.instance.isLoggedIn()) {
// No current site. Perform the action if only 1 site found, choose the site otherwise.
if (action.sites?.length == 1) {
action.action(action.sites[0]);
} else {
this.goToChooseSite(url);
}
} else if (action.sites?.length == 1 && action.sites[0] == CoreSites.instance.getCurrentSiteId()) {
// Current site.
action.action(action.sites[0]);
} else {
try {
// Not current site or more than one site. Ask for confirmation.
await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.contentlinks.confirmurlothersite'));
if (action.sites?.length == 1) {
action.action(action.sites[0]);
} else {
this.goToChooseSite(url);
}
} catch {
// User canceled.
}
}
return true;
} catch {
// Ignore errors.
}
return false;
}
/**
* Handle a root URL of a site.
*
* @param site Site to handle.
* @param openBrowserRoot Whether to open in browser if it's root URL and it belongs to current site.
* @param checkToken Whether to check that token is the same to verify it's current site. If false or not defined,
* only the URL will be checked.
* @return Promise resolved when done.
*/
async handleRootURL(site: CoreSite, openBrowserRoot?: boolean, checkToken?: boolean): Promise<void> {
const currentSite = CoreSites.instance.getCurrentSite();
if (currentSite && currentSite.getURL() == site.getURL() && (!checkToken || currentSite.getToken() == site.getToken())) {
// Already logged in.
if (openBrowserRoot) {
return site.openInBrowserWithAutoLogin(site.getURL());
}
} else {
// Login in the site.
return CoreLoginHelper.instance.redirect('', {}, site.getId());
}
}
}
export class CoreContentLinksHelper extends makeSingleton(CoreContentLinksHelperProvider) {}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '../services/delegate';
import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '../services/mainmenu.delegate';
/**
* Handler to add Home into main menu.

View File

@ -21,7 +21,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreMainMenuDelegate } from './services/delegate';
import { CoreMainMenuDelegate } from './services/mainmenu.delegate';
import { CoreMainMenuRoutingModule } from './mainmenu-routing.module';
import { CoreMainMenuPage } from './pages/menu/menu.page';

View File

@ -44,7 +44,7 @@ export class CoreHomePage implements OnInit {
* Initialize the component.
*/
ngOnInit(): void {
this.subscription = this.homeDelegate.getHandlers().subscribe((handlers) => {
this.subscription = this.homeDelegate.getHandlersObservable().subscribe((handlers) => {
handlers && this.initHandlers(handlers);
});
}

View File

@ -21,7 +21,7 @@ import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites';
import { CoreEvents, CoreEventObserver, CoreEventLoadPageMainMenuData } from '@singletons/events';
import { CoreMainMenu } from '../../services/mainmenu';
import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/delegate';
import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu.delegate';
import { CoreDomUtils } from '@/app/services/utils/dom';
import { Translate } from '@/app/singletons/core.singletons';
@ -97,7 +97,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy {
}
});
this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => {
this.subscription = this.menuDelegate.getHandlersObservable().subscribe((handlers) => {
// Remove the handlers that should only appear in the More menu.
this.allHandlers = handlers.filter((handler) => !handler.onlyInMore);

View File

@ -70,7 +70,8 @@
<h2>{{ 'core.mainmenu.help' | translate }}</h2>
</ion-label>
</ion-item>
<ion-item button (click)="openSitePreferences()" title="{{ 'core.settings.preferences' | translate }}" detail>
<ion-item button router-direction="forward" routerLink="preferences"
title="{{ 'core.settings.preferences' | translate }}" detail>
<ion-icon name="fa-wrench" slot="start"></ion-icon>
<ion-label>
<h2>{{ 'core.settings.preferences' | translate }}</h2>

View File

@ -19,7 +19,7 @@ import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreSiteInfo } from '@classes/site';
import { CoreLoginHelper } from '@core/login/services/helper';
import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../services/delegate';
import { CoreMainMenuDelegate, CoreMainMenuHandlerData } from '../../services/mainmenu.delegate';
import { CoreMainMenu, CoreMainMenuCustomItem } from '../../services/mainmenu';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
@ -69,7 +69,7 @@ export class CoreMainMenuMorePage implements OnInit, OnDestroy {
*/
ngOnInit(): void {
// Load the handlers.
this.subscription = this.menuDelegate.getHandlers().subscribe((handlers) => {
this.subscription = this.menuDelegate.getHandlersObservable().subscribe((handlers) => {
this.allHandlers = handlers;
this.initHandlers();
@ -149,20 +149,6 @@ export class CoreMainMenuMorePage implements OnInit, OnDestroy {
// @todo
}
/**
* Open app settings page.
*/
openAppSettings(): void {
// @todo
}
/**
* Open site settings page.
*/
openSitePreferences(): void {
// @todo
}
/**
* Scan and treat a QR code.
*/

View File

@ -1,177 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { Subject, BehaviorSubject } from 'rxjs';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { CoreEvents } from '@singletons/events';
/**
* Interface that all main menu handlers must implement.
*/
export interface CoreMainMenuHandler extends CoreDelegateHandler {
/**
* The highest priority is displayed first.
*/
priority?: number;
/**
* Returns the data needed to render the handler.
*
* @return Data.
*/
getDisplayData(): CoreMainMenuHandlerData;
}
/**
* Data needed to render a main menu handler. It's returned by the handler.
*/
export interface CoreMainMenuHandlerData {
/**
* Name of the page to load for the handler.
*/
page: string;
/**
* Title to display for the handler.
*/
title: string;
/**
* Name of the icon to display for the handler.
*/
icon: string; // Name of the icon to display in the tab.
/**
* Class to add to the displayed handler.
*/
class?: string;
/**
* If the handler has badge to show or not.
*/
showBadge?: boolean;
/**
* Text to display on the badge. Only used if showBadge is true.
*/
badge?: string;
/**
* If true, the badge number is being loaded. Only used if showBadge is true.
*/
loading?: boolean;
/**
* Params to pass to the page.
*/
pageParams?: Params;
/**
* Whether the handler should only appear in More menu.
*/
onlyInMore?: boolean;
}
/**
* Data returned by the delegate for each handler.
*/
export interface CoreMainMenuHandlerToDisplay extends CoreMainMenuHandlerData {
/**
* Name of the handler.
*/
name?: string;
/**
* Priority of the handler.
*/
priority?: number;
/**
* Hide tab. Used then resizing.
*/
hide?: boolean;
}
/**
* Service to interact with plugins to be shown in the main menu. Provides functions to register a plugin
* and notify an update in the data.
*/
@Injectable({
providedIn: 'root',
})
export class CoreMainMenuDelegate extends CoreDelegate {
protected loaded = false;
protected siteHandlers: Subject<CoreMainMenuHandlerToDisplay[]> = new BehaviorSubject<CoreMainMenuHandlerToDisplay[]>([]);
protected featurePrefix = 'CoreMainMenuDelegate_';
constructor() {
super('CoreMainMenuDelegate', true);
CoreEvents.on(CoreEvents.LOGOUT, this.clearSiteHandlers.bind(this));
}
/**
* Check if handlers are loaded.
*
* @return True if handlers are loaded, false otherwise.
*/
areHandlersLoaded(): boolean {
return this.loaded;
}
/**
* Clear current site handlers. Reserved for core use.
*/
protected clearSiteHandlers(): void {
this.loaded = false;
this.siteHandlers.next([]);
}
/**
* Get the handlers for the current site.
*
* @return An observable that will receive the handlers.
*/
getHandlers(): Subject<CoreMainMenuHandlerToDisplay[]> {
return this.siteHandlers;
}
/**
* Update handlers Data.
*/
updateData(): void {
const displayData: CoreMainMenuHandlerToDisplay[] = [];
for (const name in this.enabledHandlers) {
const handler = <CoreMainMenuHandler> this.enabledHandlers[name];
const data = <CoreMainMenuHandlerToDisplay> handler.getDisplayData();
data.name = name;
data.priority = handler.priority;
displayData.push(data);
}
// Sort them by priority.
displayData.sort((a, b) => (b.priority || 0) - (a.priority || 0));
this.loaded = true;
this.siteHandlers.next(displayData);
}
}

View File

@ -14,27 +14,14 @@
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { Subject, BehaviorSubject } from 'rxjs';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { CoreEvents } from '@singletons/events';
import { CoreDelegateDisplayHandler, CoreDelegateToDisplay } from '@classes/delegate';
import { CoreSortedDelegate } from '@classes/delegate-sorted';
/**
* Interface that all main menu handlers must implement.
* Interface that all home handlers must implement.
*/
export interface CoreHomeHandler extends CoreDelegateHandler {
/**
* The highest priority is displayed first.
*/
priority?: number;
/**
* Returns the data needed to render the handler.
*
* @return Data.
*/
getDisplayData(): CoreHomeHandlerData;
}
export type CoreHomeHandler = CoreDelegateDisplayHandler<CoreHomeHandlerToDisplay>;
/**
* Data needed to render a main menu handler. It's returned by the handler.
@ -84,17 +71,7 @@ export interface CoreHomeHandlerData {
/**
* Data returned by the delegate for each handler.
*/
export interface CoreHomeHandlerToDisplay extends CoreHomeHandlerData {
/**
* Name of the handler.
*/
name?: string;
/**
* Priority of the handler.
*/
priority?: number;
export interface CoreHomeHandlerToDisplay extends CoreDelegateToDisplay, CoreHomeHandlerData {
/**
* Priority to select handler.
*/
@ -108,65 +85,12 @@ export interface CoreHomeHandlerToDisplay extends CoreHomeHandlerData {
@Injectable({
providedIn: 'root',
})
export class CoreHomeDelegate extends CoreDelegate {
export class CoreHomeDelegate extends CoreSortedDelegate<CoreHomeHandlerToDisplay, CoreHomeHandler> {
protected loaded = false;
protected siteHandlers: Subject<CoreHomeHandlerToDisplay[]> = new BehaviorSubject<CoreHomeHandlerToDisplay[]>([]);
protected featurePrefix = 'CoreHomeDelegate_';
constructor() {
super('CoreHomeDelegate', true);
CoreEvents.on(CoreEvents.LOGOUT, this.clearSiteHandlers.bind(this));
}
/**
* Check if handlers are loaded.
*
* @return True if handlers are loaded, false otherwise.
*/
areHandlersLoaded(): boolean {
return this.loaded;
}
/**
* Clear current site handlers. Reserved for core use.
*/
protected clearSiteHandlers(): void {
this.loaded = false;
this.siteHandlers.next([]);
}
/**
* Get the handlers for the current site.
*
* @return An observable that will receive the handlers.
*/
getHandlers(): Subject<CoreHomeHandlerToDisplay[]> {
return this.siteHandlers;
}
/**
* Update handlers Data.
*/
updateData(): void {
const displayData: CoreHomeHandlerToDisplay[] = [];
for (const name in this.enabledHandlers) {
const handler = <CoreHomeHandler> this.enabledHandlers[name];
const data = <CoreHomeHandlerToDisplay> handler.getDisplayData();
data.name = name;
data.priority = handler.priority;
displayData.push(data);
}
// Sort them by priority.
displayData.sort((a, b) => (b.priority || 0) - (a.priority || 0));
this.loaded = true;
this.siteHandlers.next(displayData);
super('CoreHomeDelegate');
}
}

View File

@ -0,0 +1,101 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreDelegateDisplayHandler, CoreDelegateToDisplay } from '@classes/delegate';
import { CoreSortedDelegate } from '@classes/delegate-sorted';
/**
* Interface that all main menu handlers must implement.
*/
export type CoreMainMenuHandler = CoreDelegateDisplayHandler<CoreMainMenuHandlerToDisplay>;
/**
* Data needed to render a main menu handler. It's returned by the handler.
*/
export interface CoreMainMenuHandlerData {
/**
* Name of the page to load for the handler.
*/
page: string;
/**
* Title to display for the handler.
*/
title: string;
/**
* Name of the icon to display for the handler.
*/
icon: string; // Name of the icon to display in the tab.
/**
* Class to add to the displayed handler.
*/
class?: string;
/**
* If the handler has badge to show or not.
*/
showBadge?: boolean;
/**
* Text to display on the badge. Only used if showBadge is true.
*/
badge?: string;
/**
* If true, the badge number is being loaded. Only used if showBadge is true.
*/
loading?: boolean;
/**
* Params to pass to the page.
*/
pageParams?: Params;
/**
* Whether the handler should only appear in More menu.
*/
onlyInMore?: boolean;
}
/**
* Data returned by the delegate for each handler.
*/
export interface CoreMainMenuHandlerToDisplay extends CoreDelegateToDisplay, CoreMainMenuHandlerData {
/**
* Hide tab. Used then resizing.
*/
hide?: boolean;
}
/**
* Service to interact with plugins to be shown in the main menu. Provides functions to register a plugin
* and notify an update in the data.
*/
@Injectable({
providedIn: 'root',
})
export class CoreMainMenuDelegate extends CoreSortedDelegate<CoreMainMenuHandlerToDisplay, CoreMainMenuHandler> {
protected featurePrefix = 'CoreMainMenuDelegate_';
constructor() {
super('CoreMainMenuDelegate');
}
}

View File

@ -19,7 +19,7 @@ import { CoreLang } from '@services/lang';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreConstants } from '@core/constants';
import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from './delegate';
import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from './mainmenu.delegate';
import { makeSingleton } from '@singletons/core.singletons';
/**
@ -47,7 +47,7 @@ export class CoreMainMenuProvider {
getCurrentMainMenuHandlers(): Promise<CoreMainMenuHandlerToDisplay[]> {
const deferred = CoreUtils.instance.promiseDefer<CoreMainMenuHandlerToDisplay[]>();
const subscription = this.menuDelegate.getHandlers().subscribe((handlers) => {
const subscription = this.menuDelegate.getHandlersObservable().subscribe((handlers) => {
subscription?.unsubscribe();
// Remove the handlers that should only appear in the More menu.

View File

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreComponentsModule } from '@components/components.module';
import { CoreSearchBoxComponent } from './search-box/search-box';
@NgModule({
declarations: [
CoreSearchBoxComponent,
],
imports: [
CommonModule,
IonicModule,
FormsModule,
TranslateModule.forChild(),
CoreDirectivesModule,
CoreComponentsModule,
],
exports: [
CoreSearchBoxComponent,
],
})
export class CoreSearchComponentsModule {}

View File

@ -0,0 +1,28 @@
<ion-card>
<form (ngSubmit)="submitForm($event)" role="search" #searchForm>
<ion-item>
<ion-label>
<ion-input type="search" name="search" [(ngModel)]="searchText" [placeholder]="placeholder"
[autocorrect]="autocorrect" [spellcheck]="spellcheck" [core-auto-focus]="autoFocus"
[disabled]="disabled" role="searchbox" (ionFocus)="focus($event)"></ion-input>
</ion-label>
<ion-button slot="end" fill="clear" type="submit" size="small" [attr.aria-label]="searchLabel"
[disabled]="disabled || !searchText || (searchText.length < lengthCheck)">
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="showClear" slot="end" fill="clear" size="small"
[attr.aria-label]="'core.clearsearch' | translate" [disabled]="searched == '' || disabled"
(click)="clearForm()">
<ion-icon name="fas-backspace" slot="icon-only"></ion-icon>
</ion-button>
</ion-item>
<ion-list class="core-search-history" [hidden]="!historyShown">
<ion-item class="ion-text-wrap" *ngFor="let item of history"
(click)="historyClicked($event, item.searchedtext)" class="core-clickable" tabindex="1">
<ion-icon name="fa-history" slot="start">
</ion-icon>
{{item.searchedtext}}
</ion-item>
</ion-list>
</form>
</ion-card>

View File

@ -0,0 +1,27 @@
:host {
height: 80px;
display: block;
position: relative;
ion-card {
position: absolute;
left: 0;
right: 0;
z-index: 4;
}
ion-button.button:last-child {
margin-left: unset;
margin-inline-start: 10px;
}
.core-search-history {
max-height: calc(-120px + 80vh);
overflow-y: auto;
.item:hover {
--background: var(--gray-lighter);
cursor: pointer;
}
}
}

View File

@ -0,0 +1,173 @@
// (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, EventEmitter, OnInit } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreSearchHistory, CoreSearchHistoryItem } from '../../services/search-history';
import { Translate } from '@singletons/core.singletons';
/**
* Component to display a "search box".
*
* @description
* This component will display a standalone search box with its search button in order to have a better UX.
*
* Example usage:
* <core-search-box (onSubmit)="search($event)" [placeholder]="'core.courses.search' | translate"
* [searchLabel]="'core.courses.search' | translate" autoFocus="true"></core-search-box>
*/
@Component({
selector: 'core-search-box',
templateUrl: 'core-search-box.html',
styleUrls: ['search-box.scss'],
})
export class CoreSearchBoxComponent implements OnInit {
@Input() searchLabel?: string; // Label to be used on action button.
@Input() placeholder?: string; // Placeholder text for search text input.
@Input() autocorrect = 'on'; // Enables/disable Autocorrection on search text input.
@Input() spellcheck?: string | boolean = true; // Enables/disable Spellchecker on search text input.
@Input() autoFocus?: string | boolean; // Enables/disable Autofocus when entering view.
@Input() lengthCheck = 3; // Check value length before submit. If 0, any string will be submitted.
@Input() showClear = true; // Show/hide clear button.
@Input() disabled = false; // Disables the input text.
@Input() protected initialSearch = ''; // Initial search text.
/* If provided. It will save and display a history of searches for this particular Id.
* To use different history lists, place different Id.
* I.e. AddonMessagesContacts or CoreUserParticipants-6 (using the course Id).*/
@Input() protected searchArea = '';
@Output() onSubmit: EventEmitter<string>; // Send data when submitting the search form.
@Output() onClear: EventEmitter<void>; // Send event when clearing the search form.
formElement?: HTMLFormElement;
searched = ''; // Last search emitted.
searchText = '';
history: CoreSearchHistoryItem[] = [];
historyShown = false;
constructor() {
this.onSubmit = new EventEmitter<string>();
this.onClear = new EventEmitter<void>();
}
ngOnInit(): void {
this.searchLabel = this.searchLabel || Translate.instance.instant('core.search');
this.placeholder = this.placeholder || Translate.instance.instant('core.search');
this.spellcheck = CoreUtils.instance.isTrueOrOne(this.spellcheck);
this.showClear = CoreUtils.instance.isTrueOrOne(this.showClear);
this.searchText = this.initialSearch;
if (this.searchArea) {
this.loadHistory();
}
}
/**
* Form submitted.
*
* @param e Event.
*/
submitForm(e?: Event): void {
e && e.preventDefault();
e && e.stopPropagation();
if (this.searchText.length < this.lengthCheck) {
// The view should handle this case, but we check it here too just in case.
return;
}
if (this.searchArea) {
this.saveSearchToHistory(this.searchText);
}
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
this.historyShown = false;
this.searched = this.searchText;
this.onSubmit.emit(this.searchText);
}
/**
* Saves the search term onto the history.
*
* @param text Text to save.
* @return Promise resolved when done.
*/
protected async saveSearchToHistory(text: string): Promise<void> {
try {
await CoreSearchHistory.instance.insertOrUpdateSearchText(this.searchArea, text.toLowerCase());
} finally {
this.loadHistory();
}
}
/**
* Loads search history.
*
* @return Promise resolved when done.
*/
protected async loadHistory(): Promise<void> {
this.history = await CoreSearchHistory.instance.getSearchHistory(this.searchArea);
}
/**
* Select an item and use it for search text.
*
* @param e Event.
* @param text Selected text.
*/
historyClicked(e: Event, text: string): void {
if (this.searched != text) {
this.searchText = text;
this.submitForm(e);
}
}
/**
* Form submitted.
*/
clearForm(): void {
this.searched = '';
this.searchText = '';
this.onClear.emit();
}
/**
* @param event Focus event on input element.
*/
focus(event: CustomEvent): void {
this.historyShown = true;
if (!this.formElement) {
this.formElement = event.detail.target.closest('form');
this.formElement?.addEventListener('blur', () => {
// Wait the new element to be focused.
setTimeout(() => {
if (document.activeElement?.closest('form') != this.formElement) {
this.historyShown = false;
}
});
}, true);
}
}
}

View File

@ -0,0 +1,28 @@
// (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 { CoreSearchComponentsModule } from './components/components.module';
@NgModule({
declarations: [
],
imports: [
CoreSearchComponentsModule,
],
providers: [
CoreSearchComponentsModule,
],
})
export class CoreSearchModule {}

View File

@ -0,0 +1,133 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreSearchHistoryDBRecord, SEARCH_HISTORY_TABLE_NAME } from './search.history.db';
import { makeSingleton } from '@/app/singletons/core.singletons';
/**
* Service that enables adding a history to a search box.
*/
@Injectable({
providedIn: 'root',
})
export class CoreSearchHistoryProvider {
protected static readonly HISTORY_LIMIT = 10;
/**
* Get a search area history sorted by use.
*
* @param searchArea Search Area Name.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the list of items when done.
*/
async getSearchHistory(searchArea: string, siteId?: string): Promise<CoreSearchHistoryDBRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
const conditions = {
searcharea: searchArea,
};
const history: CoreSearchHistoryDBRecord[] = await site.getDb().getRecords(SEARCH_HISTORY_TABLE_NAME, conditions);
// Sorting by last used DESC.
return history.sort((a, b) => (b.lastused || 0) - (a.lastused || 0));
}
/**
* Controls search limit and removes the last item if overflows.
*
* @param searchArea Search area to control
* @param db SQLite DB where to perform the search.
* @return Resolved when done.
*/
protected async controlSearchLimit(searchArea: string, db: SQLiteDB): Promise<void> {
const items = await this.getSearchHistory(searchArea);
if (items.length > CoreSearchHistoryProvider.HISTORY_LIMIT) {
// Over the limit. Remove the last.
const lastItem = items.pop();
const searchItem = {
searcharea: lastItem!.searcharea,
searchedtext: lastItem!.searchedtext,
};
await db.deleteRecords(SEARCH_HISTORY_TABLE_NAME, searchItem);
}
}
/**
* Updates the search history item if exists.
*
* @param searchArea Area where the search has been performed.
* @param text Text of the performed text.
* @param db SQLite DB where to perform the search.
* @return True if exists, false otherwise.
*/
protected async updateExistingItem(searchArea: string, text: string, db: SQLiteDB): Promise<boolean> {
const searchItem = {
searcharea: searchArea,
searchedtext: text,
};
try {
const existingItem: CoreSearchHistoryDBRecord = await db.getRecord(SEARCH_HISTORY_TABLE_NAME, searchItem);
// If item exist, update time and number of times searched.
existingItem.lastused = Date.now();
existingItem.times++;
await db.updateRecords(SEARCH_HISTORY_TABLE_NAME, existingItem, searchItem);
return true;
} catch {
return false;
}
}
/**
* Inserts a searched term on the history.
*
* @param searchArea Area where the search has been performed.
* @param text Text of the performed text.
* @param siteId Site ID. If not defined, current site.
* @return Resolved when done.
*/
async insertOrUpdateSearchText(searchArea: string, text: string, siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
const db = site.getDb();
const exists = await this.updateExistingItem(searchArea, text, db);
if (!exists) {
// If item is new, control the history does not goes over the limit.
const searchItem: CoreSearchHistoryDBRecord = {
searcharea: searchArea,
searchedtext: text,
lastused: Date.now(),
times: 1,
};
await db.insertRecord(SEARCH_HISTORY_TABLE_NAME, searchItem);
await this.controlSearchLimit(searchArea, db);
}
}
}
export class CoreSearchHistory extends makeSingleton(CoreSearchHistoryProvider) {}

View File

@ -0,0 +1,67 @@
// (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 CoreSearchHistory service.
*/
export const SEARCH_HISTORY_TABLE_NAME = 'seach_history';
const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreSearchHistoryProvider',
version: 1,
tables: [
{
name: SEARCH_HISTORY_TABLE_NAME,
columns: [
{
name: 'searcharea',
type: 'TEXT',
notNull: true,
},
{
name: 'lastused',
type: 'INTEGER',
notNull: true,
},
{
name: 'times',
type: 'INTEGER',
notNull: true,
},
{
name: 'searchedtext',
type: 'TEXT',
notNull: true,
},
],
primaryKeys: ['searcharea', 'searchedtext'],
},
],
};
/**
* Search history item definition.
*/
export type CoreSearchHistoryDBRecord = {
searcharea: string; // Search area where the search has been performed.
lastused: number; // Timestamp of the last search.
searchedtext: string; // Text of the performed search.
times: number; // Times search has been performed (if previously in history).
};
export const initCoreSearchHistoryDB = (): void => {
registerSiteSchema(SITE_SCHEMA);
};

View File

@ -21,7 +21,7 @@
<ion-icon name="fa-user-shield" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.privacypolicy' | translate }}</ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" router-direction="forward" routerLink="deviceinfo" detail>
<ion-item button class="ion-text-wrap" (click)="openPage('deviceinfo')" detail>
<ion-icon name="fa-mobile" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.deviceinfo' | translate }}</ion-label>
</ion-item>

View File

@ -34,6 +34,12 @@ const routes: Routes = [
import('@core/settings/pages/deviceinfo/deviceinfo.page.module')
.then(m => m.CoreSettingsDeviceInfoPageModule),
},
{
path: 'licenses',
loadChildren: () =>
import('@core/settings/pages/licenses/licenses.page.module')
.then(m => m.CoreSettingsLicensesPageModule),
},
];
@NgModule({

View File

@ -12,13 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreSites } from '@services/sites';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { CoreConstants } from '@core/constants';
import { ActivatedRoute, Router } from '@angular/router';
import { CoreConstants } from '@core/constants';
import { CoreSites } from '@services/sites';
/**
* App settings about menu page.
*/
@Component({
selector: 'settings-about',
selector: 'page-core-app-settings-about',
templateUrl: 'about.html',
})
export class CoreSettingsAboutPage {
@ -29,6 +33,7 @@ export class CoreSettingsAboutPage {
constructor(
protected router: Router,
protected route: ActivatedRoute,
) {
const currentSite = CoreSites.instance.getCurrentSite();
@ -48,7 +53,7 @@ export class CoreSettingsAboutPage {
openPage(page: string): void {
// const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
// navCtrl.push(page);
this.router.navigate(['/settings/' + page]);
this.router.navigate([page], { relativeTo: this.route });
}
}

View File

@ -19,7 +19,7 @@
<ion-label>{{ 'core.settings.spaceusage' | translate }}</ion-label>
</ion-item>
<ion-item button (click)="openSettings('sync')" [class.core-split-item-selected]="'sync' == selectedPage" detail>
<ion-icon name="fa-sync" slot="start"></ion-icon>
<ion-icon name="fa-sync-alt" slot="start"></ion-icon>
<ion-label>{{ 'core.settings.synchronization' | translate }}</ion-label>
</ion-item>
<ion-item button *ngIf="isIOS" (click)="openSettings('sharedfiles', {manage: true})"

View File

@ -16,8 +16,11 @@ import { CoreApp } from '@services/app';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
/**
* App settings menu page.
*/
@Component({
selector: 'app-settings',
selector: 'page-core-app-settings',
templateUrl: 'app.html',
})
export class CoreSettingsAppPage implements OnInit {

View File

@ -54,9 +54,11 @@ interface CoreSettingsDeviceInfo {
localNotifAvailable: string;
}
/**
* Page that displays the device information.
*/
@Component({
selector: 'settings-deviceinfo',
selector: 'page-core-app-settings-deviceinfo',
templateUrl: 'deviceinfo.html',
styleUrls: ['deviceinfo.scss'],
})

View File

@ -32,17 +32,19 @@ const routes: Routes = [
];
@NgModule({
declarations: [
CoreSettingsGeneralPage,
],
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
FormsModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
FormsModule,
],
declarations: [
CoreSettingsGeneralPage,
],
exports: [RouterModule],
})
export class CoreSettingsGeneralPageModule {}

View File

@ -25,7 +25,7 @@ import { CoreSettingsHelper, CoreColorScheme } from '../../services/settings.hel
* Page that displays the general settings.
*/
@Component({
selector: 'page-core-settings-general',
selector: 'page-core-app-settings-general',
templateUrl: 'general.html',
styleUrls: ['general.scss'],
})

View File

@ -20,7 +20,6 @@ import { IonicModule } from '@ionic/angular';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreSettingsLicensesPage } from './licenses.page';
@ -32,9 +31,6 @@ const routes: Routes = [
];
@NgModule({
declarations: [
CoreSettingsLicensesPage,
],
imports: [
RouterModule.forChild(routes),
CommonModule,
@ -42,7 +38,10 @@ const routes: Routes = [
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
],
declarations: [
CoreSettingsLicensesPage,
],
exports: [RouterModule],
})
export class CoreSettingsLicensesPageModule {}

View File

@ -14,7 +14,7 @@
import { Component, OnInit } from '@angular/core';
import { CoreConstants } from '@core/constants';
import { Http } from '@/app/singletons/core.singletons';
import { Http } from '@singletons/core.singletons';
/**
* Defines license info
@ -35,7 +35,7 @@ interface CoreSettingsLicense {
* Page that displays the open source licenses information.
*/
@Component({
selector: 'page-core-settings-licenses',
selector: 'page-core-app-settings-licenses',
templateUrl: 'licenses.html',
})
export class CoreSettingsLicensesPage implements OnInit {

View File

@ -0,0 +1,80 @@
<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.settings.preferences' | translate}}</ion-title>
<ion-buttons slot="end">
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-list>
<ion-item *ngIf="siteInfo" class="ion-text-wrap">
<ion-label>
<h2>{{siteInfo!.fullname}}</h2>
<p>
<core-format-text [text]="siteName" contextLevel="system" [contextInstanceId]="0"
[wsNotFiltered]="true"></core-format-text>
</p>
<p>{{ siteUrl }}</p>
</ion-label>
</ion-item>
<ion-item-divider></ion-item-divider>
<ion-item *ngIf="isIOS"
(click)="openHandler('CoreSharedFilesListPage', {manage: true, siteId: siteId, hideSitePicker: true})"
[title]="'core.sharedfiles.sharedfiles' | translate"
[class.core-split-item-selected]="'CoreSharedFilesListPage' == selectedPage" details>
<ion-icon name="fas-folder" slot="start"></ion-icon>
<ion-label>
<h2>{{ 'core.sharedfiles.sharedfiles' | translate }}</h2>
</ion-label>
<ion-badge slot="end">{{ iosSharedFiles }}</ion-badge>
</ion-item>
<ion-item *ngFor="let handler of handlers" [ngClass]="['core-settings-handler', handler.class]"
(click)="openHandler(handler.page, handler.params)" [title]="handler.title | translate" details
[class.core-split-item-selected]="handler.page == selectedPage">
<ion-icon [name]="handler.icon" slot="start" *ngIf="handler.icon">
</ion-icon>
<ion-label>
<h2>{{ handler.title | translate}}</h2>
</ion-label>
</ion-item>
<ion-card>
<ion-item class="ion-text-wrap" *ngIf="spaceUsage">
<ion-label>
<h2 class="ion-text-wrap">{{ 'core.settings.spaceusage' | translate }} <ion-icon
name="fas-info-circle" color="secondary" [attr.aria-label]="'core.info' | translate"
(click)="showSpaceInfo()"></ion-icon>
</h2>
<p *ngIf="spaceUsage.spaceUsage">{{ spaceUsage.spaceUsage | coreBytesToSize }}</p>
</ion-label>
<ion-button fill="clear" color="danger" slot="end" (click)="deleteSiteStorage()"
[hidden]="spaceUsage.spaceUsage! + spaceUsage.cacheEntries! <= 0"
[attr.aria-label]="'core.settings.deletesitefilestitle' | translate">
<ion-icon name="fas-trash" slot="icon-only"></ion-icon>
</ion-button>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'core.settings.synchronizenow' | translate }} <ion-icon name="fas-info-circle"
color="secondary" [attr.aria-label]="'core.info' | translate" (click)="showSyncInfo()">
</ion-icon>
</h2>
</ion-label>
<ion-button fill="clear" slot="end" *ngIf="!isSynchronizing()" (click)="synchronize()"
[title]="siteName" [attr.aria-label]="'core.settings.synchronizenow' | translate">
<ion-icon name="fas-sync-alt" slot="icon-only"></ion-icon>
</ion-button>
<ion-spinner slot="end" *ngIf="isSynchronizing()"></ion-spinner>
</ion-item>
</ion-card>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,48 @@
// (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 { TranslateModule } from '@ngx-translate/core';
import { RouterModule, Routes } from '@angular/router';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreSitePreferencesPage } from './site.page';
const routes: Routes = [
{
path: '',
component: CoreSitePreferencesPage,
},
];
@NgModule({
declarations: [
CoreSitePreferencesPage,
],
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
],
})
export class CoreSitePreferencesPageModule {}

View File

@ -0,0 +1,217 @@
// (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, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { IonRefresher } from '@ionic/angular';
import { CoreSettingsDelegate, CoreSettingsHandlerData } from '../../services/settings.delegate';
import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
// import { CoreSplitViewComponent } from '@components/split-view/split-view';
// import { CoreSharedFiles } from '@core/sharedfiles/providers/sharedfiles';
import { CoreSettingsHelper, CoreSiteSpaceUsage } from '../../services/settings.helper';
import { CoreApp } from '@services/app';
import { CoreSiteInfo } from '@classes/site';
import { Translate } from '@singletons/core.singletons';
/**
* Page that displays the list of site settings pages.
*/
@Component({
selector: 'page-core-site-preferences',
templateUrl: 'site.html',
})
export class CoreSitePreferencesPage implements OnInit, OnDestroy {
// @ViewChild(CoreSplitViewComponent) splitviewCtrl?: CoreSplitViewComponent;
isIOS: boolean;
selectedPage?: string;
handlers: CoreSettingsHandlerData[] = [];
siteId: string;
siteInfo?: CoreSiteInfo;
siteName?: string;
siteUrl?: string;
spaceUsage: CoreSiteSpaceUsage = {
cacheEntries: 0,
spaceUsage: 0,
};
loaded = false;
iosSharedFiles = 0;
protected sitesObserver: CoreEventObserver;
protected isDestroyed = false;
constructor(
protected settingsDelegate: CoreSettingsDelegate,
protected route: ActivatedRoute,
protected router: Router, // Will be removed when splitview is implemented
) {
this.isIOS = CoreApp.instance.isIOS();
this.siteId = CoreSites.instance.getCurrentSiteId();
this.selectedPage = route.snapshot.paramMap.get('page') || undefined;
this.sitesObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, (data: CoreEventSiteUpdatedData) => {
if (data.siteId == this.siteId) {
this.refreshData();
}
});
}
/**
* View loaded.
*/
ngOnInit(): void {
this.fetchData().finally(() => {
this.loaded = true;
if (this.selectedPage) {
this.openHandler(this.selectedPage);
} /* else if (this.splitviewCtrl.isOn()) {
if (this.isIOS) {
this.openHandler('CoreSharedFilesListPage', { manage: true, siteId: this.siteId, hideSitePicker: true });
} else if (this.handlers.length > 0) {
this.openHandler(this.handlers[0].page, this.handlers[0].params);
}
}*/
});
}
/**
* Fetch Data.
*/
protected async fetchData(): Promise<void> {
this.handlers = this.settingsDelegate.getHandlers();
const currentSite = CoreSites.instance.getCurrentSite();
this.siteInfo = currentSite!.getInfo();
this.siteName = currentSite!.getSiteName();
this.siteUrl = currentSite!.getURL();
const promises: Promise<void>[] = [];
promises.push(CoreSettingsHelper.instance.getSiteSpaceUsage(this.siteId)
.then((spaceUsage) => {
this.spaceUsage = spaceUsage;
return;
}));
/* if (this.isIOS) {
promises.push(CoreSharedFiles.instance.getSiteSharedFiles(this.siteId)
.then((files) => {
this.iosSharedFiles = files.length;
return;
}));
}*/
await Promise.all(promises);
}
/**
* Syncrhonizes the site.
*/
async synchronize(): Promise<void> {
try {
// Using syncOnlyOnWifi false to force manual sync.
await CoreSettingsHelper.instance.synchronizeSite(false, this.siteId);
} catch (error) {
if (this.isDestroyed) {
return;
}
CoreDomUtils.instance.showErrorModalDefault(error, 'core.settings.errorsyncsite', true);
}
}
/**
* Returns true if site is beeing synchronized.
*
* @return True if site is beeing synchronized, false otherwise.
*/
isSynchronizing(): boolean {
return !!CoreSettingsHelper.instance.getSiteSyncPromise(this.siteId);
}
/**
* Refresh the data.
*
* @param refresher Refresher.
*/
refreshData(refresher?: CustomEvent<IonRefresher>): void {
this.fetchData().finally(() => {
refresher?.detail.complete();
});
}
/**
* Deletes files of a site and the tables that can be cleared.
*
* @param siteData Site object with space usage.
*/
async deleteSiteStorage(): Promise<void> {
try {
this.spaceUsage = await CoreSettingsHelper.instance.deleteSiteStorage(this.siteName || '', this.siteId);
} catch {
// Ignore cancelled confirmation modal.
}
}
/**
* Open a handler.
*
* @param page Page to open.
* @param params Params of the page to open.
*/
openHandler(page: string, params?: Params): void {
this.selectedPage = page;
// this.splitviewCtrl.push(page, params);
this.router.navigate([page], { relativeTo: this.route, queryParams: params });
}
/**
* Show information about space usage actions.
*/
showSpaceInfo(): void {
CoreDomUtils.instance.showAlert(
Translate.instance.instant('core.help'),
Translate.instance.instant('core.settings.spaceusagehelp'),
);
}
/**
* Show information about sync actions.
*/
showSyncInfo(): void {
CoreDomUtils.instance.showAlert(
Translate.instance.instant('core.help'),
Translate.instance.instant('core.settings.synchronizenowhelp'),
);
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
this.sitesObserver?.off();
}
}

View File

@ -0,0 +1,46 @@
<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.settings.spaceusage' | translate }}</ion-title>
<ion-buttons slot="end">
<!-- @todo <core-navbar-buttons></core-navbar-buttons>-->
<ion-button (click)="showInfo()" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher [disabled]="!loaded" (ionRefresh)="refreshData($event)" slot="fixed">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-item *ngFor="let site of sites" [class.core-selected-item]="site.id == currentSiteId">
<ion-label class="ion-text-wrap">
<h2>
<core-format-text [text]="site.siteName" clean="true" [siteId]="site.id"></core-format-text>
</h2>
<p class="ion-text-wrap">{{ site.fullName }}</p>
<p>{{ site.siteUrl }}</p>
</ion-label>
<p *ngIf="site.spaceUsage != null" slot="end">
{{ site.spaceUsage | coreBytesToSize }}
</p>
<ion-button fill="clear" color="danger" slot="end" (click)="deleteSiteStorage(site)"
[hidden]="site.spaceUsage! + site.cacheEntries! <= 0"
[attr.aria-label]="'core.settings.deletesitefilestitle' | translate">
<ion-icon name="fas-trash" slot="icon-only"></ion-icon>
</ion-button>
</ion-item>
<ion-item-divider>
<ion-label>
<h2>{{ 'core.settings.total' | translate }}</h2>
</ion-label>
<p slot="end" class="ion-margin-end">
{{ totals.spaceUsage | coreBytesToSize }}
</p>
</ion-item-divider>
</core-loading>
</ion-content>

View File

@ -0,0 +1,49 @@
// (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 { TranslateModule } from '@ngx-translate/core';
import { RouterModule, Routes } from '@angular/router';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreSettingsSpaceUsagePage } from './space-usage.page';
const routes: Routes = [
{
path: '',
component: CoreSettingsSpaceUsagePage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
],
declarations: [
CoreSettingsSpaceUsagePage,
],
exports: [RouterModule],
})
export class CoreSettingsSpaceUsagePageModule {}

View File

@ -0,0 +1,157 @@
// (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, OnDestroy, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreSiteBasicInfo, CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { Translate } from '@singletons/core.singletons';
import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events';
import { CoreSettingsHelper, CoreSiteSpaceUsage } from '../../services/settings.helper';
/**
* Page that displays the space usage settings.
*/
@Component({
selector: 'page-core-app-settings-space-usage',
templateUrl: 'space-usage.html',
})
export class CoreSettingsSpaceUsagePage implements OnInit, OnDestroy {
loaded = false;
sites: CoreSiteBasicInfoWithUsage[] = [];
currentSiteId = '';
totals: CoreSiteSpaceUsage = {
cacheEntries: 0,
spaceUsage: 0,
};
protected sitesObserver: CoreEventObserver;
constructor() {
this.currentSiteId = CoreSites.instance.getCurrentSiteId();
this.sitesObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, async (data: CoreEventSiteUpdatedData) => {
const site = await CoreSites.instance.getSite(data.siteId);
const siteEntry = this.sites.find((siteEntry) => siteEntry.id == site.id);
if (siteEntry) {
const siteInfo = site.getInfo();
siteEntry.siteName = site.getSiteName();
if (siteInfo) {
siteEntry.siteUrl = siteInfo.siteurl;
siteEntry.fullName = siteInfo.fullname;
}
}
});
}
/**
* View loaded.
*/
ngOnInit(): void {
this.loadSiteData().finally(() => {
this.loaded = true;
});
}
/**
* Convenience function to load site data/usage and calculate the totals.
*
* @return Resolved when done.
*/
protected async loadSiteData(): Promise<void> {
// Calculate total usage.
let totalSize = 0;
let totalEntries = 0;
this.sites = await CoreSites.instance.getSortedSites();
const settingsHelper = CoreSettingsHelper.instance;
// Get space usage.
await Promise.all(this.sites.map(async (site) => {
const siteInfo = await settingsHelper.getSiteSpaceUsage(site.id);
site.cacheEntries = siteInfo.cacheEntries;
site.spaceUsage = siteInfo.spaceUsage;
totalSize += site.spaceUsage || 0;
totalEntries += site.cacheEntries || 0;
}));
this.totals.spaceUsage = totalSize;
this.totals.cacheEntries = totalEntries;
}
/**
* Refresh the data.
*
* @param event Refresher event.
*/
refreshData(event?: CustomEvent<IonRefresher>): void {
this.loadSiteData().finally(() => {
event?.detail.complete();
});
}
/**
* Deletes files of a site and the tables that can be cleared.
*
* @param siteData Site object with space usage.
*/
async deleteSiteStorage(siteData: CoreSiteBasicInfoWithUsage): Promise<void> {
try {
const newInfo = await CoreSettingsHelper.instance.deleteSiteStorage(siteData.siteName || '', siteData.id);
this.totals.spaceUsage -= siteData.spaceUsage! - newInfo.spaceUsage;
this.totals.spaceUsage -= siteData.cacheEntries! - newInfo.cacheEntries;
siteData.spaceUsage = newInfo.spaceUsage;
siteData.cacheEntries = newInfo.cacheEntries;
} catch {
// Ignore cancelled confirmation modal.
}
}
/**
* Show information about space usage actions.
*/
showInfo(): void {
CoreDomUtils.instance.showAlert(
Translate.instance.instant('core.help'),
Translate.instance.instant('core.settings.spaceusagehelp'),
);
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.sitesObserver?.off();
}
}
/**
* Basic site info with space usage and cache entries that can be erased.
*/
export interface CoreSiteBasicInfoWithUsage extends CoreSiteBasicInfo {
cacheEntries?: number; // Number of cached entries that can be cleared.
spaceUsage?: number; // Space used in this site.
}

View File

@ -0,0 +1,47 @@
<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.settings.synchronization' | translate }}</ion-title>
<ion-buttons slot="end">
<!-- @todo <core-navbar-buttons></core-navbar-buttons>-->
<ion-button (click)="showInfo()" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="sitesLoaded">
<ion-item-divider>
<ion-label>
<h2>{{ 'core.settings.syncsettings' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'core.settings.enablesyncwifi' | translate }}</ion-label>
<ion-toggle slot="end" [(ngModel)]="syncOnlyOnWifi" (ngModelChange)="syncOnlyOnWifiChanged()">
</ion-toggle>
</ion-item>
<ion-item-divider>
<ion-label>
<h2>{{ 'core.settings.sites' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item *ngFor="let site of sites" [class.core-selected-item]="site.id == currentSiteId" class="ion-text-wrap">
<ion-label>
<h2>
<core-format-text [text]="site.siteName" clean="true" [siteId]="site.id"></core-format-text>
</h2>
<p>{{ site.fullName }}</p>
<p>{{ site.siteUrl }}</p>
</ion-label>
<ion-button fill="clear" slot="end" *ngIf="!isSynchronizing(site.id)" (click)="synchronize(site.id)"
[title]="site.siteName" [attr.aria-label]="'core.settings.synchronizenow' | translate">
<ion-icon name="fas-sync-alt" slot="icon-only"></ion-icon>
</ion-button>
<ion-spinner slot="end" *ngIf="isSynchronizing(site.id)"></ion-spinner>
</ion-item>
</core-loading>
</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 { TranslateModule } from '@ngx-translate/core';
import { RouterModule, Routes } from '@angular/router';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreSettingsSynchronizationPage } from './synchronization.page';
import { FormsModule } from '@angular/forms';
const routes: Routes = [
{
path: '',
component: CoreSettingsSynchronizationPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
FormsModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
],
declarations: [
CoreSettingsSynchronizationPage,
],
exports: [RouterModule],
})
export class CoreSettingsSynchronizationPageModule {}

View File

@ -0,0 +1,130 @@
// (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, OnDestroy, OnInit } from '@angular/core';
import { CoreConstants } from '@core/constants';
import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events';
import { CoreSites, CoreSiteBasicInfo } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreConfig } from '@services/config';
import { CoreSettingsHelper } from '@core/settings/services/settings.helper';
import { Translate } from '@singletons/core.singletons';
/**
* Page that displays the synchronization settings.
*/
@Component({
selector: 'page-core-app-settings-synchronization',
templateUrl: 'synchronization.html',
})
export class CoreSettingsSynchronizationPage implements OnInit, OnDestroy {
sites: CoreSiteBasicInfo[] = [];
sitesLoaded = false;
currentSiteId = '';
syncOnlyOnWifi = false;
protected isDestroyed = false;
protected sitesObserver: CoreEventObserver;
constructor() {
this.currentSiteId = CoreSites.instance.getCurrentSiteId();
this.sitesObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, async (data: CoreEventSiteUpdatedData) => {
const site = await CoreSites.instance.getSite(data.siteId);
const siteEntry = this.sites.find((siteEntry) => siteEntry.id == site.id);
if (siteEntry) {
const siteInfo = site.getInfo();
siteEntry.siteName = site.getSiteName();
if (siteInfo) {
siteEntry.siteUrl = siteInfo.siteurl;
siteEntry.fullName = siteInfo.fullname;
}
}
});
}
/**
* View loaded.
*/
async ngOnInit(): Promise<void> {
try {
this.sites = await CoreSites.instance.getSortedSites();
} catch {
// Ignore errors.
}
this.sitesLoaded = true;
this.syncOnlyOnWifi = await CoreConfig.instance.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, true);
}
/**
* Called when sync only on wifi setting is enabled or disabled.
*/
syncOnlyOnWifiChanged(): void {
CoreConfig.instance.set(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, this.syncOnlyOnWifi ? 1 : 0);
}
/**
* Syncrhonizes a site.
*
* @param siteId Site ID.
*/
async synchronize(siteId: string): Promise<void> {
// Using syncOnlyOnWifi false to force manual sync.
try {
await CoreSettingsHelper.instance.synchronizeSite(false, siteId);
} catch (error) {
if (this.isDestroyed) {
return;
}
CoreDomUtils.instance.showErrorModalDefault(error, 'core.settings.errorsyncsite', true);
}
}
/**
* Returns true if site is beeing synchronized.
*
* @param siteId Site ID.
* @return True if site is beeing synchronized, false otherwise.
*/
isSynchronizing(siteId: string): boolean {
return !!CoreSettingsHelper.instance.getSiteSyncPromise(siteId);
}
/**
* Show information about sync actions.
*/
showInfo(): void {
CoreDomUtils.instance.showAlert(
Translate.instance.instant('core.help'),
Translate.instance.instant('core.settings.synchronizenowhelp'),
);
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.isDestroyed = true;
this.sitesObserver?.off();
}
}

View File

@ -0,0 +1,74 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreDelegateDisplayHandler, CoreDelegateToDisplay } from '@classes/delegate';
import { CoreSortedDelegate } from '@classes/delegate-sorted';
/**
* Interface that all settings handlers must implement.
*/
export type CoreSettingsHandler = CoreDelegateDisplayHandler<CoreSettingsHandlerToDisplay>;
/**
* Data needed to render a setting handler. It's returned by the handler.
*/
export interface CoreSettingsHandlerData {
/**
* Name of the page to load for the handler.
*/
page: string;
/**
* Params list of the page to load for the handler.
*/
params?: Params;
/**
* Title to display for the handler.
*/
title: string;
/**
* Name of the icon to display for the handler.
*/
icon?: string; // Name of the icon to display in the menu.
/**
* Class to add to the displayed handler.
*/
class?: string;
}
/**
* Data returned by the delegate for each handler.
*/
export type CoreSettingsHandlerToDisplay = CoreDelegateToDisplay & CoreSettingsHandlerData;
/**
* Service to interact with addons to be shown in app settings. Provides functions to register a plugin
* and notify an update in the data.
*/
@Injectable({
providedIn: 'root',
})
export class CoreSettingsDelegate extends CoreSortedDelegate<CoreSettingsHandlerToDisplay, CoreSettingsHandler> {
constructor() {
super('CoreSettingsDelegate');
}
}

View File

@ -26,13 +26,14 @@ import { CoreConfig } from '@services/config';
import { CoreDomUtils } from '@services/utils/dom';
// import { CoreCourseProvider } from '@core/course/providers/course';
import { makeSingleton, Translate } from '@singletons/core.singletons';
import { CoreError } from '@classes/errors/error';
/**
* Object with space usage and cache entries that can be erased.
*/
export interface CoreSiteSpaceUsage {
cacheEntries?: number; // Number of cached entries that can be cleared.
spaceUsage?: number; // Space used in this site (total files + estimate of cache).
cacheEntries: number; // Number of cached entries that can be cleared.
spaceUsage: number; // Space used in this site (total files + estimate of cache).
}
/**
@ -257,7 +258,7 @@ export class CoreSettingsHelperProvider {
* @param siteId ID of the site.
* @return Sync promise or null if site is not being syncrhonized.
*/
async getSiteSyncPromise(siteId: string): Promise<void> {
getSiteSyncPromise(siteId: string): Promise<void> | void {
if (this.syncPromises[siteId]) {
return this.syncPromises[siteId];
}
@ -281,12 +282,12 @@ export class CoreSettingsHelperProvider {
if (site.isLoggedOut()) {
// Cannot sync logged out sites.
throw Translate.instance.instant('core.settings.cannotsyncloggedout');
throw new CoreError(Translate.instance.instant('core.settings.cannotsyncloggedout'));
} else if (hasSyncHandlers && !CoreApp.instance.isOnline()) {
// We need connection to execute sync.
throw Translate.instance.instant('core.settings.cannotsyncoffline');
throw new CoreError(Translate.instance.instant('core.settings.cannotsyncoffline'));
} else if (hasSyncHandlers && syncOnlyOnWifi && CoreApp.instance.isNetworkAccessLimited()) {
throw Translate.instance.instant('core.settings.cannotsyncwithoutwifi');
throw new CoreError(Translate.instance.instant('core.settings.cannotsyncwithoutwifi'));
}
const syncPromise = Promise.all([
@ -329,7 +330,7 @@ export class CoreSettingsHelperProvider {
// Local mobile was added. Throw invalid session to force reconnect and create a new token.
CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, site.getId());
throw Translate.instance.instant('core.lostconnection');
throw new CoreError(Translate.instance.instant('core.lostconnection'));
}
/**

View File

@ -23,6 +23,10 @@ const routes: Routes = [
path: 'settings',
loadChildren: () => import('@core/settings/settings.module').then(m => m.CoreSettingsModule),
},
{
path: 'preferences',
loadChildren: () => import('@core/settings/pages/site/site.page.module').then(m => m.CoreSitePreferencesPageModule),
},
];
@NgModule({

View File

@ -18,19 +18,27 @@ import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: 'about',
loadChildren: () => import('./pages/about/about.page.module').then( m => m.CoreSettingsAboutPageModule),
loadChildren: () => import('./pages/about/about.page.module').then(m => m.CoreSettingsAboutPageModule),
},
{
path: 'general',
loadChildren: () => import('./pages/general/general.page.module').then( m => m.CoreSettingsGeneralPageModule),
loadChildren: () => import('./pages/general/general.page.module').then(m => m.CoreSettingsGeneralPageModule),
},
{
path: 'licenses',
loadChildren: () => import('./pages/licenses/licenses.page.module').then( m => m.CoreSettingsLicensesPageModule),
path: 'spaceusage',
loadChildren: () =>
import('@core/settings/pages/space-usage/space-usage.page.module')
.then(m => m.CoreSettingsSpaceUsagePageModule),
},
{
path: 'sync',
loadChildren: () =>
import('@core/settings/pages/synchronization/synchronization.page.module')
.then(m => m.CoreSettingsSynchronizationPageModule),
},
{
path: '',
loadChildren: () => import('./pages/app/app.page.module').then( m => m.CoreSettingsAppPageModule),
loadChildren: () => import('./pages/app/app.page.module').then(m => m.CoreSettingsAppPageModule),
},
];

View File

@ -20,6 +20,5 @@ import { CoreSettingsRoutingModule } from './settings-routing.module';
imports: [
CoreSettingsRoutingModule,
],
declarations: [],
})
export class CoreSettingsModule {}

View File

@ -231,7 +231,7 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges {
if (!site.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(url)) {
this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken.
throw 'Site doesn\'t allow downloading files.';
throw new CoreError('Site doesn\'t allow downloading files.');
}
// Download images, tracks and posters if size is unknown.

View File

@ -0,0 +1,55 @@
// (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 { Pipe, PipeTransform } from '@angular/core';
import { CoreLogger } from '@singletons/logger';
import { CoreTextUtils } from '@services/utils/text';
/**
* Pipe to turn a number in bytes to a human readable size (e.g. 5,25 MB).
*/
@Pipe({
name: 'coreBytesToSize',
})
export class CoreBytesToSizePipe implements PipeTransform {
protected logger: CoreLogger;
constructor() {
this.logger = CoreLogger.getInstance('CoreBytesToSizePipe');
}
/**
* Takes a number and turns it to a human readable size.
*
* @param value The bytes to convert.
* @return Readable bytes.
*/
transform(value: number | string): string {
if (typeof value == 'string') {
// Convert the value to a number.
const numberValue = parseInt(value, 10);
if (isNaN(numberValue)) {
this.logger.error('Invalid value received', value);
return value;
}
value = numberValue;
}
return CoreTextUtils.instance.bytesToSize(value);
}
}

View File

@ -17,6 +17,7 @@ import { CoreCreateLinksPipe } from './create-links.pipe';
import { CoreFormatDatePipe } from './format-date.pipe';
import { CoreNoTagsPipe } from './no-tags.pipe';
import { CoreTimeAgoPipe } from './time-ago.pipe';
import { CoreBytesToSizePipe } from './bytes-to-size.pipe';
@NgModule({
declarations: [
@ -24,6 +25,7 @@ import { CoreTimeAgoPipe } from './time-ago.pipe';
CoreNoTagsPipe,
CoreTimeAgoPipe,
CoreFormatDatePipe,
CoreBytesToSizePipe,
],
imports: [],
exports: [
@ -31,6 +33,7 @@ import { CoreTimeAgoPipe } from './time-ago.pipe';
CoreNoTagsPipe,
CoreTimeAgoPipe,
CoreFormatDatePipe,
CoreBytesToSizePipe,
],
})
export class CorePipesModule {}

View File

@ -19,7 +19,7 @@ import { CoreApp } from '@services/app';
import { CoreEvents } from '@singletons/events';
import { CoreFile } from '@services/file';
import { CoreInit } from '@services/init';
import { CorePluginFile } from '@services/plugin-file-delegate';
import { CorePluginFile } from '@services/plugin-file.delegate';
import { CoreSites } from '@services/sites';
import { CoreWS, CoreWSExternalFile } from '@services/ws';
import { CoreDomUtils } from '@services/utils/dom';

View File

@ -25,7 +25,7 @@ import { makeSingleton } from '@singletons/core.singletons';
* Delegate to register pluginfile information handlers.
*/
@Injectable()
export class CorePluginFileDelegate extends CoreDelegate {
export class CorePluginFileDelegate extends CoreDelegate<CorePluginFileHandler> {
protected handlerNameProperty = 'component';
@ -98,7 +98,7 @@ export class CorePluginFileDelegate extends CoreDelegate {
*/
getComponentRevisionRegExp(args: string[]): RegExp | void {
// Get handler based on component (args[1]).
const handler = <CorePluginFileHandler> this.getHandler(args[1], true);
const handler = this.getHandler(args[1], true);
if (handler && handler.getComponentRevisionRegExp) {
return handler.getComponentRevisionRegExp(args);
@ -116,7 +116,7 @@ export class CorePluginFileDelegate extends CoreDelegate {
let files = <string[]>[];
for (const component in this.enabledHandlers) {
const handler = <CorePluginFileHandler> this.enabledHandlers[component];
const handler = this.enabledHandlers[component];
if (handler && handler.getDownloadableFilesFromHTML) {
files = files.concat(handler.getDownloadableFilesFromHTML(container));
@ -217,7 +217,7 @@ export class CorePluginFileDelegate extends CoreDelegate {
*/
protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler | undefined {
for (const component in this.enabledHandlers) {
const handler = <CorePluginFileHandler> this.enabledHandlers[component];
const handler = this.enabledHandlers[component];
if (handler && handler.shouldHandleFile && handler.shouldHandleFile(file)) {
return handler;
@ -252,7 +252,7 @@ export class CorePluginFileDelegate extends CoreDelegate {
*/
removeRevisionFromUrl(url: string, args: string[]): string {
// Get handler based on component (args[1]).
const handler = <CorePluginFileHandler> this.getHandler(args[1], true);
const handler = this.getHandler(args[1], true);
if (handler && handler.getComponentRevisionRegExp && handler.getComponentRevisionReplace) {
const revisionRegex = handler.getComponentRevisionRegExp(args);

View File

@ -1038,7 +1038,7 @@ export class CoreSitesProvider {
id: site.id,
siteUrl: site.siteUrl,
fullName: siteInfo?.fullname,
siteName: CoreConstants.CONFIG.sitename ?? siteInfo?.sitename,
siteName: CoreConstants.CONFIG.sitename == '' ? siteInfo?.sitename: CoreConstants.CONFIG.sitename,
avatar: siteInfo?.userpictureurl,
siteHomeId: siteInfo?.siteid || 1,
};
@ -1055,32 +1055,32 @@ export class CoreSitesProvider {
* @param ids IDs of the sites to get. If not defined, return all sites.
* @return Promise resolved when the sites are retrieved.
*/
getSortedSites(ids?: string[]): Promise<CoreSiteBasicInfo[]> {
return this.getSites(ids).then((sites) => {
// Sort sites by url and ful lname.
sites.sort((a, b) => {
// First compare by site url without the protocol.
const urlA = a.siteUrl.replace(/^https?:\/\//, '').toLowerCase();
const urlB = b.siteUrl.replace(/^https?:\/\//, '').toLowerCase();
const compare = urlA.localeCompare(urlB);
async getSortedSites(ids?: string[]): Promise<CoreSiteBasicInfo[]> {
const sites = await this.getSites(ids);
if (compare !== 0) {
return compare;
}
// Sort sites by url and ful lname.
sites.sort((a, b) => {
// First compare by site url without the protocol.
const urlA = a.siteUrl.replace(/^https?:\/\//, '').toLowerCase();
const urlB = b.siteUrl.replace(/^https?:\/\//, '').toLowerCase();
const compare = urlA.localeCompare(urlB);
// If site url is the same, use fullname instead.
const fullNameA = a.fullName?.toLowerCase().trim();
const fullNameB = b.fullName?.toLowerCase().trim();
if (compare !== 0) {
return compare;
}
if (!fullNameA || !fullNameB) {
return 0;
}
// If site url is the same, use fullname instead.
const fullNameA = a.fullName?.toLowerCase().trim();
const fullNameB = b.fullName?.toLowerCase().trim();
return fullNameA.localeCompare(fullNameB);
});
if (!fullNameA || !fullNameB) {
return 0;
}
return sites;
return fullNameA.localeCompare(fullNameB);
});
return sites;
}
/**

View File

@ -18,7 +18,7 @@ import { CoreSiteSchema, registerSiteSchema } from '@services/sites';
* Database variables for CoreSync service.
*/
export const SYNC_TABLE_NAME = 'sync';
export const SITE_SCHEMA: CoreSiteSchema = {
const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreSyncProvider',
version: 1,
tables: [

View File

@ -14,7 +14,7 @@
import { Injectable } from '@angular/core';
import { CoreEvents } from '@singletons/events';
import { CoreSites, CoreSiteSchema } from '@services/sites';
import { CoreSites } from '@services/sites';
import { makeSingleton } from '@singletons/core.singletons';
import { SYNC_TABLE_NAME, CoreSyncRecord } from '@services/sync.db';
@ -24,38 +24,6 @@ import { SYNC_TABLE_NAME, CoreSyncRecord } from '@services/sync.db';
@Injectable()
export class CoreSyncProvider {
// Variables for the database.
protected siteSchema: 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'],
},
],
};
// Store blocked sync objects.
protected blockedItems: { [siteId: string]: { [blockId: string]: { [operation: string]: boolean } } } = {};
@ -129,8 +97,10 @@ export class CoreSyncProvider {
* @param siteId Site ID. If not defined, current site.
* @return Record if found or reject.
*/
getSyncRecord(component: string, id: string | number, siteId?: string): Promise<CoreSyncRecord> {
return CoreSites.instance.getSiteDb(siteId).then((db) => db.getRecord(SYNC_TABLE_NAME, { component: component, id: id }));
async getSyncRecord(component: string, id: string | number, siteId?: string): Promise<CoreSyncRecord> {
const db = await CoreSites.instance.getSiteDb(siteId);
return await db.getRecord(SYNC_TABLE_NAME, { component: component, id: id });
}
/**

View File

@ -1725,14 +1725,14 @@ export class CoreDomUtilsProvider {
* @param online Whether the action was done in offline or not.
* @param siteId The site affected. If not provided, no site affected.
*/
triggerFormSubmittedEvent(formRef: ElementRef | undefined, online?: boolean, siteId?: string): void {
triggerFormSubmittedEvent(formRef: ElementRef | HTMLFormElement | undefined, online?: boolean, siteId?: string): void {
if (!formRef) {
return;
}
CoreEvents.trigger(CoreEvents.FORM_ACTION, {
action: 'submit',
form: formRef.nativeElement,
form: formRef.nativeElement || formRef,
online: !!online,
}, siteId);
}

View File

@ -981,6 +981,15 @@ export type CoreWSExternalWarning = {
};
/**
* Special response structure of many webservices that contains success status and warnings.
*/
export type CoreStatusWithWarningsWSResponse = {
status: boolean; // Status: true if success.
offline?: boolean; // True if information has been stored in offline for future use.
warnings?: CoreWSExternalWarning[];
};
/**
* Structure of files returned by WS.
*/

View File

@ -16,6 +16,7 @@ import { Params } from '@angular/router';
import { Subject } from 'rxjs';
import { CoreLogger } from '@singletons/logger';
import { CoreSiteInfoResponse } from '@classes/site';
/**
* Observer instance to stop listening to an event.
@ -192,13 +193,24 @@ export class CoreEvents {
}
/**
* Some events contains siteId added by the trigger function. This type is intended to be combined with others.
*/
export type CoreEventSiteData = {
siteId?: string;
};
/**
* Data passed to SITE_UPDATED event.
*/
export type CoreEventSiteUpdatedData = CoreEventSiteData & CoreSiteInfoResponse;
/**
* Data passed to SESSION_EXPIRED event.
*/
export type CoreEventSessionExpiredData = {
export type CoreEventSessionExpiredData = CoreEventSiteData & {
pageName?: string;
params?: Params;
siteId?: string;
};
/**

View File

@ -1,861 +0,0 @@
{
"assets.countries.AD": "Andorra",
"assets.countries.AE": "United Arab Emirates",
"assets.countries.AF": "Afghanistan",
"assets.countries.AG": "Antigua and Barbuda",
"assets.countries.AI": "Anguilla",
"assets.countries.AL": "Albania",
"assets.countries.AM": "Armenia",
"assets.countries.AO": "Angola",
"assets.countries.AQ": "Antarctica",
"assets.countries.AR": "Argentina",
"assets.countries.AS": "American Samoa",
"assets.countries.AT": "Austria",
"assets.countries.AU": "Australia",
"assets.countries.AW": "Aruba",
"assets.countries.AX": "Åland Islands",
"assets.countries.AZ": "Azerbaijan",
"assets.countries.BA": "Bosnia and Herzegovina",
"assets.countries.BB": "Barbados",
"assets.countries.BD": "Bangladesh",
"assets.countries.BE": "Belgium",
"assets.countries.BF": "Burkina Faso",
"assets.countries.BG": "Bulgaria",
"assets.countries.BH": "Bahrain",
"assets.countries.BI": "Burundi",
"assets.countries.BJ": "Benin",
"assets.countries.BL": "Saint Barthélemy",
"assets.countries.BM": "Bermuda",
"assets.countries.BN": "Brunei Darussalam",
"assets.countries.BO": "Bolivia (Plurinational State of)",
"assets.countries.BQ": "Bonaire, Sint Eustatius and Saba",
"assets.countries.BR": "Brazil",
"assets.countries.BS": "Bahamas",
"assets.countries.BT": "Bhutan",
"assets.countries.BV": "Bouvet Island",
"assets.countries.BW": "Botswana",
"assets.countries.BY": "Belarus",
"assets.countries.BZ": "Belize",
"assets.countries.CA": "Canada",
"assets.countries.CC": "Cocos (Keeling) Islands",
"assets.countries.CD": "Congo (the Democratic Republic of the)",
"assets.countries.CF": "Central African Republic",
"assets.countries.CG": "Congo",
"assets.countries.CH": "Switzerland",
"assets.countries.CI": "Côte d'Ivoire",
"assets.countries.CK": "Cook Islands",
"assets.countries.CL": "Chile",
"assets.countries.CM": "Cameroon",
"assets.countries.CN": "China",
"assets.countries.CO": "Colombia",
"assets.countries.CR": "Costa Rica",
"assets.countries.CU": "Cuba",
"assets.countries.CV": "Cabo Verde",
"assets.countries.CW": "Curaçao",
"assets.countries.CX": "Christmas Island",
"assets.countries.CY": "Cyprus",
"assets.countries.CZ": "Czechia",
"assets.countries.DE": "Germany",
"assets.countries.DJ": "Djibouti",
"assets.countries.DK": "Denmark",
"assets.countries.DM": "Dominica",
"assets.countries.DO": "Dominican Republic",
"assets.countries.DZ": "Algeria",
"assets.countries.EC": "Ecuador",
"assets.countries.EE": "Estonia",
"assets.countries.EG": "Egypt",
"assets.countries.EH": "Western Sahara",
"assets.countries.ER": "Eritrea",
"assets.countries.ES": "Spain",
"assets.countries.ET": "Ethiopia",
"assets.countries.FI": "Finland",
"assets.countries.FJ": "Fiji",
"assets.countries.FK": "Falkland Islands (Malvinas)",
"assets.countries.FM": "Micronesia (Federated States of)",
"assets.countries.FO": "Faroe Islands",
"assets.countries.FR": "France",
"assets.countries.GA": "Gabon",
"assets.countries.GB": "United Kingdom",
"assets.countries.GD": "Grenada",
"assets.countries.GE": "Georgia",
"assets.countries.GF": "French Guiana",
"assets.countries.GG": "Guernsey",
"assets.countries.GH": "Ghana",
"assets.countries.GI": "Gibraltar",
"assets.countries.GL": "Greenland",
"assets.countries.GM": "Gambia",
"assets.countries.GN": "Guinea",
"assets.countries.GP": "Guadeloupe",
"assets.countries.GQ": "Equatorial Guinea",
"assets.countries.GR": "Greece",
"assets.countries.GS": "South Georgia and the South Sandwich Islands",
"assets.countries.GT": "Guatemala",
"assets.countries.GU": "Guam",
"assets.countries.GW": "Guinea-Bissau",
"assets.countries.GY": "Guyana",
"assets.countries.HK": "Hong Kong",
"assets.countries.HM": "Heard Island and McDonald Islands",
"assets.countries.HN": "Honduras",
"assets.countries.HR": "Croatia",
"assets.countries.HT": "Haiti",
"assets.countries.HU": "Hungary",
"assets.countries.ID": "Indonesia",
"assets.countries.IE": "Ireland",
"assets.countries.IL": "Israel",
"assets.countries.IM": "Isle of Man",
"assets.countries.IN": "India",
"assets.countries.IO": "British Indian Ocean Territory",
"assets.countries.IQ": "Iraq",
"assets.countries.IR": "Iran (Islamic Republic of)",
"assets.countries.IS": "Iceland",
"assets.countries.IT": "Italy",
"assets.countries.JE": "Jersey",
"assets.countries.JM": "Jamaica",
"assets.countries.JO": "Jordan",
"assets.countries.JP": "Japan",
"assets.countries.KE": "Kenya",
"assets.countries.KG": "Kyrgyzstan",
"assets.countries.KH": "Cambodia",
"assets.countries.KI": "Kiribati",
"assets.countries.KM": "Comoros",
"assets.countries.KN": "Saint Kitts and Nevis",
"assets.countries.KP": "Korea (the Democratic People's Republic of)",
"assets.countries.KR": "Korea (the Republic of)",
"assets.countries.KW": "Kuwait",
"assets.countries.KY": "Cayman Islands",
"assets.countries.KZ": "Kazakhstan",
"assets.countries.LA": "Lao People's Democratic Republic",
"assets.countries.LB": "Lebanon",
"assets.countries.LC": "Saint Lucia",
"assets.countries.LI": "Liechtenstein",
"assets.countries.LK": "Sri Lanka",
"assets.countries.LR": "Liberia",
"assets.countries.LS": "Lesotho",
"assets.countries.LT": "Lithuania",
"assets.countries.LU": "Luxembourg",
"assets.countries.LV": "Latvia",
"assets.countries.LY": "Libya",
"assets.countries.MA": "Morocco",
"assets.countries.MC": "Monaco",
"assets.countries.MD": "Moldova (the Republic of)",
"assets.countries.ME": "Montenegro",
"assets.countries.MF": "Saint Martin (French part)",
"assets.countries.MG": "Madagascar",
"assets.countries.MH": "Marshall Islands",
"assets.countries.MK": "North Macedonia",
"assets.countries.ML": "Mali",
"assets.countries.MM": "Myanmar",
"assets.countries.MN": "Mongolia",
"assets.countries.MO": "Macao",
"assets.countries.MP": "Northern Mariana Islands",
"assets.countries.MQ": "Martinique",
"assets.countries.MR": "Mauritania",
"assets.countries.MS": "Montserrat",
"assets.countries.MT": "Malta",
"assets.countries.MU": "Mauritius",
"assets.countries.MV": "Maldives",
"assets.countries.MW": "Malawi",
"assets.countries.MX": "Mexico",
"assets.countries.MY": "Malaysia",
"assets.countries.MZ": "Mozambique",
"assets.countries.NA": "Namibia",
"assets.countries.NC": "New Caledonia",
"assets.countries.NE": "Niger",
"assets.countries.NF": "Norfolk Island",
"assets.countries.NG": "Nigeria",
"assets.countries.NI": "Nicaragua",
"assets.countries.NL": "Netherlands",
"assets.countries.NO": "Norway",
"assets.countries.NP": "Nepal",
"assets.countries.NR": "Nauru",
"assets.countries.NU": "Niue",
"assets.countries.NZ": "New Zealand",
"assets.countries.OM": "Oman",
"assets.countries.PA": "Panama",
"assets.countries.PE": "Peru",
"assets.countries.PF": "French Polynesia",
"assets.countries.PG": "Papua New Guinea",
"assets.countries.PH": "Philippines",
"assets.countries.PK": "Pakistan",
"assets.countries.PL": "Poland",
"assets.countries.PM": "Saint Pierre and Miquelon",
"assets.countries.PN": "Pitcairn",
"assets.countries.PR": "Puerto Rico",
"assets.countries.PS": "Palestine, State of",
"assets.countries.PT": "Portugal",
"assets.countries.PW": "Palau",
"assets.countries.PY": "Paraguay",
"assets.countries.QA": "Qatar",
"assets.countries.RE": "Réunion",
"assets.countries.RO": "Romania",
"assets.countries.RS": "Serbia",
"assets.countries.RU": "Russian Federation",
"assets.countries.RW": "Rwanda",
"assets.countries.SA": "Saudi Arabia",
"assets.countries.SB": "Solomon Islands",
"assets.countries.SC": "Seychelles",
"assets.countries.SD": "Sudan",
"assets.countries.SE": "Sweden",
"assets.countries.SG": "Singapore",
"assets.countries.SH": "Saint Helena, Ascension and Tristan da Cunha",
"assets.countries.SI": "Slovenia",
"assets.countries.SJ": "Svalbard and Jan Mayen",
"assets.countries.SK": "Slovakia",
"assets.countries.SL": "Sierra Leone",
"assets.countries.SM": "San Marino",
"assets.countries.SN": "Senegal",
"assets.countries.SO": "Somalia",
"assets.countries.SR": "Suriname",
"assets.countries.SS": "South Sudan",
"assets.countries.ST": "Sao Tome and Principe",
"assets.countries.SV": "El Salvador",
"assets.countries.SX": "Sint Maarten (Dutch part)",
"assets.countries.SY": "Syrian Arab Republic",
"assets.countries.SZ": "Eswatini",
"assets.countries.TC": "Turks and Caicos Islands",
"assets.countries.TD": "Chad",
"assets.countries.TF": "French Southern Territories",
"assets.countries.TG": "Togo",
"assets.countries.TH": "Thailand",
"assets.countries.TJ": "Tajikistan",
"assets.countries.TK": "Tokelau",
"assets.countries.TL": "Timor-Leste",
"assets.countries.TM": "Turkmenistan",
"assets.countries.TN": "Tunisia",
"assets.countries.TO": "Tonga",
"assets.countries.TR": "Turkey",
"assets.countries.TT": "Trinidad and Tobago",
"assets.countries.TV": "Tuvalu",
"assets.countries.TW": "Taiwan",
"assets.countries.TZ": "Tanzania, the United Republic of",
"assets.countries.UA": "Ukraine",
"assets.countries.UG": "Uganda",
"assets.countries.UM": "United States Minor Outlying Islands",
"assets.countries.US": "United States",
"assets.countries.UY": "Uruguay",
"assets.countries.UZ": "Uzbekistan",
"assets.countries.VA": "Holy See",
"assets.countries.VC": "Saint Vincent and the Grenadines",
"assets.countries.VE": "Venezuela (Bolivarian Republic of)",
"assets.countries.VG": "Virgin Islands (British)",
"assets.countries.VI": "Virgin Islands (U.S.)",
"assets.countries.VN": "Viet Nam",
"assets.countries.VU": "Vanuatu",
"assets.countries.WF": "Wallis and Futuna",
"assets.countries.WS": "Samoa",
"assets.countries.YE": "Yemen",
"assets.countries.YT": "Mayotte",
"assets.countries.ZA": "South Africa",
"assets.countries.ZM": "Zambia",
"assets.countries.ZW": "Zimbabwe",
"assets.mimetypes.application/epub_zip": "EPUB ebook",
"assets.mimetypes.application/msword": "Word document",
"assets.mimetypes.application/pdf": "PDF document",
"assets.mimetypes.application/vnd.moodle.backup": "Moodle backup",
"assets.mimetypes.application/vnd.ms-excel": "Excel spreadsheet",
"assets.mimetypes.application/vnd.ms-excel.sheet.macroEnabled.12": "Excel 2007 macro-enabled workbook",
"assets.mimetypes.application/vnd.ms-powerpoint": "Powerpoint presentation",
"assets.mimetypes.application/vnd.oasis.opendocument.spreadsheet": "OpenDocument Spreadsheet",
"assets.mimetypes.application/vnd.oasis.opendocument.spreadsheet-template": "OpenDocument Spreadsheet template",
"assets.mimetypes.application/vnd.oasis.opendocument.text": "OpenDocument Text document",
"assets.mimetypes.application/vnd.oasis.opendocument.text-template": "OpenDocument Text template",
"assets.mimetypes.application/vnd.oasis.opendocument.text-web": "OpenDocument Web page template",
"assets.mimetypes.application/vnd.openxmlformats-officedocument.presentationml.presentation": "Powerpoint 2007 presentation",
"assets.mimetypes.application/vnd.openxmlformats-officedocument.presentationml.slideshow": "Powerpoint 2007 slideshow",
"assets.mimetypes.application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel 2007 spreadsheet",
"assets.mimetypes.application/vnd.openxmlformats-officedocument.spreadsheetml.template": "Excel 2007 template",
"assets.mimetypes.application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Word 2007 document",
"assets.mimetypes.application/x-iwork-keynote-sffkey": "iWork Keynote presentation",
"assets.mimetypes.application/x-iwork-numbers-sffnumbers": "iWork Numbers spreadsheet",
"assets.mimetypes.application/x-iwork-pages-sffpages": "iWork Pages document",
"assets.mimetypes.application/x-javascript": "JavaScript source",
"assets.mimetypes.application/x-mspublisher": "Publisher document",
"assets.mimetypes.application/x-shockwave-flash": "Flash animation",
"assets.mimetypes.application/xhtml_xml": "XHTML document",
"assets.mimetypes.archive": "Archive ({{$a.EXT}})",
"assets.mimetypes.audio": "Audio file ({{$a.EXT}})",
"assets.mimetypes.default": "{{$a.mimetype}}",
"assets.mimetypes.document/unknown": "File",
"assets.mimetypes.group:archive": "Archive files",
"assets.mimetypes.group:audio": "Audio files",
"assets.mimetypes.group:document": "Document files",
"assets.mimetypes.group:html_audio": "Audio files natively supported by browsers",
"assets.mimetypes.group:html_track": "HTML track files",
"assets.mimetypes.group:html_video": "Video files natively supported by browsers",
"assets.mimetypes.group:image": "Image files",
"assets.mimetypes.group:presentation": "Presentation files",
"assets.mimetypes.group:sourcecode": "Source code",
"assets.mimetypes.group:spreadsheet": "Spreadsheet files",
"assets.mimetypes.group:video": "Video files",
"assets.mimetypes.group:web_audio": "Audio files used on the web",
"assets.mimetypes.group:web_file": "Web files",
"assets.mimetypes.group:web_image": "Image files used on the web",
"assets.mimetypes.group:web_video": "Video files used on the web",
"assets.mimetypes.image": "Image ({{$a.MIMETYPE2}})",
"assets.mimetypes.image/vnd.microsoft.icon": "Windows icon",
"assets.mimetypes.text/css": "Cascading Style-Sheet",
"assets.mimetypes.text/csv": "Comma-separated values",
"assets.mimetypes.text/html": "HTML document",
"assets.mimetypes.text/plain": "Text file",
"assets.mimetypes.text/rtf": "RTF document",
"assets.mimetypes.text/vtt": "Web Video Text Track",
"assets.mimetypes.video": "Video file ({{$a.EXT}})",
"core.accounts": "Accounts",
"core.add": "Add",
"core.agelocationverification": "Age and location verification",
"core.ago": "{{$a}} ago",
"core.all": "All",
"core.allgroups": "All groups",
"core.allparticipants": "All participants",
"core.answer": "Answer",
"core.answered": "Answered",
"core.areyousure": "Are you sure?",
"core.back": "Back",
"core.browser": "Browser",
"core.cancel": "Cancel",
"core.cannotconnect": "Cannot connect",
"core.cannotconnecttrouble": "We're having trouble connecting to your site.",
"core.cannotconnectverify": "<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.copytoclipboard": "Copy to clipboard",
"core.course": "Course",
"core.coursedetails": "Course details",
"core.coursenogroups": "You are not a member of any group of this course.",
"core.courses.addtofavourites": "Star this course",
"core.courses.allowguests": "This course allows guest users to enter",
"core.courses.availablecourses": "Available courses",
"core.courses.cannotretrievemorecategories": "Categories deeper than level {{$a}} cannot be retrieved.",
"core.courses.categories": "Course categories",
"core.courses.confirmselfenrol": "Are you sure you want to enrol yourself in this course?",
"core.courses.courses": "Courses",
"core.courses.downloadcourses": "Download courses",
"core.courses.enrolme": "Enrol me",
"core.courses.errorloadcategories": "An error occurred while loading categories.",
"core.courses.errorloadcourses": "An error occurred while loading courses.",
"core.courses.errorloadplugins": "The plugins required by this course could not be loaded correctly. Please reload the app to try again.",
"core.courses.errorsearching": "An error occurred while searching.",
"core.courses.errorselfenrol": "An error occurred while self enrolling.",
"core.courses.filtermycourses": "Filter my courses",
"core.courses.frontpage": "Front page",
"core.courses.hidecourse": "Remove from view",
"core.courses.ignore": "Ignore",
"core.courses.mycourses": "My courses",
"core.courses.mymoodle": "Dashboard",
"core.courses.nocourses": "No course information to show.",
"core.courses.nocoursesyet": "No courses in this category",
"core.courses.nosearchresults": "No results",
"core.courses.notenroled": "You are not enrolled in this course",
"core.courses.notenrollable": "You cannot enrol yourself in this course.",
"core.courses.password": "Enrolment key",
"core.courses.paymentrequired": "This course requires a payment for entry.",
"core.courses.paypalaccepted": "PayPal payments accepted",
"core.courses.reload": "Reload",
"core.courses.removefromfavourites": "Unstar this course",
"core.courses.search": "Search",
"core.courses.searchcourses": "Search courses",
"core.courses.searchcoursesadvice": "You can use the search courses button to find courses to access as a guest or enrol yourself in courses that allow it.",
"core.courses.selfenrolment": "Self enrolment",
"core.courses.sendpaymentbutton": "Send payment via PayPal",
"core.courses.show": "Restore to view",
"core.courses.totalcoursesearchresults": "Total courses: {{$a}}",
"core.currentdevice": "Current device",
"core.datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.",
"core.date": "Date",
"core.day": "day",
"core.days": "days",
"core.decsep": ".",
"core.defaultvalue": "Default ({{$a}})",
"core.delete": "Delete",
"core.deletedoffline": "Deleted offline",
"core.deleteduser": "Deleted user",
"core.deleting": "Deleting",
"core.description": "Description",
"core.desktop": "Desktop",
"core.dfdaymonthyear": "MM-DD-YYYY",
"core.dfdayweekmonth": "ddd, D MMM",
"core.dffulldate": "dddd, D MMMM YYYY h[:]mm A",
"core.dflastweekdate": "ddd",
"core.dfmediumdate": "LLL",
"core.dftimedate": "h[:]mm A",
"core.digitalminor": "Digital minor",
"core.digitalminor_desc": "Please ask your parent/guardian to contact:",
"core.discard": "Discard",
"core.dismiss": "Dismiss",
"core.displayoptions": "Display options",
"core.done": "Done",
"core.download": "Download",
"core.downloaded": "Downloaded",
"core.downloadfile": "Download file",
"core.downloading": "Downloading",
"core.edit": "Edit",
"core.emptysplit": "This page will appear blank if the left panel is empty or is loading.",
"core.error": "Error",
"core.errorchangecompletion": "An error occurred while changing the completion status. Please try again.",
"core.errordeletefile": "Error deleting the file. Please try again.",
"core.errordownloading": "Error downloading file.",
"core.errordownloadingsomefiles": "Error downloading files. Some files might be missing.",
"core.errorfileexistssamename": "A file with this name already exists.",
"core.errorinvalidform": "The form contains invalid data. Please check that all required fields are filled in and that the data is valid.",
"core.errorinvalidresponse": "Invalid response received. Please contact your site administrator if the error persists.",
"core.errorloadingcontent": "Error loading content.",
"core.errorofflinedisabled": "Offline browsing is disabled on your site. You need to be connected to the internet to use the app.",
"core.erroropenfilenoapp": "Error opening file: no app found to open this type of file.",
"core.erroropenfilenoextension": "Error opening file: the file doesn't have an extension.",
"core.erroropenpopup": "This activity is trying to open a popup. This is not supported in the app.",
"core.errorrenamefile": "Error renaming file. Please try again.",
"core.errorsomedatanotdownloaded": "If you downloaded this activity, please notice that some data isn't downloaded during the download process for performance and data usage reasons.",
"core.errorsync": "An error occurred while synchronising. Please try again.",
"core.errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.",
"core.errorurlschemeinvalidscheme": "This URL is meant to be used in another app: {{$a}}.",
"core.errorurlschemeinvalidsite": "This site URL cannot be opened in this app.",
"core.explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.",
"core.favourites": "Starred",
"core.filename": "Filename",
"core.filenameexist": "File name already exists: {{$a}}",
"core.filenotfound": "File not found, sorry.",
"core.filter": "Filter",
"core.folder": "Folder",
"core.forcepasswordchangenotice": "You must change your password to proceed.",
"core.fulllistofcourses": "All courses",
"core.fullnameandsitename": "{{fullname}} ({{sitename}})",
"core.group": "Group",
"core.groupsseparate": "Separate groups",
"core.groupsvisible": "Visible groups",
"core.hasdatatosync": "This {{$a}} has offline data to be synchronised.",
"core.help": "Help",
"core.hide": "Hide",
"core.hour": "hour",
"core.hours": "hours",
"core.humanreadablesize": "{{size}} {{unit}}",
"core.image": "Image",
"core.imageviewer": "Image viewer",
"core.info": "Information",
"core.invalidformdata": "Incorrect form data",
"core.labelsep": ":",
"core.lastaccess": "Last access",
"core.lastdownloaded": "Last downloaded",
"core.lastmodified": "Last modified",
"core.lastsync": "Last synchronisation",
"core.layoutgrid": "Grid",
"core.list": "List",
"core.listsep": ",",
"core.loading": "Loading",
"core.loadmore": "Load more",
"core.location": "Location",
"core.login.auth_email": "Email-based self-registration",
"core.login.authenticating": "Authenticating",
"core.login.cancel": "Cancel",
"core.login.changepassword": "Change password",
"core.login.changepasswordbutton": "Open the change password page",
"core.login.changepasswordhelp": "If you have problems changing your password, please contact your site administrator. \"Site Administrators\" are the people who manages 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.login.changepasswordinstructions": "You cannot change your password in the app. Please click the following button to open the site in a web browser to change your password. Take into account you need to close the browser after changing the password as you will not be redirected to the app.",
"core.login.changepasswordlogoutinstructions": "If you prefer to change site or log out, please click the following button:",
"core.login.changepasswordreconnectinstructions": "Click the following button to reconnect to the site. (Take into account that if you didn't change your password successfully, you would return to the previous screen).",
"core.login.confirmdeletesite": "Are you sure you want to delete the site {{sitename}}?",
"core.login.connect": "Connect!",
"core.login.connecttomoodle": "Connect to Moodle",
"core.login.connecttomoodleapp": "You are trying to connect to a regular Moodle site. Please download the official Moodle app to access this site.",
"core.login.connecttoworkplaceapp": "You are trying to connect to a Moodle Workplace site. Please download the Moodle Workplace app to access this site.",
"core.login.contactyouradministrator": "Contact your site administrator for further help.",
"core.login.contactyouradministratorissue": "Please ask your site administrator to check the following issue: {{$a}}",
"core.login.createaccount": "Create my new account",
"core.login.createuserandpass": "Choose your username and password",
"core.login.credentialsdescription": "Please provide your username and password to log in.",
"core.login.emailconfirmsent": "<p>An email should have been sent to your address at <b>{{$a}}</b></p>\n <p>It contains easy instructions to complete your registration.</p>\n <p>If you continue to have difficulty, contact the site administrator.</p>",
"core.login.emailconfirmsentnoemail": "<p>An email should have been sent to your address.</p><p>It contains easy instructions to complete your registration.</p><p>If you continue to have difficulty, contact the site administrator.</p>",
"core.login.emailconfirmsentsuccess": "Confirmation email sent successfully",
"core.login.emailnotmatch": "Emails do not match",
"core.login.erroraccesscontrolalloworigin": "The cross-origin call you're trying to perform has been rejected. Please check https://docs.moodle.org/dev/Moodle_Mobile_development_using_Chrome_or_Chromium",
"core.login.errordeletesite": "An error occurred while deleting this site. Please try again.",
"core.login.errorexampleurl": "The URL https://campus.example.edu is only an example URL, it's not a real site. <strong>Please use the URL of your school or organization's site.</strong>",
"core.login.errorqrnoscheme": "This URL isn't a valid login URL.",
"core.login.errorupdatesite": "An error occurred while updating the site's token.",
"core.login.faqcannotconnectanswer": "Please, contact your site administrator.",
"core.login.faqcannotconnectquestion": "I typed my site address correctly but I still can't connect.",
"core.login.faqcannotfindmysiteanswer": "Have you typed the name correctly? It's also possible that your site is not included in our public sites directory. If you still can't find it, please enter your site address instead.",
"core.login.faqcannotfindmysitequestion": "I can't find my site.",
"core.login.faqsetupsiteanswer": "Visit {{$link}} to check out the different options you have to create your own Moodle site.",
"core.login.faqsetupsitelinktitle": "Get started.",
"core.login.faqsetupsitequestion": "I want to set up my own Moodle site.",
"core.login.faqtestappanswer": "To test the app in a Moodle Demo Site, type \"teacher\" or \"student\" in the \"Your site\" field and click the \"Connect to your site\" button.",
"core.login.faqtestappquestion": "I just want to test the app, what can I do?",
"core.login.faqwhatisurlanswer": "<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.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.findyoursite": "Find your site",
"core.login.firsttime": "Is this your first time here?",
"core.login.forcepasswordchangenotice": "You must change your password to proceed.",
"core.login.forgotten": "Forgotten your username or password?",
"core.login.help": "Help",
"core.login.helpmelogin": "<p>There are many thousands of Moodle sites around the world. This app can only connect to Moodle sites that have specifically enabled Mobile app access.</p><p>If you can't connect to your Moodle site then you need to contact your site administrator and ask them to read <a href=\"http://docs.moodle.org/en/Mobile_app\" target=\"_blank\">http://docs.moodle.org/en/Mobile_app</a></p><p>To test the app in a Moodle demo site type <i>teacher</i> or <i>student</i> in the <i>Site address</i> field and click the <b>Connect button</b>.</p>",
"core.login.instructions": "Instructions",
"core.login.invalidaccount": "Please check your login details or ask your site administrator to check the site configuration.",
"core.login.invaliddate": "Invalid date",
"core.login.invalidemail": "Invalid email address",
"core.login.invalidmoodleversion": "<p>Invalid Moodle site version. The Moodle app only supports Moodle systems {{$a}} onwards.</p>\n<p>You can contact your site administrators and ask them to update their Moodle system.</p>\n<p>\"Site Administrators\" are the people who manages the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.</p>",
"core.login.invalidsite": "The site URL is invalid.",
"core.login.invalidtime": "Invalid time",
"core.login.invalidurl": "Invalid URL specified",
"core.login.invalidvaluemax": "The maximum value is {{$a}}",
"core.login.invalidvaluemin": "The minimum value is {{$a}}",
"core.login.localmobileunexpectedresponse": "Moodle Mobile Additional Features check returned an unexpected response. You will be authenticated using the standard mobile service.",
"core.login.loggedoutssodescription": "You have to authenticate again. You need to log in to the site in a browser window.",
"core.login.login": "Log in",
"core.login.loginbutton": "Log in",
"core.login.logininsiterequired": "You need to log in to the site in a browser window.",
"core.login.loginsteps": "For full access to this site, you first need to create an account.",
"core.login.missingemail": "Missing email address",
"core.login.missingfirstname": "Missing given name",
"core.login.missinglastname": "Missing surname",
"core.login.mobileservicesnotenabled": "Mobile access is not enabled on your site. Please contact your site administrator if you think it should be enabled.",
"core.login.mustconfirm": "You need to confirm your account",
"core.login.newaccount": "New account",
"core.login.notloggedin": "You need to be logged in.",
"core.login.onboardingcreatemanagecourses": "Create & manage your courses",
"core.login.onboardingenrolmanagestudents": "Enrol & manage your students",
"core.login.onboardinggetstarted": "Get started with Moodle",
"core.login.onboardingialreadyhaveasite": "I already have a Moodle site",
"core.login.onboardingimalearner": "I'm a learner",
"core.login.onboardingimaneducator": "I'm an educator",
"core.login.onboardingineedasite": "I need a Moodle site",
"core.login.onboardingprovidefeedback": "Provide timely feedback",
"core.login.onboardingtoconnect": "To connect to the Moodle App you'll need a Moodle site",
"core.login.onboardingwelcome": "Welcome to the Moodle App!",
"core.login.or": "OR",
"core.login.password": "Password",
"core.login.passwordforgotten": "Forgotten password",
"core.login.passwordforgotteninstructions2": "To reset your password, submit your username or your email address below. If we can find you in the database, an email will be sent to your email address, with instructions how to get access again.",
"core.login.passwordrequired": "Password required",
"core.login.policyaccept": "I understand and agree",
"core.login.policyagree": "You must agree to this policy to continue using this site. Do you agree?",
"core.login.policyagreement": "Site policy agreement",
"core.login.policyagreementclick": "Link to site policy agreement",
"core.login.potentialidps": "Log in using your account on:",
"core.login.profileinvaliddata": "Invalid value",
"core.login.recaptchachallengeimage": "reCAPTCHA challenge image",
"core.login.recaptchaexpired": "Verification expired. Answer the security question again.",
"core.login.recaptchaincorrect": "The security question answer is incorrect.",
"core.login.reconnect": "Reconnect",
"core.login.reconnectdescription": "Your authentication token is invalid or has expired. You have to reconnect to the site.",
"core.login.reconnectssodescription": "Your authentication token is invalid or has expired. You have to reconnect to the site. You need to log in to the site in a browser window.",
"core.login.resendemail": "Resend email",
"core.login.searchby": "Search by:",
"core.login.security_question": "Security question",
"core.login.selectacountry": "Select a country",
"core.login.selectsite": "Please select your site:",
"core.login.signupplugindisabled": "{{$a}} is not enabled.",
"core.login.signuprequiredfieldnotsupported": "The signup form contains a required custom field that isn't supported in the app. Please create your account using a web browser.",
"core.login.siteaddress": "Your site",
"core.login.siteaddressplaceholder": "https://campus.example.edu",
"core.login.sitehasredirect": "Your site contains at least one HTTP redirect. The app cannot follow redirects, this could be the issue that's preventing the app from connecting to your site.",
"core.login.siteinmaintenance": "Your site is in maintenance mode",
"core.login.sitepolicynotagreederror": "Site policy not agreed.",
"core.login.siteurl": "Site URL",
"core.login.siteurlrequired": "Site URL required i.e <i>http://www.yourmoodlesite.org</i>",
"core.login.startsignup": "Create new account",
"core.login.stillcantconnect": "Still can't connect?",
"core.login.supplyinfo": "More details",
"core.login.username": "Username",
"core.login.usernameoremail": "Enter either username or email address",
"core.login.usernamerequired": "Username required",
"core.login.usernotaddederror": "User not added - error",
"core.login.visitchangepassword": "Do you want to visit the site to change the password?",
"core.login.webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.",
"core.login.youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.",
"core.login.yourenteredsite": "Connect to your site",
"core.lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.",
"core.mainmenu.changesite": "Change site",
"core.mainmenu.help": "Help",
"core.mainmenu.home": "Home",
"core.mainmenu.logout": "Log out",
"core.mainmenu.website": "Website",
"core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
"core.min": "min",
"core.mins": "mins",
"core.misc": "Miscellaneous",
"core.mod_assign": "Assignment",
"core.mod_assignment": "Assignment 2.2 (Disabled)",
"core.mod_book": "Book",
"core.mod_chat": "Chat",
"core.mod_choice": "Choice",
"core.mod_data": "Database",
"core.mod_database": "Database",
"core.mod_external-tool": "External tool",
"core.mod_feedback": "Feedback",
"core.mod_file": "File",
"core.mod_folder": "Folder",
"core.mod_forum": "Forum",
"core.mod_glossary": "Glossary",
"core.mod_h5pactivity": "H5P",
"core.mod_ims": "IMS content package",
"core.mod_imscp": "IMS content package",
"core.mod_label": "Label",
"core.mod_lesson": "Lesson",
"core.mod_lti": "External tool",
"core.mod_page": "Page",
"core.mod_quiz": "Quiz",
"core.mod_resource": "File",
"core.mod_scorm": "SCORM package",
"core.mod_survey": "Survey",
"core.mod_url": "URL",
"core.mod_wiki": "Wiki",
"core.mod_workshop": "Workshop",
"core.moduleintro": "Description",
"core.more": "more",
"core.mygroups": "My groups",
"core.name": "Name",
"core.needhelp": "Need help?",
"core.networkerroriframemsg": "This content is not available offline. Please connect to the internet and try again.",
"core.networkerrormsg": "There was a problem connecting to the site. Please check your connection and try again.",
"core.never": "Never",
"core.next": "Next",
"core.no": "No",
"core.nocomments": "No comments",
"core.nograde": "No grade",
"core.none": "None",
"core.nooptionavailable": "No option available",
"core.nopasswordchangeforced": "You cannot proceed without changing your password.",
"core.nopermissionerror": "Sorry, but you do not currently have permissions to do that",
"core.nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).",
"core.noresults": "No results",
"core.noselection": "No selection",
"core.notapplicable": "n/a",
"core.notavailable": "Not available",
"core.notenrolledprofile": "This profile is not available because this user is not enrolled in this course.",
"core.notice": "Notice",
"core.notingroup": "Sorry, but you need to be part of a group to see this page.",
"core.notsent": "Not sent",
"core.now": "now",
"core.nummore": "{{$a}} more",
"core.numwords": "{{$a}} words",
"core.offline": "Offline",
"core.ok": "OK",
"core.online": "Online",
"core.openfile": "Open file",
"core.openfullimage": "Click here to display the full size image",
"core.openinbrowser": "Open in browser",
"core.openmodinbrowser": "Open {{$a}} in browser",
"core.othergroups": "Other groups",
"core.pagea": "Page {{$a}}",
"core.parentlanguage": "",
"core.paymentinstant": "Use the button below to pay and be enrolled within minutes!",
"core.percentagenumber": "{{$a}}%",
"core.phone": "Phone",
"core.pictureof": "Picture of {{$a}}",
"core.previous": "Previous",
"core.proceed": "Proceed",
"core.pulltorefresh": "Pull to refresh",
"core.qrscanner": "QR scanner",
"core.quotausage": "You have currently used {{$a.used}} of your {{$a.total}} limit.",
"core.redirectingtosite": "You will be redirected to the site.",
"core.refresh": "Refresh",
"core.remove": "Remove",
"core.removefiles": "Remove files {{$a}}",
"core.required": "Required",
"core.requireduserdatamissing": "This user lacks some required profile data. Please enter the data in your site and try again.<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.appsettings": "App settings",
"core.settings.appversion": "App version",
"core.settings.cannotsyncloggedout": "This site cannot be synchronised because you've logged out. Please try again when you're logged in the site again.",
"core.settings.cannotsyncoffline": "Cannot synchronise offline.",
"core.settings.cannotsyncwithoutwifi": "Cannot synchronise because the current settings only allow to synchronise when connected to Wi-Fi. Please connect to a Wi-Fi network.",
"core.settings.colorscheme": "Color Scheme",
"core.settings.colorscheme-auto": "Auto (based on system settings)",
"core.settings.colorscheme-dark": "Dark",
"core.settings.colorscheme-light": "Light",
"core.settings.compilationinfo": "Compilation info",
"core.settings.copyinfo": "Copy device info on the clipboard",
"core.settings.cordovadevicemodel": "Cordova device model",
"core.settings.cordovadeviceosversion": "Cordova device OS version",
"core.settings.cordovadeviceplatform": "Cordova device platform",
"core.settings.cordovadeviceuuid": "Cordova device UUID",
"core.settings.cordovaversion": "Cordova version",
"core.settings.currentlanguage": "Current language",
"core.settings.debugdisplay": "Display debug messages",
"core.settings.debugdisplaydescription": "If enabled, error modals will display more data about the error if possible.",
"core.settings.deletesitefiles": "Are you sure that you want to delete the downloaded files and cached data from the site '{{sitename}}'? You won't be able to use the app in offline mode.",
"core.settings.deletesitefilestitle": "Delete site files",
"core.settings.deviceinfo": "Device info",
"core.settings.deviceos": "Device OS",
"core.settings.disableall": "Disable notifications",
"core.settings.disabled": "Disabled",
"core.settings.displayformat": "Display format",
"core.settings.enabledownloadsection": "Enable download sections",
"core.settings.enablefirebaseanalytics": "Enable Firebase analytics",
"core.settings.enablefirebaseanalyticsdescription": "If enabled, the app will collect anonymous data usage.",
"core.settings.enablerichtexteditor": "Enable text editor",
"core.settings.enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.",
"core.settings.enablesyncwifi": "Allow sync only when on Wi-Fi",
"core.settings.entriesincache": "{{$a}} entries in cache",
"core.settings.errordeletesitefiles": "Error deleting site files.",
"core.settings.errorsyncsite": "Error synchronising site data. Please check your Internet connection and try again.",
"core.settings.estimatedfreespace": "Estimated free space",
"core.settings.filesystemroot": "File system root",
"core.settings.fontsize": "Text size",
"core.settings.fontsizecharacter": "A",
"core.settings.forcedsetting": "This setting has been forced by your site configuration.",
"core.settings.general": "General",
"core.settings.language": "Language",
"core.settings.license": "Licence",
"core.settings.localnotifavailable": "Local notifications available",
"core.settings.locationhref": "Web view URL",
"core.settings.locked": "Locked",
"core.settings.loggedin": "Online",
"core.settings.loggedoff": "Offline",
"core.settings.navigatorlanguage": "Navigator language",
"core.settings.navigatoruseragent": "Navigator userAgent",
"core.settings.networkstatus": "Internet connection status",
"core.settings.opensourcelicenses": "Open Source Licences",
"core.settings.preferences": "Preferences",
"core.settings.privacypolicy": "Privacy policy",
"core.settings.publisher": "Publisher",
"core.settings.pushid": "Push notifications ID",
"core.settings.reportinbackground": "Report errors automatically",
"core.settings.screen": "Screen information",
"core.settings.settings": "Settings",
"core.settings.showdownloadoptions": "Show download options",
"core.settings.siteinfo": "Site info",
"core.settings.sites": "Sites",
"core.settings.spaceusage": "Space usage",
"core.settings.spaceusagehelp": "Deleting the stored information of the site will remove all the site offline data. This information allows you to use the app when offline. ",
"core.settings.synchronization": "Synchronisation",
"core.settings.synchronizenow": "Synchronise now",
"core.settings.synchronizenowhelp": "Synchronising a site will send pending changes and all offline activity stored in the device and will synchronise some data like messages and notifications.",
"core.settings.syncsettings": "Synchronisation settings",
"core.settings.total": "Total",
"core.settings.wificonnection": "Wi-Fi connection",
"core.show": "Show",
"core.showless": "Show less...",
"core.showmore": "Show more...",
"core.site": "Site",
"core.sitemaintenance": "The site is undergoing maintenance and is currently not available",
"core.sizeb": "bytes",
"core.sizegb": "GB",
"core.sizekb": "KB",
"core.sizemb": "MB",
"core.sizetb": "TB",
"core.skip": "Skip",
"core.sorry": "Sorry...",
"core.sort": "Sort",
"core.sortby": "Sort by",
"core.start": "Start",
"core.storingfiles": "Storing files",
"core.strftimedate": "%d %B %Y",
"core.strftimedatefullshort": "%d/%m/%y",
"core.strftimedateshort": "%d %B",
"core.strftimedatetime": "%d %B %Y, %I:%M %p",
"core.strftimedatetimeshort": "%d/%m/%y, %H:%M",
"core.strftimedaydate": "%A, %d %B %Y",
"core.strftimedaydatetime": "%A, %d %B %Y, %I:%M %p",
"core.strftimedayshort": "%A, %d %B",
"core.strftimedaytime": "%a, %H:%M",
"core.strftimemonthyear": "%B %Y",
"core.strftimerecent": "%d %b, %H:%M",
"core.strftimerecentfull": "%a, %d %b %Y, %I:%M %p",
"core.strftimetime": "%I:%M %p",
"core.strftimetime12": "%I:%M %p",
"core.strftimetime24": "%H:%M",
"core.submit": "Submit",
"core.success": "Success",
"core.tablet": "Tablet",
"core.teachers": "Teachers",
"core.thereisdatatosync": "There are offline {{$a}} to be synchronised.",
"core.thisdirection": "ltr",
"core.time": "Time",
"core.timesup": "Time is up!",
"core.today": "Today",
"core.tryagain": "Try again",
"core.twoparagraphs": "{{p1}}<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.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

@ -105,6 +105,13 @@ ion-list.list-md {
font-size: 14px;
}
// Item styles
.item.core-selected-item {
// TODO: Add safe are to border and RTL
border-inline-start: var(--selected-item-border-width) solid var(--selected-item-color);
--ion-safe-area-left: calc(-1 * var(--selected-item-border-width));
}
// Avatar
// -------------------------
// Large centered avatar

View File

@ -126,11 +126,18 @@
--background: var(--custom-tab-background, var(--white));
--color: var(--custom-tab-background, var(--gray-dark));
--border-color: var(--custom-tab-border-color, var(--gray));
--color-active: var(--custom-tab-color-active, var(--core-color));
--border-color-active: var(--custom-tab-border-color-active, var(--color-active));
--color-active: var(--custom-tab-color-active, var(--color));
--border-color-active: var(--custom-tab-border-color-active, var(--core-color));
}
}
ion-spinner {
--color: var(--core-color);
}
--selected-item-color: var(--custom-selected-item-color, var(--core-color));
--selected-item-border-width: var(--custom-selected-item-border-width, 5px);
--drop-shadow: var(--custom-drop-shadow, 0, 0, 0, 0.2);
--core-login-background: var(--custom-login-background, var(--white));