forked from CIT/Vmeda.Online
		
	
						commit
						782d94f6c0
					
				
							
								
								
									
										10
									
								
								.eslintrc.js
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								.eslintrc.js
									
									
									
									
									
								
							@ -126,7 +126,7 @@ const appConfig = {
 | 
			
		||||
                ignoreParameters: true,
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        '@typescript-eslint/no-non-null-assertion': 'off',
 | 
			
		||||
        '@typescript-eslint/no-non-null-assertion': 'warn',
 | 
			
		||||
        '@typescript-eslint/no-redeclare': 'error',
 | 
			
		||||
        '@typescript-eslint/no-this-alias': 'error',
 | 
			
		||||
        '@typescript-eslint/no-unused-vars': 'error',
 | 
			
		||||
@ -139,7 +139,6 @@ const appConfig = {
 | 
			
		||||
            'always',
 | 
			
		||||
        ],
 | 
			
		||||
        '@typescript-eslint/type-annotation-spacing': 'error',
 | 
			
		||||
        '@typescript-eslint/unified-signatures': 'error',
 | 
			
		||||
        'header/header': [
 | 
			
		||||
            2,
 | 
			
		||||
            'line',
 | 
			
		||||
@ -235,6 +234,11 @@ const appConfig = {
 | 
			
		||||
                prev: '*',
 | 
			
		||||
                next: 'return',
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                blankLine: 'always',
 | 
			
		||||
                prev: '*',
 | 
			
		||||
                next: 'function',
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        'prefer-arrow/prefer-arrow-functions': [
 | 
			
		||||
            'error',
 | 
			
		||||
@ -271,6 +275,7 @@ testsConfig['rules']['padded-blocks'] = [
 | 
			
		||||
        switches: 'never',
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
testsConfig['rules']['jest/expect-expect'] = 'off';
 | 
			
		||||
testsConfig['plugins'].push('jest');
 | 
			
		||||
testsConfig['extends'].push('plugin:jest/recommended');
 | 
			
		||||
 | 
			
		||||
@ -291,6 +296,7 @@ module.exports = {
 | 
			
		||||
                '@angular-eslint/template/no-positive-tabindex': 'error',
 | 
			
		||||
                '@angular-eslint/template/accessibility-table-scope': 'error',
 | 
			
		||||
                '@angular-eslint/template/accessibility-valid-aria': 'error',
 | 
			
		||||
                '@angular-eslint/template/no-duplicate-attributes': 'error',
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,2 @@
 | 
			
		||||
* text=auto
 | 
			
		||||
*.ts eol=lf
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/migration.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/migration.yml
									
									
									
									
										vendored
									
									
								
							@ -12,7 +12,7 @@ jobs:
 | 
			
		||||
    - name: Use Node.js
 | 
			
		||||
      uses: actions/setup-node@v1
 | 
			
		||||
      with:
 | 
			
		||||
        node-version: '12.x'
 | 
			
		||||
        node-version: '14.x'
 | 
			
		||||
    - run: npm ci
 | 
			
		||||
    - run: result=$(find src -type f -iname '*.html' -exec sh -c 'cat {} | tr "\n" " " | grep -Eo "class=\"[^\"]+\"[^>]+class=\"" ' \; | wc -l); test $result -eq 0
 | 
			
		||||
    - run: npm install -D @ionic/v4-migration-tslint
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										63
									
								
								.github/workflows/performance.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								.github/workflows/performance.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,63 @@
 | 
			
		||||
name: Performance
 | 
			
		||||
 | 
			
		||||
on: [push, pull_request]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  performance:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    env:
 | 
			
		||||
      MOODLE_DOCKER_DB: pgsql
 | 
			
		||||
      MOODLE_DOCKER_BROWSER: chrome
 | 
			
		||||
      MOODLE_DOCKER_PHP_VERSION: 7.3
 | 
			
		||||
    steps:
 | 
			
		||||
    - uses: actions/checkout@v2
 | 
			
		||||
    - id: nvmrc
 | 
			
		||||
      uses: browniebroke/read-nvmrc-action@v1
 | 
			
		||||
    - uses: actions/setup-node@v1
 | 
			
		||||
      with:
 | 
			
		||||
        node-version: '${{ steps.nvmrc.outputs.node_version }}'
 | 
			
		||||
    - name: Additional checkouts
 | 
			
		||||
      run: |
 | 
			
		||||
        git clone --branch master --depth 1 https://github.com/moodle/moodle $GITHUB_WORKSPACE/moodle
 | 
			
		||||
        git clone --branch integration --depth 1 https://github.com/moodlehq/moodle-local_moodlemobileapp $GITHUB_WORKSPACE/moodle/local/moodlemobileapp
 | 
			
		||||
        git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
 | 
			
		||||
    - name: Install npm packages
 | 
			
		||||
      run: |
 | 
			
		||||
        npm install -g npm@7
 | 
			
		||||
        npm ci --no-audit
 | 
			
		||||
    - name: Generate Behat tests plugin
 | 
			
		||||
      run: |
 | 
			
		||||
        export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
 | 
			
		||||
        npx gulp behat
 | 
			
		||||
    - name: Configure & launch Moodle with Docker
 | 
			
		||||
      run: |
 | 
			
		||||
        export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
 | 
			
		||||
        cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        sed -i "59i\        'capabilities' => [" $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        sed -i "60i\            'extra_capabilities' => [" $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        sed -i "61i\                'goog:loggingPrefs' => ['performance' => 'ALL']," $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        sed -i "62i\                'chromeOptions' => ['perfLoggingPrefs' => ['traceCategories' => 'devtools.timeline']]," $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        sed -i "63i\            ]," $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        sed -i "64i\        ]," $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        sed -i "76i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php
 | 
			
		||||
        $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull
 | 
			
		||||
        $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d
 | 
			
		||||
        $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db
 | 
			
		||||
    - name: Compile & launch production app with Docker
 | 
			
		||||
      run: |
 | 
			
		||||
        docker build -t moodlehq/moodleapp:performance .
 | 
			
		||||
        docker run -d --rm --name moodleapp moodlehq/moodleapp:performance
 | 
			
		||||
        docker network connect moodle-docker_default moodleapp --alias moodleapp
 | 
			
		||||
    - name: Init Behat
 | 
			
		||||
      run: |
 | 
			
		||||
        export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
 | 
			
		||||
        $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php"
 | 
			
		||||
    - name: Run performance tests
 | 
			
		||||
      run: |
 | 
			
		||||
        export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
 | 
			
		||||
        for i in {0..2}
 | 
			
		||||
        do
 | 
			
		||||
          $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --tags="@performance" --auto-rerun"
 | 
			
		||||
        done
 | 
			
		||||
    - name: Show performance results
 | 
			
		||||
      run: node ./scripts/print-performance-measures.js $GITHUB_WORKSPACE/moodle/behatperformancemeasures/
 | 
			
		||||
							
								
								
									
										12
									
								
								.github/workflows/testing.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/workflows/testing.yml
									
									
									
									
										vendored
									
									
								
							@ -12,9 +12,11 @@ jobs:
 | 
			
		||||
    - name: Use Node.js
 | 
			
		||||
      uses: actions/setup-node@v1
 | 
			
		||||
      with:
 | 
			
		||||
        node-version: '12.x'
 | 
			
		||||
        node-version: '14'
 | 
			
		||||
    - name: Install npm packages
 | 
			
		||||
      run: npm ci
 | 
			
		||||
      run: |
 | 
			
		||||
        npm install -g npm@7
 | 
			
		||||
        npm ci --no-audit
 | 
			
		||||
    - name: Check langindex
 | 
			
		||||
      run: |
 | 
			
		||||
        result=$(cat scripts/langindex.json | grep \"TBD\" | wc -l); test $result -eq 0
 | 
			
		||||
@ -49,11 +51,11 @@ jobs:
 | 
			
		||||
          echo "Found $found missing langkeys"
 | 
			
		||||
          exit 1
 | 
			
		||||
        fi
 | 
			
		||||
    - name: Run Linter
 | 
			
		||||
      run: npm run lint
 | 
			
		||||
    - name: Run Linter (ignore warnings)
 | 
			
		||||
      run: npm run lint -- --quiet
 | 
			
		||||
    - name: Run tests
 | 
			
		||||
      run: npm run test:ci
 | 
			
		||||
    - name: Production builds
 | 
			
		||||
      run: npm run build:prod
 | 
			
		||||
    - name: JavaScript code compatibility
 | 
			
		||||
      run: result=$(npx check-es-compat www/*.js 2> /dev/null | grep -v -E "Array\.prototype\.includes|Promise\.prototype\.finally|String\.prototype\.(matchAll|trimRight)|globalThis" | grep -Po "(?<=error).*?(?=\s+ecmascript)" | wc -l); test $result -eq 0
 | 
			
		||||
      run: result=$(npx check-es-compat www/*.js 2> /dev/null | grep -v -E "Array\.prototype\.includes|Promise\.prototype\.finally|String\.prototype\.(matchAll|trimRight)|globalThis" | grep -Po "(?<=error).*?(?=\s+ecmascript)" | wc -l); test $result -eq 1
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								.travis.yml
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								.travis.yml
									
									
									
									
									
								
							@ -1,6 +1,6 @@
 | 
			
		||||
os: linux
 | 
			
		||||
dist: trusty
 | 
			
		||||
node_js: 12
 | 
			
		||||
node_js: 14
 | 
			
		||||
 | 
			
		||||
git:
 | 
			
		||||
  depth: 3
 | 
			
		||||
@ -18,12 +18,12 @@ cache:
 | 
			
		||||
    - $HOME/.android/build-cache
 | 
			
		||||
 | 
			
		||||
before_install:
 | 
			
		||||
  - nvm install 12
 | 
			
		||||
  - nvm install
 | 
			
		||||
  - npm install npm@^7 -g
 | 
			
		||||
  - node --version
 | 
			
		||||
  - npm --version
 | 
			
		||||
  - nvm --version
 | 
			
		||||
  - npm ci
 | 
			
		||||
  - npm install npm@^6 -g
 | 
			
		||||
 | 
			
		||||
before_script:
 | 
			
		||||
  - npx gulp
 | 
			
		||||
@ -46,16 +46,30 @@ jobs:
 | 
			
		||||
      - extra-google-google_play_services
 | 
			
		||||
      - extra-google-m2repository
 | 
			
		||||
      - extra-android-m2repository
 | 
			
		||||
    before_install:
 | 
			
		||||
      - nvm install
 | 
			
		||||
      - npm install npm@^7 -g
 | 
			
		||||
      - node --version
 | 
			
		||||
      - npm --version
 | 
			
		||||
      - nvm --version
 | 
			
		||||
      - npm ci
 | 
			
		||||
      - yes | sdkmanager "build-tools;30.0.3"
 | 
			
		||||
    addons:
 | 
			
		||||
      apt:
 | 
			
		||||
        packages:
 | 
			
		||||
        - libsecret-1-dev
 | 
			
		||||
        - php5-cli
 | 
			
		||||
        - php5-common
 | 
			
		||||
  - stage: build
 | 
			
		||||
    name: "Build iOS"
 | 
			
		||||
    language: node_js
 | 
			
		||||
    if: env(BUILD_IOS) = 1 AND (env(DEPLOY) = 1 OR (env(DEPLOY) = 2 AND tag IS NOT blank))
 | 
			
		||||
    os: osx
 | 
			
		||||
    osx_image: xcode12.5
 | 
			
		||||
    osx_image: xcode13.1
 | 
			
		||||
    addons:
 | 
			
		||||
      homebrew:
 | 
			
		||||
        packages:
 | 
			
		||||
        - jq
 | 
			
		||||
  - stage: test
 | 
			
		||||
    name: "End to end tests (mod_forum and mod_messages)"
 | 
			
		||||
    services:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
    "recommendations": [
 | 
			
		||||
        "dbaeumer.vscode-eslint"
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							@ -1,5 +1,24 @@
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Formatting.
 | 
			
		||||
     */
 | 
			
		||||
    "editor.defaultFormatter": "dbaeumer.vscode-eslint",
 | 
			
		||||
    "[html]": {
 | 
			
		||||
        "editor.defaultFormatter": "vscode.html-language-features",
 | 
			
		||||
    },
 | 
			
		||||
    "editor.formatOnSave": true,
 | 
			
		||||
    "eslint.format.enable": true,
 | 
			
		||||
    "html.format.endWithNewline": true,
 | 
			
		||||
    "html.format.wrapLineLength": 140,
 | 
			
		||||
    "files.eol": "\n",
 | 
			
		||||
    "files.trimFinalNewlines": true,
 | 
			
		||||
    "files.insertFinalNewline": true,
 | 
			
		||||
    "files.trimTrailingWhitespace": true,
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Config files.
 | 
			
		||||
     */
 | 
			
		||||
    "files.associations": {
 | 
			
		||||
        "moodle.config.json": "jsonc",
 | 
			
		||||
        "moodle.config.*.json": "jsonc",
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,8 @@ WORKDIR /app
 | 
			
		||||
# Prepare node dependencies
 | 
			
		||||
RUN apt-get update && apt-get install libsecret-1-0 -y
 | 
			
		||||
COPY package*.json ./
 | 
			
		||||
RUN npm ci
 | 
			
		||||
RUN npm install -g npm@7
 | 
			
		||||
RUN npm ci --no-audit
 | 
			
		||||
 | 
			
		||||
# Build source
 | 
			
		||||
ARG build_command="npm run build:prod"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								README.md
									
									
									
									
									
								
							@ -1,22 +1,15 @@
 | 
			
		||||
Moodle Mobile
 | 
			
		||||
Moodle App
 | 
			
		||||
=================
 | 
			
		||||
 | 
			
		||||
This is the primary repository of source code for the official Moodle Mobile app.
 | 
			
		||||
This is the primary repository of source code for the official mobile app for Moodle.
 | 
			
		||||
 | 
			
		||||
* [User documentation](http://docs.moodle.org/en/Moodle_Mobile)
 | 
			
		||||
* [Developer documentation](http://docs.moodle.org/dev/Moodle_Mobile)
 | 
			
		||||
* [Development environment setup](http://docs.moodle.org/dev/Setting_up_your_development_environment_for_Moodle_Mobile_2)
 | 
			
		||||
* [User documentation](https://docs.moodle.org/en/Moodle_app)
 | 
			
		||||
* [Developer documentation](http://docs.moodle.org/dev/Moodle_App)
 | 
			
		||||
* [Development environment setup](https://docs.moodle.org/dev/Setting_up_your_development_environment_for_the_Moodle_App)
 | 
			
		||||
* [Bug Tracker](https://tracker.moodle.org/browse/MOBILE)
 | 
			
		||||
* [Release Notes](http://docs.moodle.org/dev/Moodle_Mobile_Release_Notes)
 | 
			
		||||
* [Release Notes](https://docs.moodle.org/dev/Moodle_App_Release_Notes)
 | 
			
		||||
 | 
			
		||||
License
 | 
			
		||||
-------
 | 
			
		||||
 | 
			
		||||
[Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)
 | 
			
		||||
 | 
			
		||||
Big Thanks
 | 
			
		||||
-----------
 | 
			
		||||
 | 
			
		||||
Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs](https://saucelabs.com)
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
							
								
								
									
										26
									
								
								angular.json
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								angular.json
									
									
									
									
									
								
							@ -12,8 +12,14 @@
 | 
			
		||||
      "schematics": {},
 | 
			
		||||
      "architect": {
 | 
			
		||||
        "build": {
 | 
			
		||||
          "builder": "@angular-devkit/build-angular:browser",
 | 
			
		||||
          "builder": "@angular-builders/custom-webpack:browser",
 | 
			
		||||
          "options": {
 | 
			
		||||
            "customWebpackConfig": {
 | 
			
		||||
              "path": "./webpack.config.js"
 | 
			
		||||
            },
 | 
			
		||||
            "allowedCommonJsDependencies":[
 | 
			
		||||
                "chart.js"
 | 
			
		||||
            ],
 | 
			
		||||
            "outputPath": "www",
 | 
			
		||||
            "index": "src/index.html",
 | 
			
		||||
            "main": "src/main.ts",
 | 
			
		||||
@ -55,11 +61,25 @@
 | 
			
		||||
              "budgets": [
 | 
			
		||||
                {
 | 
			
		||||
                  "type": "initial",
 | 
			
		||||
                  "maximumWarning": "50mb",
 | 
			
		||||
                  "maximumError": "100mb"
 | 
			
		||||
                  "maximumWarning": "5mb",
 | 
			
		||||
                  "maximumError": "20mb"
 | 
			
		||||
                }
 | 
			
		||||
              ]
 | 
			
		||||
            },
 | 
			
		||||
            "testing": {
 | 
			
		||||
              "optimization": {
 | 
			
		||||
                "scripts": false,
 | 
			
		||||
                "styles": true
 | 
			
		||||
              },
 | 
			
		||||
              "outputHashing": "all",
 | 
			
		||||
              "sourceMap": false,
 | 
			
		||||
              "extractCss": true,
 | 
			
		||||
              "namedChunks": false,
 | 
			
		||||
              "aot": true,
 | 
			
		||||
              "extractLicenses": true,
 | 
			
		||||
              "vendorChunk": false,
 | 
			
		||||
              "buildOptimizer": true
 | 
			
		||||
            },
 | 
			
		||||
            "ci": {
 | 
			
		||||
              "progress": false
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										28
									
								
								config.xml
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								config.xml
									
									
									
									
									
								
							@ -1,5 +1,5 @@
 | 
			
		||||
<?xml version='1.0' encoding='utf-8'?>
 | 
			
		||||
<widget android-versionCode="39503" id="com.moodle.moodlemobile" ios-CFBundleVersion="3.9.5.3" version="3.9.5" versionCode="39503" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
 | 
			
		||||
<widget android-versionCode="40001" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.0.0.1" version="4.0.0" versionCode="40001" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
 | 
			
		||||
    <name>Moodle</name>
 | 
			
		||||
    <description>Moodle official app</description>
 | 
			
		||||
    <author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author>
 | 
			
		||||
@ -27,7 +27,7 @@
 | 
			
		||||
    <preference name="UIWebViewBounce" value="false" />
 | 
			
		||||
    <preference name="DisallowOverscroll" value="true" />
 | 
			
		||||
    <preference name="prerendered-icon" value="true" />
 | 
			
		||||
    <preference name="AppendUserAgent" value="MoodleMobile" />
 | 
			
		||||
    <preference name="AppendUserAgent" value="MoodleMobile 4.0.0 (40000)" />
 | 
			
		||||
    <preference name="BackupWebStorage" value="none" />
 | 
			
		||||
    <preference name="ScrollEnabled" value="false" />
 | 
			
		||||
    <preference name="KeyboardDisplayRequiresUserAction" value="false" />
 | 
			
		||||
@ -47,6 +47,11 @@
 | 
			
		||||
    <preference name="iosPersistentFileLocation" value="Compatibility" />
 | 
			
		||||
    <preference name="iosScheme" value="moodleappfs" />
 | 
			
		||||
    <preference name="WKWebViewOnly" value="true" />
 | 
			
		||||
    <preference name="WKFullScreenEnabled" value="true" />
 | 
			
		||||
    <preference name="AndroidXEnabled" value="true" />
 | 
			
		||||
    <preference name="GradlePluginGoogleServicesEnabled" value="true" />
 | 
			
		||||
    <preference name="GradlePluginGoogleServicesVersion" value="4.3.10" />
 | 
			
		||||
    <preference name="StatusBarOverlaysWebView" value="false" />
 | 
			
		||||
    <feature name="StatusBar">
 | 
			
		||||
        <param name="ios-package" onload="true" value="CDVStatusBar" />
 | 
			
		||||
    </feature>
 | 
			
		||||
@ -57,11 +62,12 @@
 | 
			
		||||
        <resource-file src="resources/android/icon/drawable-mdpi-smallicon.png" target="app/src/main/res/mipmap-mdpi/smallicon.png" />
 | 
			
		||||
        <resource-file src="resources/android/icon/drawable-hdpi-smallicon.png" target="app/src/main/res/mipmap-hdpi/smallicon.png" />
 | 
			
		||||
        <resource-file src="resources/android/icon/drawable-xhdpi-smallicon.png" target="app/src/main/res/mipmap-xhdpi/smallicon.png" />
 | 
			
		||||
        <resource-file src="resources/android/xml/network_security_config.xml" target="app/src/main/res/xml/network_security_config.xml" />
 | 
			
		||||
        <edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application/activity[@android:name='MainActivity']">
 | 
			
		||||
            <activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|screenLayout|smallestScreenSize" />
 | 
			
		||||
        </edit-config>
 | 
			
		||||
        <edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application">
 | 
			
		||||
            <application android:largeHeap="true" android:usesCleartextTraffic="true" />
 | 
			
		||||
            <application android:largeHeap="true" android:networkSecurityConfig="@xml/network_security_config" />
 | 
			
		||||
        </edit-config>
 | 
			
		||||
        <config-file parent="/manifest/application" target="AndroidManifest.xml">
 | 
			
		||||
            <meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
 | 
			
		||||
@ -124,11 +130,6 @@
 | 
			
		||||
                <param name="android-package" value="org.apache.cordova.geolocation.Geolocation" />
 | 
			
		||||
            </feature>
 | 
			
		||||
        </config-file>
 | 
			
		||||
        <config-file parent="/*" target="res/xml/config.xml">
 | 
			
		||||
            <feature name="Globalization">
 | 
			
		||||
                <param name="android-package" value="org.apache.cordova.globalization.Globalization" />
 | 
			
		||||
            </feature>
 | 
			
		||||
        </config-file>
 | 
			
		||||
        <config-file parent="/*" target="res/xml/config.xml">
 | 
			
		||||
            <feature name="InAppBrowser">
 | 
			
		||||
                <param name="android-package" value="org.apache.cordova.inappbrowser.InAppBrowser" />
 | 
			
		||||
@ -185,12 +186,6 @@
 | 
			
		||||
                <param name="onload" value="true" />
 | 
			
		||||
            </feature>
 | 
			
		||||
        </config-file>
 | 
			
		||||
        <config-file parent="/*" target="res/xml/config.xml">
 | 
			
		||||
            <feature name="Whitelist">
 | 
			
		||||
                <param name="android-package" value="org.apache.cordova.whitelist.WhitelistPlugin" />
 | 
			
		||||
                <param name="onload" value="true" />
 | 
			
		||||
            </feature>
 | 
			
		||||
        </config-file>
 | 
			
		||||
        <config-file parent="/*" target="res/xml/config.xml">
 | 
			
		||||
            <feature name="SQLitePlugin">
 | 
			
		||||
                <param name="android-package" value="io.sqlc.SQLitePlugin" />
 | 
			
		||||
@ -256,7 +251,7 @@
 | 
			
		||||
            <true />
 | 
			
		||||
        </edit-config>
 | 
			
		||||
        <edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString">
 | 
			
		||||
            <string>3.9.5</string>
 | 
			
		||||
            <string>4.0.0</string>
 | 
			
		||||
        </edit-config>
 | 
			
		||||
        <edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations">
 | 
			
		||||
            <array>
 | 
			
		||||
@ -288,6 +283,9 @@
 | 
			
		||||
        <config-file parent="NSCrossWebsiteTrackingUsageDescription" target="*-Info.plist">
 | 
			
		||||
            <string>This app needs third party cookies to correctly render embedded content from the Moodle site.</string>
 | 
			
		||||
        </config-file>
 | 
			
		||||
        <config-file parent="ITSAppUsesNonExemptEncryption" target="*-Info.plist">
 | 
			
		||||
            <false />
 | 
			
		||||
        </config-file>
 | 
			
		||||
        <config-file parent="CFBundleDocumentTypes" target="*-Info.plist">
 | 
			
		||||
            <array>
 | 
			
		||||
                <dict>
 | 
			
		||||
 | 
			
		||||
@ -69,3 +69,7 @@ gulp.task('watch', () => {
 | 
			
		||||
        gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat'));
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
gulp.task('watch-behat', () => {
 | 
			
		||||
    gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat'));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2226
									
								
								licenses.json
									
									
									
									
									
								
							
							
						
						
									
										2226
									
								
								licenses.json
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -1,8 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
    "app_id": "com.moodle.moodlemobile",
 | 
			
		||||
    "appname": "Moodle Mobile",
 | 
			
		||||
    "versioncode": 3950,
 | 
			
		||||
    "versionname": "3.9.5",
 | 
			
		||||
    "versioncode": 40000,
 | 
			
		||||
    "versionname": "4.0.0",
 | 
			
		||||
    "cache_update_frequency_usually": 420000,
 | 
			
		||||
    "cache_update_frequency_often": 1200000,
 | 
			
		||||
    "cache_update_frequency_sometimes": 3600000,
 | 
			
		||||
@ -30,6 +30,7 @@
 | 
			
		||||
        "he": "עברית",
 | 
			
		||||
        "hi": "हिंदी",
 | 
			
		||||
        "hr": "Hrvatski",
 | 
			
		||||
        "hsb": "Hornjoserbsski",
 | 
			
		||||
        "hu": "magyar",
 | 
			
		||||
        "hy": "Հայերեն",
 | 
			
		||||
        "id": "Indonesian",
 | 
			
		||||
@ -38,6 +39,7 @@
 | 
			
		||||
        "km": "ខ្មែរ",
 | 
			
		||||
        "kn": "ಕನ್ನಡ",
 | 
			
		||||
        "ko": "한국어",
 | 
			
		||||
        "lo": "ລາວ",
 | 
			
		||||
        "lt": "Lietuvių",
 | 
			
		||||
        "lv": "Latviešu",
 | 
			
		||||
        "mn": "Монгол",
 | 
			
		||||
@ -62,15 +64,14 @@
 | 
			
		||||
        "zh-tw": "正體中文"
 | 
			
		||||
    },
 | 
			
		||||
    "wsservice": "moodle_mobile_app",
 | 
			
		||||
    "wsextservice": "local_mobile",
 | 
			
		||||
    "demo_sites": {
 | 
			
		||||
        "student": {
 | 
			
		||||
            "url": "https:\/\/school.moodledemo.net",
 | 
			
		||||
            "url": "https://school.moodledemo.net",
 | 
			
		||||
            "username": "student",
 | 
			
		||||
            "password": "moodle"
 | 
			
		||||
        },
 | 
			
		||||
        "teacher": {
 | 
			
		||||
            "url": "https:\/\/school.moodledemo.net",
 | 
			
		||||
            "url": "https://school.moodledemo.net",
 | 
			
		||||
            "username": "teacher",
 | 
			
		||||
            "password": "moodle"
 | 
			
		||||
        }
 | 
			
		||||
@ -88,7 +89,7 @@
 | 
			
		||||
    "onlyallowlistedsites": false,
 | 
			
		||||
    "skipssoconfirmation": false,
 | 
			
		||||
    "forcedefaultlanguage": false,
 | 
			
		||||
    "privacypolicy": "https:\/\/moodle.net\/moodle-app-privacy\/",
 | 
			
		||||
    "privacypolicy": "https://moodle.net/moodle-app-privacy/",
 | 
			
		||||
    "notificoncolor": "#f98012",
 | 
			
		||||
    "enableanalytics": false,
 | 
			
		||||
    "enableonboarding": true,
 | 
			
		||||
@ -98,5 +99,8 @@
 | 
			
		||||
    "appstores": {
 | 
			
		||||
        "android": "com.moodle.moodlemobile",
 | 
			
		||||
        "ios": "id633359593"
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
    "wsrequestqueuelimit": 10,
 | 
			
		||||
    "wsrequestqueuedelay": 100,
 | 
			
		||||
    "calendarreminderdefaultvalue": 3600
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										34615
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										34615
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										124
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										124
									
								
								package.json
									
									
									
									
									
								
							@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "moodlemobile",
 | 
			
		||||
  "version": "3.9.5",
 | 
			
		||||
  "version": "4.0.0",
 | 
			
		||||
  "description": "The official app for Moodle.",
 | 
			
		||||
  "author": {
 | 
			
		||||
    "name": "Moodle Pty Ltd.",
 | 
			
		||||
@ -8,7 +8,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "repository": {
 | 
			
		||||
    "type": "git",
 | 
			
		||||
    "url": "https://github.com/moodlehq/moodlemobile2.git"
 | 
			
		||||
    "url": "https://github.com/moodlehq/moodleapp.git"
 | 
			
		||||
  },
 | 
			
		||||
  "license": "Apache-2.0",
 | 
			
		||||
  "licenses": [
 | 
			
		||||
@ -19,21 +19,22 @@
 | 
			
		||||
  ],
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "ng": "ng",
 | 
			
		||||
    "start": "ionic serve",
 | 
			
		||||
    "start": "ionic serve --browser=$MOODLE_APP_BROWSER",
 | 
			
		||||
    "serve:test": "NODE_ENV=testing ionic serve --no-open",
 | 
			
		||||
    "build": "ionic build",
 | 
			
		||||
    "build:prod": "NODE_ENV=production ionic build --prod",
 | 
			
		||||
    "build:test": "NODE_ENV=testing ionic build",
 | 
			
		||||
    "build:test": "NODE_ENV=testing ionic build --configuration=testing",
 | 
			
		||||
    "dev:android": "ionic cordova run android --livereload",
 | 
			
		||||
    "dev:ios": "ionic cordova run ios --livereload",
 | 
			
		||||
    "prod:android": "NODE_ENV=production ionic cordova run android --aot",
 | 
			
		||||
    "prod:ios": "NODE_ENV=production ionic cordova run ios --aot",
 | 
			
		||||
    "dev:ios": "ionic cordova run ios",
 | 
			
		||||
    "prod:android": "NODE_ENV=production ionic cordova run android --prod",
 | 
			
		||||
    "prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
 | 
			
		||||
    "test": "NODE_ENV=testing gulp && jest --verbose",
 | 
			
		||||
    "test:ci": "NODE_ENV=testing gulp && jest -ci --runInBand --verbose",
 | 
			
		||||
    "test:watch": "NODE_ENV=testing gulp watch & jest --watch",
 | 
			
		||||
    "test:coverage": "NODE_ENV=testing gulp && jest --coverage",
 | 
			
		||||
    "lint": "NODE_OPTIONS=--max-old-space-size=4096 ng lint",
 | 
			
		||||
    "ionic:serve:before": "gulp",
 | 
			
		||||
    "ionic:serve": "gulp watch & NODE_OPTIONS=--max-old-space-size=4096 ng serve",
 | 
			
		||||
    "ionic:serve": "cross-env-shell ./scripts/serve.sh",
 | 
			
		||||
    "ionic:build:before": "gulp"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
@ -70,65 +71,61 @@
 | 
			
		||||
    "@ionic-native/status-bar": "5.33.0",
 | 
			
		||||
    "@ionic-native/web-intent": "5.33.0",
 | 
			
		||||
    "@ionic-native/zip": "5.33.0",
 | 
			
		||||
    "@ionic/angular": "5.6.6",
 | 
			
		||||
    "@ionic/angular": "5.9.2",
 | 
			
		||||
    "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5",
 | 
			
		||||
    "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3",
 | 
			
		||||
    "@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1",
 | 
			
		||||
    "@moodlehq/cordova-plugin-local-notification": "0.9.0-moodle.3",
 | 
			
		||||
    "@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2",
 | 
			
		||||
    "@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1",
 | 
			
		||||
    "@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4",
 | 
			
		||||
    "@ngx-translate/core": "13.0.0",
 | 
			
		||||
    "@ngx-translate/http-loader": "6.0.0",
 | 
			
		||||
    "@types/chart.js": "2.9.31",
 | 
			
		||||
    "@types/cordova": "0.0.34",
 | 
			
		||||
    "@types/cordova-plugin-file-transfer": "1.6.2",
 | 
			
		||||
    "@types/dom-mediacapture-record": "1.0.7",
 | 
			
		||||
    "chart.js": "2.9.4",
 | 
			
		||||
    "com-darryncampbell-cordova-plugin-intent": "1.3.0",
 | 
			
		||||
    "cordova": "10.0.0",
 | 
			
		||||
    "cordova-android": "9.1.0",
 | 
			
		||||
    "cordova-android-support-gradle-release": "3.0.1",
 | 
			
		||||
    "com-darryncampbell-cordova-plugin-intent": "2.2.0",
 | 
			
		||||
    "cordova": "11.0.0",
 | 
			
		||||
    "cordova-android": "10.1.1",
 | 
			
		||||
    "cordova-clipboard": "1.3.0",
 | 
			
		||||
    "cordova-ios": "6.2.0",
 | 
			
		||||
    "cordova-plugin-add-swift-support": "2.0.2",
 | 
			
		||||
    "cordova-plugin-advanced-http": "3.1.0",
 | 
			
		||||
    "cordova-plugin-advanced-http": "3.2.2",
 | 
			
		||||
    "cordova-plugin-badge": "0.8.8",
 | 
			
		||||
    "cordova-plugin-camera": "5.0.1",
 | 
			
		||||
    "cordova-plugin-camera": "6.0.0",
 | 
			
		||||
    "cordova-plugin-chooser": "1.3.2",
 | 
			
		||||
    "cordova-plugin-customurlscheme": "5.0.2",
 | 
			
		||||
    "cordova-plugin-device": "2.0.3",
 | 
			
		||||
    "cordova-plugin-file": "6.0.2",
 | 
			
		||||
    "cordova-plugin-file-opener2": "3.0.5",
 | 
			
		||||
    "cordova-plugin-file-transfer": "git+https://github.com/moodlemobile/cordova-plugin-file-transfer.git",
 | 
			
		||||
    "cordova-plugin-geolocation": "4.1.0",
 | 
			
		||||
    "cordova-plugin-globalization": "1.11.0",
 | 
			
		||||
    "cordova-plugin-inappbrowser": "git+https://github.com/moodlemobile/cordova-plugin-inappbrowser.git#moodle-ionic5",
 | 
			
		||||
    "cordova-plugin-ionic-keyboard": "2.2.0",
 | 
			
		||||
    "cordova-plugin-ionic-webview": "5.0.0",
 | 
			
		||||
    "cordova-plugin-local-notification": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle",
 | 
			
		||||
    "cordova-plugin-media": "5.0.3",
 | 
			
		||||
    "cordova-plugin-media": "5.0.4",
 | 
			
		||||
    "cordova-plugin-media-capture": "3.0.3",
 | 
			
		||||
    "cordova-plugin-network-information": "2.0.2",
 | 
			
		||||
    "cordova-plugin-qrscanner": "git+https://github.com/moodlemobile/cordova-plugin-qrscanner.git#dist",
 | 
			
		||||
    "cordova-plugin-screen-orientation": "3.0.2",
 | 
			
		||||
    "cordova-plugin-network-information": "3.0.0",
 | 
			
		||||
    "cordova-plugin-prevent-override": "1.0.1",
 | 
			
		||||
    "cordova-plugin-splashscreen": "6.0.0",
 | 
			
		||||
    "cordova-plugin-statusbar": "2.4.3",
 | 
			
		||||
    "cordova-plugin-whitelist": "1.3.4",
 | 
			
		||||
    "cordova-plugin-wkuserscript": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git",
 | 
			
		||||
    "cordova-plugin-wkwebview-cookies": "git+https://github.com/moodlemobile/cordova-plugin-wkwebview-cookies.git",
 | 
			
		||||
    "cordova-plugin-zip": "3.1.0",
 | 
			
		||||
    "cordova-plugin-statusbar": "3.0.0",
 | 
			
		||||
    "cordova-plugin-wkuserscript": "1.0.1",
 | 
			
		||||
    "cordova-plugin-wkwebview-cookies": "1.0.1",
 | 
			
		||||
    "cordova-sqlite-storage": "6.0.0",
 | 
			
		||||
    "cordova-support-google-services": "1.3.2",
 | 
			
		||||
    "cordova.plugins.diagnostic": "5.0.2",
 | 
			
		||||
    "cordova.plugins.diagnostic": "6.1.1",
 | 
			
		||||
    "core-js": "3.9.1",
 | 
			
		||||
    "es6-promise-plugin": "4.2.2",
 | 
			
		||||
    "jszip": "3.5.0",
 | 
			
		||||
    "hammerjs": "2.0.8",
 | 
			
		||||
    "jszip": "3.7.1",
 | 
			
		||||
    "mathjax": "2.7.7",
 | 
			
		||||
    "moment": "2.29.0",
 | 
			
		||||
    "moment": "2.29.2",
 | 
			
		||||
    "nl.kingsquare.cordova.background-audio": "1.0.1",
 | 
			
		||||
    "phonegap-plugin-multidex": "1.0.0",
 | 
			
		||||
    "phonegap-plugin-push": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#moodle-v3",
 | 
			
		||||
    "rxjs": "6.5.5",
 | 
			
		||||
    "ts-md5": "1.2.7",
 | 
			
		||||
    "tslib": "2.0.1",
 | 
			
		||||
    "tslib": "2.3.1",
 | 
			
		||||
    "zone.js": "0.10.3"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@angular-devkit/architect": "0.1101.2",
 | 
			
		||||
    "@angular-builders/custom-webpack": "10.0.1",
 | 
			
		||||
    "@angular-devkit/architect": "0.1202.7",
 | 
			
		||||
    "@angular-devkit/build-angular": "0.1000.8",
 | 
			
		||||
    "@angular-eslint/builder": "4.2.0",
 | 
			
		||||
    "@angular-eslint/eslint-plugin": "4.2.0",
 | 
			
		||||
@ -140,7 +137,7 @@
 | 
			
		||||
    "@angular/compiler-cli": "10.0.14",
 | 
			
		||||
    "@angular/language-service": "10.0.14",
 | 
			
		||||
    "@ionic/angular-toolkit": "2.3.3",
 | 
			
		||||
    "@ionic/cli": "6.14.1",
 | 
			
		||||
    "@ionic/cli": "6.19.0",
 | 
			
		||||
    "@types/faker": "5.1.3",
 | 
			
		||||
    "@types/node": "12.12.64",
 | 
			
		||||
    "@types/resize-observer-browser": "0.1.5",
 | 
			
		||||
@ -148,7 +145,9 @@
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "4.22.0",
 | 
			
		||||
    "@typescript-eslint/parser": "4.22.0",
 | 
			
		||||
    "check-es-compat": "1.1.1",
 | 
			
		||||
    "cordova-plugin-prevent-override": "git+https://github.com/moodlemobile/cordova-plugin-prevent-override.git",
 | 
			
		||||
    "cordova-plugin-androidx-adapter": "1.1.3",
 | 
			
		||||
    "cordova-plugin-screen-orientation": "^3.0.2",
 | 
			
		||||
    "cross-env": "7.0.3",
 | 
			
		||||
    "eslint": "7.25.0",
 | 
			
		||||
    "eslint-config-prettier": "8.3.0",
 | 
			
		||||
    "eslint-plugin-header": "3.1.1",
 | 
			
		||||
@ -169,13 +168,14 @@
 | 
			
		||||
    "jest": "26.5.2",
 | 
			
		||||
    "jest-preset-angular": "8.3.1",
 | 
			
		||||
    "jsonc-parser": "2.3.1",
 | 
			
		||||
    "native-run": "^1.4.0",
 | 
			
		||||
    "native-run": "1.4.0",
 | 
			
		||||
    "terser-webpack-plugin": "4.2.3",
 | 
			
		||||
    "ts-jest": "26.4.1",
 | 
			
		||||
    "ts-node": "8.3.0",
 | 
			
		||||
    "typescript": "3.9.9"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=12.x"
 | 
			
		||||
    "node": ">=14.15.0 <15"
 | 
			
		||||
  },
 | 
			
		||||
  "cordova": {
 | 
			
		||||
    "platforms": [
 | 
			
		||||
@ -183,11 +183,14 @@
 | 
			
		||||
      "ios"
 | 
			
		||||
    ],
 | 
			
		||||
    "plugins": {
 | 
			
		||||
      "cordova-plugin-advanced-http": {},
 | 
			
		||||
      "cordova-plugin-advanced-http": {
 | 
			
		||||
        "ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1"
 | 
			
		||||
      },
 | 
			
		||||
      "cordova-clipboard": {},
 | 
			
		||||
      "cordova-plugin-badge": {},
 | 
			
		||||
      "cordova-plugin-camera": {
 | 
			
		||||
        "ANDROID_SUPPORT_V4_VERSION": "27.+"
 | 
			
		||||
        "ANDROID_SUPPORT_V4_VERSION": "27.+",
 | 
			
		||||
        "ANDROIDX_CORE_VERSION": "1.6.+"
 | 
			
		||||
      },
 | 
			
		||||
      "cordova-plugin-chooser": {},
 | 
			
		||||
      "cordova-plugin-customurlscheme": {
 | 
			
		||||
@ -203,10 +206,10 @@
 | 
			
		||||
      "cordova-plugin-geolocation": {
 | 
			
		||||
        "GPS_REQUIRED": "false"
 | 
			
		||||
      },
 | 
			
		||||
      "cordova-plugin-inappbrowser": {},
 | 
			
		||||
      "@moodlehq/cordova-plugin-inappbrowser": {},
 | 
			
		||||
      "cordova-plugin-ionic-keyboard": {},
 | 
			
		||||
      "cordova-plugin-ionic-webview": {},
 | 
			
		||||
      "cordova-plugin-local-notification": {
 | 
			
		||||
      "@moodlehq/cordova-plugin-ionic-webview": {},
 | 
			
		||||
      "@moodlehq/cordova-plugin-local-notification": {
 | 
			
		||||
        "ANDROID_SUPPORT_V4_VERSION": "26.+"
 | 
			
		||||
      },
 | 
			
		||||
      "cordova-plugin-media-capture": {},
 | 
			
		||||
@ -214,30 +217,29 @@
 | 
			
		||||
        "KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
 | 
			
		||||
      },
 | 
			
		||||
      "cordova-plugin-network-information": {},
 | 
			
		||||
      "cordova-plugin-qrscanner": {},
 | 
			
		||||
      "cordova-plugin-screen-orientation": {},
 | 
			
		||||
      "@moodlehq/cordova-plugin-qrscanner": {},
 | 
			
		||||
      "cordova-plugin-splashscreen": {},
 | 
			
		||||
      "cordova-plugin-statusbar": {},
 | 
			
		||||
      "cordova-plugin-whitelist": {},
 | 
			
		||||
      "cordova-plugin-wkuserscript": {},
 | 
			
		||||
      "cordova-plugin-wkwebview-cookies": {},
 | 
			
		||||
      "cordova-plugin-zip": {},
 | 
			
		||||
      "@moodlehq/cordova-plugin-zip": {},
 | 
			
		||||
      "cordova-sqlite-storage": {},
 | 
			
		||||
      "phonegap-plugin-push": {
 | 
			
		||||
        "ANDROID_SUPPORT_V13_VERSION": "27.+",
 | 
			
		||||
        "FCM_VERSION": "17.0.+"
 | 
			
		||||
      "@moodlehq/phonegap-plugin-push": {
 | 
			
		||||
        "ANDROID_SUPPORT_V13_VERSION": "28.0.0",
 | 
			
		||||
        "FCM_VERSION": "18.+",
 | 
			
		||||
        "IOS_FIREBASE_MESSAGING_VERSION": "~> 6.32.2"
 | 
			
		||||
      },
 | 
			
		||||
      "com-darryncampbell-cordova-plugin-intent": {},
 | 
			
		||||
      "nl.kingsquare.cordova.background-audio": {},
 | 
			
		||||
      "cordova-android-support-gradle-release": {
 | 
			
		||||
        "ANDROID_SUPPORT_VERSION": "27.+"
 | 
			
		||||
      },
 | 
			
		||||
      "cordova.plugins.diagnostic": {
 | 
			
		||||
        "ANDROID_SUPPORT_VERSION": "28.+"
 | 
			
		||||
        "ANDROID_SUPPORT_VERSION": "28.+",
 | 
			
		||||
        "ANDROIDX_VERSION": "1.0.0",
 | 
			
		||||
        "ANDROIDX_APPCOMPAT_VERSION": "1.3.1"
 | 
			
		||||
      },
 | 
			
		||||
      "cordova-plugin-globalization": {},
 | 
			
		||||
      "cordova-plugin-file-transfer": {},
 | 
			
		||||
      "cordova-plugin-prevent-override": {}
 | 
			
		||||
      "@moodlehq/cordova-plugin-file-transfer": {},
 | 
			
		||||
      "cordova-plugin-prevent-override": {},
 | 
			
		||||
      "cordova-plugin-androidx-adapter": {},
 | 
			
		||||
      "cordova-plugin-screen-orientation": {}
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "optionalDependencies": {
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,4 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<network-security-config>
 | 
			
		||||
    <domain-config cleartextTrafficPermitted="true">
 | 
			
		||||
        <domain includeSubdomains="true">localhost</domain>
 | 
			
		||||
    </domain-config>
 | 
			
		||||
</network-security-config>
 | 
			
		||||
    <base-config cleartextTrafficPermitted="true" />
 | 
			
		||||
</network-security-config>
 | 
			
		||||
@ -113,10 +113,17 @@ function add_langs_to_config($langs, $config) {
 | 
			
		||||
        $config['languages'] = json_decode( json_encode( $config['languages'] ), true );
 | 
			
		||||
        ksort($config['languages']);
 | 
			
		||||
        $config['languages'] = json_decode( json_encode( $config['languages'] ), false );
 | 
			
		||||
        file_put_contents(CONFIG, json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
 | 
			
		||||
        save_json(CONFIG, $config);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Save json data.
 | 
			
		||||
 */
 | 
			
		||||
function save_json($path, $content) {
 | 
			
		||||
    file_put_contents($path, str_replace('\/', '/', json_encode($content, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))."\n");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function get_langfolder($lang) {
 | 
			
		||||
    $folder = LANGPACKSFOLDER.'/'.str_replace('-', '_', $lang);
 | 
			
		||||
    if (!is_dir($folder) || !is_file($folder.'/langconfig.php')) {
 | 
			
		||||
@ -246,9 +253,11 @@ function build_lang($lang, $keys) {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($value->file != 'local_moodlemobileapp') {
 | 
			
		||||
            $text = str_replace('$a->@', '$a.', $text);
 | 
			
		||||
            $text = str_replace('$a->', '$a.', $text);
 | 
			
		||||
            $text = str_replace('{$a', '{{$a', $text);
 | 
			
		||||
            $text = str_replace('}', '}}', $text);
 | 
			
		||||
            $text = preg_replace('/@@.+?@@(<br>)?\\s*/', '', $text);
 | 
			
		||||
            // Prevent double.
 | 
			
		||||
            $text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text);
 | 
			
		||||
        } else {
 | 
			
		||||
@ -270,7 +279,7 @@ function build_lang($lang, $keys) {
 | 
			
		||||
 | 
			
		||||
    // Sort and save.
 | 
			
		||||
    ksort($translations);
 | 
			
		||||
    file_put_contents(ASSETSPATH.$lang.'.json', str_replace('\/', '/', json_encode($translations, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)));
 | 
			
		||||
    save_json(ASSETSPATH.$lang.'.json', $translations);
 | 
			
		||||
 | 
			
		||||
    $success = count($translations);
 | 
			
		||||
    $percentage = floor($success/$total * 100);
 | 
			
		||||
@ -365,7 +374,7 @@ function save_key($key, $value, $filePath) {
 | 
			
		||||
    if (!isset($file[$key]) || $file[$key] != $value) {
 | 
			
		||||
        $file[$key] = $value;
 | 
			
		||||
        ksort($file);
 | 
			
		||||
        file_put_contents($filePath, str_replace('\/', '/', json_encode($file, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)));
 | 
			
		||||
        save_json($filePath, $file);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -102,6 +102,15 @@ function get_language {
 | 
			
		||||
    pushd $LANGPACKSFOLDER > /dev/null
 | 
			
		||||
 | 
			
		||||
    curl -s $MOODLEORG_URL/$lastversion/$lang.zip --output $lang.zip > /dev/null
 | 
			
		||||
    size=$(du -k "$lang.zip" | cut -f 1)
 | 
			
		||||
    if [ ! -n $lang.zip ] || [ $size -le 60 ]; then
 | 
			
		||||
        echo "Wrong language name or corrupt file for $lang"
 | 
			
		||||
        rm $lang.zip
 | 
			
		||||
 | 
			
		||||
        popd > /dev/null
 | 
			
		||||
        return
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    rm -R $lang > /dev/null 2>&1> /dev/null
 | 
			
		||||
    unzip -o -u $lang.zip > /dev/null
 | 
			
		||||
 | 
			
		||||
@ -114,6 +123,11 @@ function get_language {
 | 
			
		||||
 | 
			
		||||
# Entry function to get all language files.
 | 
			
		||||
function get_languages {
 | 
			
		||||
    suffix=$1
 | 
			
		||||
    if [ -z $suffix ]; then
 | 
			
		||||
        suffix=''
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    get_last_version
 | 
			
		||||
 | 
			
		||||
    if [ -d $LANGPACKSFOLDER ]; then
 | 
			
		||||
@ -131,6 +145,7 @@ function get_languages {
 | 
			
		||||
 | 
			
		||||
    if [ $AWS_SERVICE -eq 1 ]; then
 | 
			
		||||
        get_all_languages_aws
 | 
			
		||||
        suffix=''
 | 
			
		||||
    else
 | 
			
		||||
        echo "Fallback language list will only get current installation languages"
 | 
			
		||||
        get_installed_languages
 | 
			
		||||
@ -138,5 +153,9 @@ function get_languages {
 | 
			
		||||
 | 
			
		||||
    for lang in $langs; do
 | 
			
		||||
        get_language "$lang"
 | 
			
		||||
 | 
			
		||||
        if [ $suffix != '' ]; then
 | 
			
		||||
            get_language "$lang$suffix"
 | 
			
		||||
        fi
 | 
			
		||||
    done
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -40,12 +40,18 @@
 | 
			
		||||
  "addon.block_learningplans.pluginname": "block_lp",
 | 
			
		||||
  "addon.block_myoverview.all": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.allincludinghidden": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.browseallcourses": "local_moodlemobileapp",
 | 
			
		||||
  "addon.block_myoverview.card": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.favourites": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.future": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.hiddencourses": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.inprogress": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.lastaccessed": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.nocourses": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.list": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.nocoursesenrolled": "local_moodlemobileapp",
 | 
			
		||||
  "addon.block_myoverview.nocoursesenrolleddescription": "local_moodlemobileapp",
 | 
			
		||||
  "addon.block_myoverview.noresult": "local_moodlemobileapp",
 | 
			
		||||
  "addon.block_myoverview.noresultdescription": "local_moodlemobileapp",
 | 
			
		||||
  "addon.block_myoverview.past": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.pluginname": "block_myoverview",
 | 
			
		||||
  "addon.block_myoverview.shortname": "block_myoverview",
 | 
			
		||||
@ -73,6 +79,7 @@
 | 
			
		||||
  "addon.block_timeline.noevents": "block_timeline",
 | 
			
		||||
  "addon.block_timeline.overdue": "block_timeline",
 | 
			
		||||
  "addon.block_timeline.pluginname": "block_timeline",
 | 
			
		||||
  "addon.block_timeline.searchevents": "block_timeline",
 | 
			
		||||
  "addon.block_timeline.sortbycourses": "block_timeline",
 | 
			
		||||
  "addon.block_timeline.sortbydates": "block_timeline",
 | 
			
		||||
  "addon.blog.blog": "blog",
 | 
			
		||||
@ -140,6 +147,7 @@
 | 
			
		||||
  "addon.calendar.sunday": "calendar",
 | 
			
		||||
  "addon.calendar.thu": "calendar",
 | 
			
		||||
  "addon.calendar.thursday": "calendar",
 | 
			
		||||
  "addon.calendar.timebefore": "local_moodlemobileapp",
 | 
			
		||||
  "addon.calendar.today": "calendar",
 | 
			
		||||
  "addon.calendar.tomorrow": "calendar",
 | 
			
		||||
  "addon.calendar.tue": "calendar",
 | 
			
		||||
@ -153,6 +161,7 @@
 | 
			
		||||
  "addon.calendar.typeopen": "calendar",
 | 
			
		||||
  "addon.calendar.typesite": "calendar",
 | 
			
		||||
  "addon.calendar.typeuser": "calendar",
 | 
			
		||||
  "addon.calendar.units": "qtype_numerical",
 | 
			
		||||
  "addon.calendar.upcomingevents": "calendar",
 | 
			
		||||
  "addon.calendar.userevents": "calendar",
 | 
			
		||||
  "addon.calendar.wed": "calendar",
 | 
			
		||||
@ -229,6 +238,7 @@
 | 
			
		||||
  "addon.coursecompletion.status": "moodle",
 | 
			
		||||
  "addon.coursecompletion.viewcoursereport": "completion",
 | 
			
		||||
  "addon.messageoutput_airnotifier.processorsettingsdesc": "local_moodlemobileapp",
 | 
			
		||||
  "addon.messageoutput_airnotifier.pushdisabledwarning": "local_moodlemobileapp",
 | 
			
		||||
  "addon.messages.acceptandaddcontact": "message",
 | 
			
		||||
  "addon.messages.addcontact": "message",
 | 
			
		||||
  "addon.messages.addcontactconfirm": "message",
 | 
			
		||||
@ -325,14 +335,18 @@
 | 
			
		||||
  "addon.mod_assign.allowsubmissionsfromdatesummary": "assign",
 | 
			
		||||
  "addon.mod_assign.applytoteam": "assign",
 | 
			
		||||
  "addon.mod_assign.assignmentisdue": "assign",
 | 
			
		||||
  "addon.mod_assign.assigntimeleft": "assign",
 | 
			
		||||
  "addon.mod_assign.attemptnumber": "assign",
 | 
			
		||||
  "addon.mod_assign.attemptreopenmethod": "assign",
 | 
			
		||||
  "addon.mod_assign.attemptreopenmethod_manual": "assign",
 | 
			
		||||
  "addon.mod_assign.attemptreopenmethod_untilpass": "assign",
 | 
			
		||||
  "addon.mod_assign.attemptsettings": "assign",
 | 
			
		||||
  "addon.mod_assign.beginassignment": "assign",
 | 
			
		||||
  "addon.mod_assign.caneditsubmission": "assign",
 | 
			
		||||
  "addon.mod_assign.cannoteditduetostatementsubmission": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_assign.cannotgradefromapp": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_assign.cannotsubmitduetostatementsubmission": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_assign.confirmstart": "assign",
 | 
			
		||||
  "addon.mod_assign.confirmsubmission": "assign",
 | 
			
		||||
  "addon.mod_assign.currentattempt": "assign",
 | 
			
		||||
  "addon.mod_assign.currentattemptof": "assign",
 | 
			
		||||
@ -340,7 +354,7 @@
 | 
			
		||||
  "addon.mod_assign.cutoffdate": "assign",
 | 
			
		||||
  "addon.mod_assign.defaultteam": "assign",
 | 
			
		||||
  "addon.mod_assign.duedate": "assign",
 | 
			
		||||
  "addon.mod_assign.duedateno": "assign",
 | 
			
		||||
  "addon.mod_assign.duedateno": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_assign.duedatereached": "assign",
 | 
			
		||||
  "addon.mod_assign.editingstatus": "assign",
 | 
			
		||||
  "addon.mod_assign.editsubmission": "assign",
 | 
			
		||||
@ -410,7 +424,10 @@
 | 
			
		||||
  "addon.mod_assign.submitassignment_help": "assign",
 | 
			
		||||
  "addon.mod_assign.submittedearly": "assign",
 | 
			
		||||
  "addon.mod_assign.submittedlate": "assign",
 | 
			
		||||
  "addon.mod_assign.submittedovertime": "assign",
 | 
			
		||||
  "addon.mod_assign.submittedundertime": "assign",
 | 
			
		||||
  "addon.mod_assign.syncblockedusercomponent": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_assign.timelimit": "assign",
 | 
			
		||||
  "addon.mod_assign.timemodified": "assign",
 | 
			
		||||
  "addon.mod_assign.timeremaining": "assign",
 | 
			
		||||
  "addon.mod_assign.ungroupedusers": "assign",
 | 
			
		||||
@ -428,6 +445,23 @@
 | 
			
		||||
  "addon.mod_assign_submission_file.pluginname": "assignsubmission_file",
 | 
			
		||||
  "addon.mod_assign_submission_onlinetext.pluginname": "assignsubmission_onlinetext",
 | 
			
		||||
  "addon.mod_assign_submission_onlinetext.wordlimitexceeded": "assignsubmission_onlinetext",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.end_session_confirm": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.end_session_confirm_title": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.mod_form_field_closingtime": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.mod_form_field_openingtime": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.userlimitreached": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_conference_action_end": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_conference_action_join": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_error_unable_join_student": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_groups_selection_warning": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_message_conference_in_progress": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_message_conference_room_ready": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_message_moderator": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_message_moderators": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_message_session_started_at": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_message_viewer": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_message_viewers": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_bigbluebuttonbn.view_nojoin": "bigbluebuttonbn",
 | 
			
		||||
  "addon.mod_book.errorchapter": "book",
 | 
			
		||||
  "addon.mod_book.modulenameplural": "book",
 | 
			
		||||
  "addon.mod_book.navnexttitle": "book",
 | 
			
		||||
@ -538,6 +572,7 @@
 | 
			
		||||
  "addon.mod_feedback.analysis": "feedback",
 | 
			
		||||
  "addon.mod_feedback.anonymous": "feedback",
 | 
			
		||||
  "addon.mod_feedback.anonymous_entries": "feedback",
 | 
			
		||||
  "addon.mod_feedback.anonymous_user": "feedback",
 | 
			
		||||
  "addon.mod_feedback.average": "feedback",
 | 
			
		||||
  "addon.mod_feedback.captchaofflinewarning": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_feedback.complete_the_form": "feedback",
 | 
			
		||||
@ -553,7 +588,6 @@
 | 
			
		||||
  "addon.mod_feedback.minimal": "feedback",
 | 
			
		||||
  "addon.mod_feedback.mode": "feedback",
 | 
			
		||||
  "addon.mod_feedback.modulenameplural": "feedback",
 | 
			
		||||
  "addon.mod_feedback.next_page": "feedback",
 | 
			
		||||
  "addon.mod_feedback.non_anonymous": "feedback",
 | 
			
		||||
  "addon.mod_feedback.non_anonymous_entries": "feedback",
 | 
			
		||||
  "addon.mod_feedback.non_respondents_students": "feedback",
 | 
			
		||||
@ -563,7 +597,6 @@
 | 
			
		||||
  "addon.mod_feedback.overview": "feedback",
 | 
			
		||||
  "addon.mod_feedback.page_after_submit": "feedback",
 | 
			
		||||
  "addon.mod_feedback.preview": "moodle",
 | 
			
		||||
  "addon.mod_feedback.previous_page": "feedback",
 | 
			
		||||
  "addon.mod_feedback.questions": "feedback",
 | 
			
		||||
  "addon.mod_feedback.questionscountdescription": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_feedback.response_nr": "feedback",
 | 
			
		||||
@ -625,8 +658,8 @@
 | 
			
		||||
  "addon.mod_forum.posttoforum": "forum",
 | 
			
		||||
  "addon.mod_forum.posttomygroups": "forum",
 | 
			
		||||
  "addon.mod_forum.privatereply": "forum",
 | 
			
		||||
  "addon.mod_forum.qandanotify": "forum",
 | 
			
		||||
  "addon.mod_forum.re": "forum",
 | 
			
		||||
  "addon.mod_forum.refreshdiscussions": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_forum.refreshposts": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_forum.removefromfavourites": "forum",
 | 
			
		||||
  "addon.mod_forum.reply": "forum",
 | 
			
		||||
@ -681,7 +714,9 @@
 | 
			
		||||
  "addon.mod_h5pactivity.attempt_success_fail": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.attempt_success_pass": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.attempt_success_unknown": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.attempts": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.attempts_none": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.attempts_report": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.completion": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.downloadh5pfile": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_h5pactivity.duration": "h5pactivity",
 | 
			
		||||
@ -692,12 +727,13 @@
 | 
			
		||||
  "addon.mod_h5pactivity.modulenameplural": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.myattempts": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.no_compatible_track": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.noparticipants": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.offlinedisabledwarning": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_h5pactivity.outcome": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.previewmode": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.result_fill-in": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.result_other": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.review_my_attempts": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.review_user_attempts": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.score": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.score_out_of": "h5pactivity",
 | 
			
		||||
  "addon.mod_h5pactivity.startdate": "h5pactivity",
 | 
			
		||||
@ -855,8 +891,6 @@
 | 
			
		||||
  "addon.mod_quiz.requirepasswordmessage": "quizaccess_password",
 | 
			
		||||
  "addon.mod_quiz.returnattempt": "quiz",
 | 
			
		||||
  "addon.mod_quiz.review": "quiz",
 | 
			
		||||
  "addon.mod_quiz.reviewofattempt": "quiz",
 | 
			
		||||
  "addon.mod_quiz.reviewofpreview": "quiz",
 | 
			
		||||
  "addon.mod_quiz.showall": "quiz",
 | 
			
		||||
  "addon.mod_quiz.showeachpage": "quiz",
 | 
			
		||||
  "addon.mod_quiz.startattempt": "quiz",
 | 
			
		||||
@ -883,6 +917,8 @@
 | 
			
		||||
  "addon.mod_resource.modifieddate": "resource",
 | 
			
		||||
  "addon.mod_resource.modulenameplural": "resource",
 | 
			
		||||
  "addon.mod_resource.openthefile": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_resource.resourcestatusoutdated": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_resource.resourcestatusoutdatedconfirm": "local_moodlemobileapp",
 | 
			
		||||
  "addon.mod_resource.uploadeddate": "resource",
 | 
			
		||||
  "addon.mod_scorm.asset": "scorm",
 | 
			
		||||
  "addon.mod_scorm.assetlaunched": "scorm",
 | 
			
		||||
@ -1069,12 +1105,30 @@
 | 
			
		||||
  "addon.privatefiles.sitefiles": "moodle",
 | 
			
		||||
  "addon.qtype_essay.maxwordlimitboundary": "qtype_essay",
 | 
			
		||||
  "addon.qtype_essay.minwordlimitboundary": "qtype_essay",
 | 
			
		||||
  "addon.storagemanager.deletecourse": "local_moodlemobileapp",
 | 
			
		||||
  "addon.report_insights.actionsaved": "report_insights",
 | 
			
		||||
  "addon.report_insights.fixedack": "analytics",
 | 
			
		||||
  "addon.report_insights.incorrectlyflagged": "analytics",
 | 
			
		||||
  "addon.report_insights.notapplicable": "analytics",
 | 
			
		||||
  "addon.report_insights.notuseful": "analytics",
 | 
			
		||||
  "addon.report_insights.useful": "analytics",
 | 
			
		||||
  "addon.storagemanager.alldata": "tool_wp",
 | 
			
		||||
  "addon.storagemanager.confirmdeleteallsitedata": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.confirmdeletecourses": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.confirmdeletedatafrom": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.coursedownloads": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.courseinfo": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.deleteall": "moodle",
 | 
			
		||||
  "addon.storagemanager.deleteallsitedata": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.deleteallsitedatainfo": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.deletecourses": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.deletedata": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.deletedatafrom": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.info": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.managestorage": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.storageused": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.downloadedcourses": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.downloads": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.errordeletedownloadeddata": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.managedownloads": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.totaldownloads": "local_moodlemobileapp",
 | 
			
		||||
  "addon.storagemanager.totalspaceusage": "local_moodlemobileapp",
 | 
			
		||||
  "assets.countries.AD": "countries",
 | 
			
		||||
  "assets.countries.AE": "countries",
 | 
			
		||||
  "assets.countries.AF": "countries",
 | 
			
		||||
@ -1324,9 +1378,23 @@
 | 
			
		||||
  "assets.countries.ZA": "countries",
 | 
			
		||||
  "assets.countries.ZM": "countries",
 | 
			
		||||
  "assets.countries.ZW": "countries",
 | 
			
		||||
  "assets.mimetypes.application/dash_xml": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/epub_zip": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/json": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/msword": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/pdf": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.audio": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.document": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.drawing": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.file": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.folder": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.form": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.fusiontable": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.presentation": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.script": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.site": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.spreadsheet": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.google-apps.video": "local_moodlemobileapp",
 | 
			
		||||
  "assets.mimetypes.application/vnd.moodle.backup": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/vnd.ms-excel": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/vnd.ms-excel.sheet.macroEnabled.12": "mimetypes",
 | 
			
		||||
@ -1345,6 +1413,7 @@
 | 
			
		||||
  "assets.mimetypes.application/x-iwork-numbers-sffnumbers": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/x-iwork-pages-sffpages": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/x-javascript": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/x-mpegURL": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/x-mspublisher": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/x-shockwave-flash": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.application/xhtml_xml": "mimetypes",
 | 
			
		||||
@ -1359,6 +1428,8 @@
 | 
			
		||||
  "assets.mimetypes.group:html_track": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.group:html_video": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.group:image": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.group:media_source": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.group:optimised_image": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.group:presentation": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.group:sourcecode": "mimetypes",
 | 
			
		||||
  "assets.mimetypes.group:spreadsheet": "mimetypes",
 | 
			
		||||
@ -1380,6 +1451,7 @@
 | 
			
		||||
  "core.add": "moodle",
 | 
			
		||||
  "core.agelocationverification": "moodle",
 | 
			
		||||
  "core.ago": "message",
 | 
			
		||||
  "core.ajaxendpointnotfound": "local_moodlemobileapp",
 | 
			
		||||
  "core.all": "moodle",
 | 
			
		||||
  "core.allgroups": "moodle",
 | 
			
		||||
  "core.allparticipants": "moodle",
 | 
			
		||||
@ -1388,12 +1460,18 @@
 | 
			
		||||
  "core.areyousure": "moodle",
 | 
			
		||||
  "core.back": "moodle",
 | 
			
		||||
  "core.block.blocks": "moodle",
 | 
			
		||||
  "core.block.noblocks": "error",
 | 
			
		||||
  "core.block.opendrawerblocks": "moodle",
 | 
			
		||||
  "core.block.tour_navigation_dashboard_content": "tool_usertours",
 | 
			
		||||
  "core.block.tour_navigation_dashboard_title": "tool_usertours",
 | 
			
		||||
  "core.browser": "local_moodlemobileapp",
 | 
			
		||||
  "core.calculating": "local_moodlemobileapp",
 | 
			
		||||
  "core.cancel": "moodle",
 | 
			
		||||
  "core.cannotconnect": "local_moodlemobileapp",
 | 
			
		||||
  "core.cannotconnecttrouble": "local_moodlemobileapp",
 | 
			
		||||
  "core.cannotconnectverify": "local_moodlemobileapp",
 | 
			
		||||
  "core.cannotdownloadfiles": "local_moodlemobileapp",
 | 
			
		||||
  "core.cannotlogoutpageblocks": "local_moodlemobileapp",
 | 
			
		||||
  "core.cannotopeninapp": "local_moodlemobileapp",
 | 
			
		||||
  "core.cannotopeninappdownload": "local_moodlemobileapp",
 | 
			
		||||
  "core.captureaudio": "local_moodlemobileapp",
 | 
			
		||||
@ -1401,6 +1479,7 @@
 | 
			
		||||
  "core.captureimage": "local_moodlemobileapp",
 | 
			
		||||
  "core.capturevideo": "local_moodlemobileapp",
 | 
			
		||||
  "core.category": "moodle",
 | 
			
		||||
  "core.certificaterror": "local_moodlemobileapp",
 | 
			
		||||
  "core.choose": "moodle",
 | 
			
		||||
  "core.choosedots": "moodle",
 | 
			
		||||
  "core.clearsearch": "local_moodlemobileapp",
 | 
			
		||||
@ -1433,8 +1512,6 @@
 | 
			
		||||
  "core.completion-alt-manual-y-override": "completion",
 | 
			
		||||
  "core.confirmcanceledit": "local_moodlemobileapp",
 | 
			
		||||
  "core.confirmdeletefile": "repository",
 | 
			
		||||
  "core.confirmgotabroot": "local_moodlemobileapp",
 | 
			
		||||
  "core.confirmgotabrootdefault": "local_moodlemobileapp",
 | 
			
		||||
  "core.confirmleaveunknownchanges": "local_moodlemobileapp",
 | 
			
		||||
  "core.confirmloss": "local_moodlemobileapp",
 | 
			
		||||
  "core.confirmopeninbrowser": "local_moodlemobileapp",
 | 
			
		||||
@ -1453,10 +1530,8 @@
 | 
			
		||||
  "core.course": "moodle",
 | 
			
		||||
  "core.course.activitydisabled": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.activitynotyetviewableremoteaddon": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.activitynotyetviewablesiteupgradeneeded": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.allsections": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.aria:sectionprogress": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.askadmintosupport": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.availablespace": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.cannotdeletewhiledownloading": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.completion_automatic:done": "course",
 | 
			
		||||
@ -1471,34 +1546,47 @@
 | 
			
		||||
  "core.course.completion_setby:manual:done": "course",
 | 
			
		||||
  "core.course.completion_setby:manual:markdone": "course",
 | 
			
		||||
  "core.course.completionrequirements": "course",
 | 
			
		||||
  "core.course.confirmdeletemodulefiles": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.confirmdeletestoreddata": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.confirmdownload": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.confirmdownloadunknownsize": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.confirmdownloadzerosize": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.confirmlimiteddownload": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.confirmpartialdownloadsize": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.contents": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.couldnotloadsectioncontent": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.couldnotloadsections": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.courseindex": "courseformat",
 | 
			
		||||
  "core.course.coursesummary": "moodle",
 | 
			
		||||
  "core.course.done": "completion",
 | 
			
		||||
  "core.course.downloadcourse": "tool_mobile",
 | 
			
		||||
  "core.course.downloadcoursesprogressdescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.downloadsectionprogressdescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.enddate": "moodle",
 | 
			
		||||
  "core.course.errordownloadingcourse": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.errordownloadingsection": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.errorgetmodule": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.failed": "completion",
 | 
			
		||||
  "core.course.hiddenfromstudents": "moodle",
 | 
			
		||||
  "core.course.hiddenoncoursepage": "moodle",
 | 
			
		||||
  "core.course.highlighted": "moodle",
 | 
			
		||||
  "core.course.insufficientavailablequota": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.insufficientavailablespace": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.lastaccessedactivity": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.manualcompletionnotsynced": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.modulenotfound": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.nextactivity": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.nextactivitynotfound": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.nocontentavailable": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.overriddennotice": "grades",
 | 
			
		||||
  "core.course.previousactivity": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.previousactivitynotfound": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.refreshcourse": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.section": "moodle",
 | 
			
		||||
  "core.course.sections": "moodle",
 | 
			
		||||
  "core.course.startdate": "moodle",
 | 
			
		||||
  "core.course.thisweek": "format_weeks/currentsection",
 | 
			
		||||
  "core.course.todo": "completion",
 | 
			
		||||
  "core.course.tour_navigation_course_index_student_content": "tool_usertours",
 | 
			
		||||
  "core.course.tour_navigation_course_index_student_title": "tool_usertours",
 | 
			
		||||
  "core.course.useactivityonbrowser": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.viewcourse": "block_timeline",
 | 
			
		||||
  "core.course.warningmanualcompletionmodified": "local_moodlemobileapp",
 | 
			
		||||
  "core.course.warningofflinemanualcompletiondeleted": "local_moodlemobileapp",
 | 
			
		||||
  "core.coursedetails": "moodle",
 | 
			
		||||
@ -1510,8 +1598,10 @@
 | 
			
		||||
  "core.courses.aria:courseprogress": "block_myoverview",
 | 
			
		||||
  "core.courses.aria:favourite": "course",
 | 
			
		||||
  "core.courses.availablecourses": "moodle",
 | 
			
		||||
  "core.courses.browserenrolinstructions": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.cannotretrievemorecategories": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.categories": "moodle",
 | 
			
		||||
  "core.courses.completeenrolmentbrowser": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.confirmselfenrol": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.courses": "moodle",
 | 
			
		||||
  "core.courses.downloadcourses": "local_moodlemobileapp",
 | 
			
		||||
@ -1533,22 +1623,25 @@
 | 
			
		||||
  "core.courses.nosearchresults": "wiki",
 | 
			
		||||
  "core.courses.notenroled": "completion",
 | 
			
		||||
  "core.courses.notenrollable": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.otherenrolments": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.password": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.paymentrequired": "moodle",
 | 
			
		||||
  "core.courses.paypalaccepted": "enrol_paypal",
 | 
			
		||||
  "core.courses.refreshcourses": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.reload": "moodle",
 | 
			
		||||
  "core.courses.removefromfavourites": "block_myoverview",
 | 
			
		||||
  "core.courses.search": "moodle",
 | 
			
		||||
  "core.courses.searchcourses": "moodle",
 | 
			
		||||
  "core.courses.searchcoursesadvice": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.selfenrolment": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.sendpaymentbutton": "enrol_paypal",
 | 
			
		||||
  "core.courses.show": "block_myoverview",
 | 
			
		||||
  "core.courses.showonlyenrolled": "local_moodlemobileapp",
 | 
			
		||||
  "core.courses.therearecourses": "moodle",
 | 
			
		||||
  "core.courses.totalcoursesearchresults": "local_moodlemobileapp",
 | 
			
		||||
  "core.currentdevice": "local_moodlemobileapp",
 | 
			
		||||
  "core.custom": "form",
 | 
			
		||||
  "core.datastoredoffline": "local_moodlemobileapp",
 | 
			
		||||
  "core.date": "moodle",
 | 
			
		||||
  "core.datecreated": "repository",
 | 
			
		||||
  "core.day": "moodle",
 | 
			
		||||
  "core.days": "moodle",
 | 
			
		||||
  "core.decsep": "langconfig",
 | 
			
		||||
@ -1567,10 +1660,12 @@
 | 
			
		||||
  "core.dftimedate": "local_moodlemobileapp",
 | 
			
		||||
  "core.digitalminor": "moodle",
 | 
			
		||||
  "core.digitalminor_desc": "moodle",
 | 
			
		||||
  "core.disablefullscreen": "h5p",
 | 
			
		||||
  "core.discard": "local_moodlemobileapp",
 | 
			
		||||
  "core.dismiss": "local_moodlemobileapp",
 | 
			
		||||
  "core.displayoptions": "atto_media",
 | 
			
		||||
  "core.done": "survey",
 | 
			
		||||
  "core.dontshowagain": "local_moodlemobileapp",
 | 
			
		||||
  "core.download": "moodle",
 | 
			
		||||
  "core.downloaded": "local_moodlemobileapp",
 | 
			
		||||
  "core.downloadfile": "moodle",
 | 
			
		||||
@ -1592,6 +1687,7 @@
 | 
			
		||||
  "core.editor.underline": "atto_underline/pluginname",
 | 
			
		||||
  "core.editor.unorderedlist": "atto_unorderedlist/pluginname",
 | 
			
		||||
  "core.emptysplit": "local_moodlemobileapp",
 | 
			
		||||
  "core.endonesteptour": "tool_usertours",
 | 
			
		||||
  "core.error": "moodle",
 | 
			
		||||
  "core.errorchangecompletion": "local_moodlemobileapp",
 | 
			
		||||
  "core.errordeletefile": "local_moodlemobileapp",
 | 
			
		||||
@ -1650,6 +1746,8 @@
 | 
			
		||||
  "core.forcepasswordchangenotice": "moodle",
 | 
			
		||||
  "core.fulllistofcourses": "moodle",
 | 
			
		||||
  "core.fullnameandsitename": "local_moodlemobileapp",
 | 
			
		||||
  "core.fullscreen": "h5p",
 | 
			
		||||
  "core.goto": "local_moodlemobileapp",
 | 
			
		||||
  "core.grades.aggregatemean": "grades",
 | 
			
		||||
  "core.grades.aggregatesum": "grades",
 | 
			
		||||
  "core.grades.average": "grades",
 | 
			
		||||
@ -1657,8 +1755,10 @@
 | 
			
		||||
  "core.grades.calculatedgrade": "grades",
 | 
			
		||||
  "core.grades.category": "grades",
 | 
			
		||||
  "core.grades.contributiontocoursetotal": "grades",
 | 
			
		||||
  "core.grades.fail": "grades",
 | 
			
		||||
  "core.grades.feedback": "grades",
 | 
			
		||||
  "core.grades.grade": "grades",
 | 
			
		||||
  "core.grades.gradebook": "grades",
 | 
			
		||||
  "core.grades.gradeitem": "grades",
 | 
			
		||||
  "core.grades.gradepass": "grades",
 | 
			
		||||
  "core.grades.grades": "grades",
 | 
			
		||||
@ -1667,6 +1767,7 @@
 | 
			
		||||
  "core.grades.nogradesreturned": "grades",
 | 
			
		||||
  "core.grades.nooutcome": "grades",
 | 
			
		||||
  "core.grades.outcome": "grades",
 | 
			
		||||
  "core.grades.pass": "grades",
 | 
			
		||||
  "core.grades.percentage": "grades",
 | 
			
		||||
  "core.grades.range": "grades",
 | 
			
		||||
  "core.grades.rank": "grades",
 | 
			
		||||
@ -1734,6 +1835,7 @@
 | 
			
		||||
  "core.h5p.licensee": "h5p",
 | 
			
		||||
  "core.h5p.licenseextras": "h5p",
 | 
			
		||||
  "core.h5p.licenseversion": "h5p",
 | 
			
		||||
  "core.h5p.missingdependency": "h5p",
 | 
			
		||||
  "core.h5p.nocopyright": "h5p",
 | 
			
		||||
  "core.h5p.offlineDialogBody": "h5p",
 | 
			
		||||
  "core.h5p.offlineDialogHeader": "h5p",
 | 
			
		||||
@ -1789,6 +1891,8 @@
 | 
			
		||||
  "core.loading": "moodle",
 | 
			
		||||
  "core.loadmore": "local_moodlemobileapp",
 | 
			
		||||
  "core.location": "moodle",
 | 
			
		||||
  "core.login.accounts": "admin",
 | 
			
		||||
  "core.login.add": "moodle",
 | 
			
		||||
  "core.login.auth_email": "auth_email/pluginname",
 | 
			
		||||
  "core.login.authenticating": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.cancel": "moodle",
 | 
			
		||||
@ -1846,7 +1950,6 @@
 | 
			
		||||
  "core.login.invalidurl": "scorm",
 | 
			
		||||
  "core.login.invalidvaluemax": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.invalidvaluemin": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.localmobileunexpectedresponse": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.loggedoutssodescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.login": "moodle",
 | 
			
		||||
  "core.login.loginbutton": "local_moodlemobileapp",
 | 
			
		||||
@ -1875,6 +1978,7 @@
 | 
			
		||||
  "core.login.passwordforgotteninstructions2": "moodle",
 | 
			
		||||
  "core.login.passwordrequired": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.policyaccept": "moodle",
 | 
			
		||||
  "core.login.policyacceptmandatory": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.policyagree": "moodle",
 | 
			
		||||
  "core.login.policyagreement": "moodle",
 | 
			
		||||
  "core.login.policyagreementclick": "moodle",
 | 
			
		||||
@ -1884,8 +1988,8 @@
 | 
			
		||||
  "core.login.recaptchaexpired": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.recaptchaincorrect": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.reconnect": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.reconnectdescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.reconnectssodescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.removeaccount": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.resendemail": "moodle",
 | 
			
		||||
  "core.login.searchby": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.security_question": "auth",
 | 
			
		||||
@ -1898,12 +2002,14 @@
 | 
			
		||||
  "core.login.sitebadgedescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.sitehasredirect": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.siteinmaintenance": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.sitenotallowed": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.sitepolicynotagreederror": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.siteurl": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.siteurlrequired": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.startsignup": "moodle",
 | 
			
		||||
  "core.login.stillcantconnect": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.supplyinfo": "moodle",
 | 
			
		||||
  "core.login.toggleremove": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.username": "moodle",
 | 
			
		||||
  "core.login.usernameoremail": "moodle",
 | 
			
		||||
  "core.login.usernamerequired": "local_moodlemobileapp",
 | 
			
		||||
@ -1913,23 +2019,23 @@
 | 
			
		||||
  "core.login.youcanstillconnectwithcredentials": "local_moodlemobileapp",
 | 
			
		||||
  "core.login.yourenteredsite": "local_moodlemobileapp",
 | 
			
		||||
  "core.lostconnection": "local_moodlemobileapp",
 | 
			
		||||
  "core.mainmenu.changesite": "local_moodlemobileapp",
 | 
			
		||||
  "core.mainmenu.help": "moodle",
 | 
			
		||||
  "core.mainmenu.home": "moodle",
 | 
			
		||||
  "core.mainmenu.logout": "moodle",
 | 
			
		||||
  "core.mainmenu.website": "local_moodlemobileapp",
 | 
			
		||||
  "core.mainmenu.switchaccount": "local_moodlemobileapp",
 | 
			
		||||
  "core.mainmenu.usermenutourdescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.mainmenu.usermenutourtitle": "local_moodlemobileapp",
 | 
			
		||||
  "core.maxfilesize": "moodle",
 | 
			
		||||
  "core.maxsizeandattachments": "moodle",
 | 
			
		||||
  "core.min": "moodle",
 | 
			
		||||
  "core.mins": "moodle",
 | 
			
		||||
  "core.minute": "moodle",
 | 
			
		||||
  "core.minutes": "moodle",
 | 
			
		||||
  "core.misc": "admin",
 | 
			
		||||
  "core.mod_assign": "assign/pluginname",
 | 
			
		||||
  "core.mod_assignment": "assignment/pluginname",
 | 
			
		||||
  "core.mod_book": "book/pluginname",
 | 
			
		||||
  "core.mod_chat": "chat/pluginname",
 | 
			
		||||
  "core.mod_choice": "choice/pluginname",
 | 
			
		||||
  "core.mod_data": "data/pluginname",
 | 
			
		||||
  "core.mod_database": "data/pluginname",
 | 
			
		||||
  "core.mod_external-tool": "lti/pluginname",
 | 
			
		||||
  "core.mod_feedback": "feedback/pluginname",
 | 
			
		||||
  "core.mod_file": "moodle/file",
 | 
			
		||||
@ -1937,7 +2043,6 @@
 | 
			
		||||
  "core.mod_forum": "forum/pluginname",
 | 
			
		||||
  "core.mod_glossary": "glossary/pluginname",
 | 
			
		||||
  "core.mod_h5pactivity": "h5pactivity/pluginname",
 | 
			
		||||
  "core.mod_ims": "imscp/pluginname",
 | 
			
		||||
  "core.mod_imscp": "imscp/pluginname",
 | 
			
		||||
  "core.mod_label": "label/pluginname",
 | 
			
		||||
  "core.mod_lesson": "lesson/pluginname",
 | 
			
		||||
@ -1951,7 +2056,7 @@
 | 
			
		||||
  "core.mod_wiki": "wiki/pluginname",
 | 
			
		||||
  "core.mod_workshop": "workshop/pluginname",
 | 
			
		||||
  "core.moduleintro": "moodle",
 | 
			
		||||
  "core.more": "moodle",
 | 
			
		||||
  "core.more": "moodle/moremenu",
 | 
			
		||||
  "core.mygroups": "group",
 | 
			
		||||
  "core.name": "moodle",
 | 
			
		||||
  "core.needhelp": "local_moodlemobileapp",
 | 
			
		||||
@ -1991,7 +2096,6 @@
 | 
			
		||||
  "core.othergroups": "group",
 | 
			
		||||
  "core.pagea": "moodle",
 | 
			
		||||
  "core.parentlanguage": "langconfig",
 | 
			
		||||
  "core.paymentinstant": "moodle",
 | 
			
		||||
  "core.percentagenumber": "local_moodlemobileapp",
 | 
			
		||||
  "core.phone": "moodle",
 | 
			
		||||
  "core.pictureof": "moodle",
 | 
			
		||||
@ -2039,6 +2143,7 @@
 | 
			
		||||
  "core.resources": "moodle",
 | 
			
		||||
  "core.restore": "moodle",
 | 
			
		||||
  "core.restricted": "moodle",
 | 
			
		||||
  "core.resume": "local_moodlemobileapp",
 | 
			
		||||
  "core.retry": "local_moodlemobileapp",
 | 
			
		||||
  "core.save": "moodle",
 | 
			
		||||
  "core.savechanges": "assign",
 | 
			
		||||
@ -2058,11 +2163,14 @@
 | 
			
		||||
  "core.sending": "chat",
 | 
			
		||||
  "core.serverconnection": "error",
 | 
			
		||||
  "core.settings.about": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.accessstatement": "access",
 | 
			
		||||
  "core.settings.appsettings": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.appversion": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.cannotsyncloggedout": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.cannotsyncoffline": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.cannotsyncwithoutwifi": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.changelanguage": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.changelanguagealert": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.colorscheme": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.colorscheme-dark": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.colorscheme-light": "local_moodlemobileapp",
 | 
			
		||||
@ -2078,12 +2186,12 @@
 | 
			
		||||
  "core.settings.currentlanguage": "moodle",
 | 
			
		||||
  "core.settings.debugdisplay": "admin",
 | 
			
		||||
  "core.settings.debugdisplaydescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.deletesitefiles": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.deletesitefilestitle": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.developeroptions": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.deviceinfo": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.deviceos": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.disableall": "message",
 | 
			
		||||
  "core.settings.disabled": "lesson",
 | 
			
		||||
  "core.settings.disallowed": "message",
 | 
			
		||||
  "core.settings.displayformat": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.enabledownloadsection": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.enablefirebaseanalytics": "local_moodlemobileapp",
 | 
			
		||||
@ -2092,12 +2200,12 @@
 | 
			
		||||
  "core.settings.enablerichtexteditordescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.enablesyncwifi": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.entriesincache": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.errordeletesitefiles": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.errorsyncsite": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.estimatedfreespace": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.filesystemroot": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.fontsize": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.fontsizecharacter": "block_accessibility/char",
 | 
			
		||||
  "core.settings.forced": "message",
 | 
			
		||||
  "core.settings.forcedsetting": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.general": "moodle",
 | 
			
		||||
  "core.settings.helpusimprove": "local_moodlemobileapp",
 | 
			
		||||
@ -2107,7 +2215,6 @@
 | 
			
		||||
  "core.settings.license": "moodle",
 | 
			
		||||
  "core.settings.localnotifavailable": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.locationhref": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.locked": "admin",
 | 
			
		||||
  "core.settings.loggedin": "message",
 | 
			
		||||
  "core.settings.loggedoff": "message",
 | 
			
		||||
  "core.settings.navigatorlanguage": "local_moodlemobileapp",
 | 
			
		||||
@ -2125,13 +2232,13 @@
 | 
			
		||||
  "core.settings.siteinfo": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.sites": "moodle",
 | 
			
		||||
  "core.settings.spaceusage": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.spaceusagehelp": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.synchronization": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.synchronizenow": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.synchronizenowhelp": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.syncsettings": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.total": "moodle",
 | 
			
		||||
  "core.settings.wificonnection": "local_moodlemobileapp",
 | 
			
		||||
  "core.settings.youradev": "local_moodlemobileapp",
 | 
			
		||||
  "core.sharedfiles.chooseaccountstorefile": "local_moodlemobileapp",
 | 
			
		||||
  "core.sharedfiles.chooseactionrepeatedfile": "local_moodlemobileapp",
 | 
			
		||||
  "core.sharedfiles.errorreceivefilenosites": "local_moodlemobileapp",
 | 
			
		||||
@ -2149,6 +2256,7 @@
 | 
			
		||||
  "core.sitehome.sitehome": "moodle",
 | 
			
		||||
  "core.sitehome.sitenews": "moodle",
 | 
			
		||||
  "core.sitemaintenance": "admin",
 | 
			
		||||
  "core.size": "moodle",
 | 
			
		||||
  "core.sizeb": "moodle",
 | 
			
		||||
  "core.sizegb": "moodle",
 | 
			
		||||
  "core.sizekb": "moodle",
 | 
			
		||||
@ -2158,7 +2266,7 @@
 | 
			
		||||
  "core.sorry": "local_moodlemobileapp",
 | 
			
		||||
  "core.sort": "moodle",
 | 
			
		||||
  "core.sortby": "moodle",
 | 
			
		||||
  "core.start": "grouptool",
 | 
			
		||||
  "core.start": "local_moodlemobileapp",
 | 
			
		||||
  "core.storingfiles": "local_moodlemobileapp",
 | 
			
		||||
  "core.strftimedate": "langconfig",
 | 
			
		||||
  "core.strftimedatefullshort": "langconfig",
 | 
			
		||||
@ -2177,6 +2285,8 @@
 | 
			
		||||
  "core.strftimetime24": "langconfig",
 | 
			
		||||
  "core.submit": "moodle",
 | 
			
		||||
  "core.success": "moodle",
 | 
			
		||||
  "core.summary": "moodle",
 | 
			
		||||
  "core.swipenavigationtourdescription": "local_moodlemobileapp",
 | 
			
		||||
  "core.tablet": "local_moodlemobileapp",
 | 
			
		||||
  "core.tag.defautltagcoll": "tag",
 | 
			
		||||
  "core.tag.errorareanotsupported": "local_moodlemobileapp",
 | 
			
		||||
@ -2203,6 +2313,7 @@
 | 
			
		||||
  "core.toggledelete": "local_moodlemobileapp",
 | 
			
		||||
  "core.tryagain": "local_moodlemobileapp",
 | 
			
		||||
  "core.twoparagraphs": "local_moodlemobileapp",
 | 
			
		||||
  "core.type": "repository",
 | 
			
		||||
  "core.uhoh": "local_moodlemobileapp",
 | 
			
		||||
  "core.unexpectederror": "local_moodlemobileapp",
 | 
			
		||||
  "core.unicodenotsupported": "local_moodlemobileapp",
 | 
			
		||||
@ -2234,26 +2345,32 @@
 | 
			
		||||
  "core.user.participants": "moodle",
 | 
			
		||||
  "core.user.phone1": "moodle",
 | 
			
		||||
  "core.user.phone2": "moodle",
 | 
			
		||||
  "core.user.profile": "moodle",
 | 
			
		||||
  "core.user.roles": "moodle",
 | 
			
		||||
  "core.user.sendemail": "local_moodlemobileapp",
 | 
			
		||||
  "core.user.student": "moodle/defaultcoursestudent",
 | 
			
		||||
  "core.user.teacher": "moodle/noneditingteacher",
 | 
			
		||||
  "core.user.useraccount": "moodle",
 | 
			
		||||
  "core.user.userwithid": "local_moodlemobileapp",
 | 
			
		||||
  "core.user.webpage": "moodle",
 | 
			
		||||
  "core.userdeleted": "moodle",
 | 
			
		||||
  "core.userdetails": "moodle",
 | 
			
		||||
  "core.usernologin": "local_moodlemobileapp",
 | 
			
		||||
  "core.usernotfullysetup": "error",
 | 
			
		||||
  "core.users": "moodle",
 | 
			
		||||
  "core.usersuspended": "tool_reportbuilder",
 | 
			
		||||
  "core.view": "moodle",
 | 
			
		||||
  "core.viewcode": "local_moodlemobileapp",
 | 
			
		||||
  "core.vieweditor": "local_moodlemobileapp",
 | 
			
		||||
  "core.viewembeddedcontent": "local_moodlemobileapp",
 | 
			
		||||
  "core.viewprofile": "moodle",
 | 
			
		||||
  "core.warningofflinedatadeleted": "local_moodlemobileapp",
 | 
			
		||||
  "core.warnopeninbrowser": "local_moodlemobileapp",
 | 
			
		||||
  "core.week": "moodle",
 | 
			
		||||
  "core.weeks": "moodle",
 | 
			
		||||
  "core.whatisyourage": "moodle",
 | 
			
		||||
  "core.wheredoyoulive": "moodle",
 | 
			
		||||
  "core.whoissiteadmin": "local_moodlemobileapp",
 | 
			
		||||
  "core.whoops": "local_moodlemobileapp",
 | 
			
		||||
  "core.whyisthishappening": "local_moodlemobileapp",
 | 
			
		||||
  "core.whyisthisrequired": "moodle",
 | 
			
		||||
  "core.wsfunctionnotavailable": "local_moodlemobileapp",
 | 
			
		||||
@ -2261,5 +2378,7 @@
 | 
			
		||||
  "core.years": "moodle",
 | 
			
		||||
  "core.yes": "moodle",
 | 
			
		||||
  "core.youreoffline": "local_moodlemobileapp",
 | 
			
		||||
  "core.youreonline": "local_moodlemobileapp"
 | 
			
		||||
  "core.youreonline": "local_moodlemobileapp",
 | 
			
		||||
  "core.zoomin": "local_moodlemobileapp",
 | 
			
		||||
  "core.zoomout": "local_moodlemobileapp"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										87
									
								
								scripts/print-performance-measures.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										87
									
								
								scripts/print-performance-measures.js
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,87 @@
 | 
			
		||||
#!/usr/bin/env node
 | 
			
		||||
 | 
			
		||||
// (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.
 | 
			
		||||
 | 
			
		||||
const { readdirSync, readFileSync } = require('fs');
 | 
			
		||||
 | 
			
		||||
if (process.argv.length < 3) {
 | 
			
		||||
    console.error('Missing measure timings storage path argument');
 | 
			
		||||
    process.exit(1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const performanceMeasuresStoragePath = process.argv[2].trimRight('/') + '/';
 | 
			
		||||
const files = readdirSync(performanceMeasuresStoragePath);
 | 
			
		||||
const performanceMeasures = {};
 | 
			
		||||
 | 
			
		||||
if (files.length === 0) {
 | 
			
		||||
    console.log('No logs found!');
 | 
			
		||||
    process.exit(0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Aggregate data
 | 
			
		||||
for (const file of files) {
 | 
			
		||||
    const performanceMeasure = JSON.parse(readFileSync(performanceMeasuresStoragePath + file));
 | 
			
		||||
 | 
			
		||||
    performanceMeasures[performanceMeasure.name] = performanceMeasures[performanceMeasure.name] ?? {
 | 
			
		||||
        duration: [],
 | 
			
		||||
        scripting: [],
 | 
			
		||||
        styling: [],
 | 
			
		||||
        blocking: [],
 | 
			
		||||
        longTasks: [],
 | 
			
		||||
        database: [],
 | 
			
		||||
        networking: [],
 | 
			
		||||
    };
 | 
			
		||||
    performanceMeasures[performanceMeasure.name].duration.push(performanceMeasure.duration);
 | 
			
		||||
    performanceMeasures[performanceMeasure.name].scripting.push(performanceMeasure.scripting);
 | 
			
		||||
    performanceMeasures[performanceMeasure.name].styling.push(performanceMeasure.styling);
 | 
			
		||||
    performanceMeasures[performanceMeasure.name].blocking.push(performanceMeasure.blocking);
 | 
			
		||||
    performanceMeasures[performanceMeasure.name].longTasks.push(performanceMeasure.longTasks);
 | 
			
		||||
    performanceMeasures[performanceMeasure.name].database.push(performanceMeasure.database);
 | 
			
		||||
    performanceMeasures[performanceMeasure.name].networking.push(performanceMeasure.networking);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Calculate averages
 | 
			
		||||
for (const [name, { duration, scripting, styling, blocking, longTasks, database, networking }] of Object.entries(performanceMeasures)) {
 | 
			
		||||
    const totalRuns = duration.length;
 | 
			
		||||
    const averageDuration = Math.round(duration.reduce((total, duration) => total + duration) / totalRuns);
 | 
			
		||||
    const averageScripting = Math.round(scripting.reduce((total, scripting) => total + scripting) / totalRuns);
 | 
			
		||||
    const averageStyling = Math.round(styling.reduce((total, styling) => total + styling) / totalRuns);
 | 
			
		||||
    const averageBlocking = Math.round(blocking.reduce((total, blocking) => total + blocking) / totalRuns);
 | 
			
		||||
    const averageLongTasks = Math.round(longTasks.reduce((total, longTasks) => total + longTasks) / totalRuns);
 | 
			
		||||
    const averageDatabase = Math.round(database.reduce((total, database) => total + database) / totalRuns);
 | 
			
		||||
    const averageNetworking = Math.round(networking.reduce((total, networking) => total + networking) / totalRuns);
 | 
			
		||||
 | 
			
		||||
    performanceMeasures[name] = {
 | 
			
		||||
        'Total duration': `${averageDuration}ms`,
 | 
			
		||||
        'Scripting': `${averageScripting}ms`,
 | 
			
		||||
        'Styling': `${averageStyling}ms`,
 | 
			
		||||
        'Blocking': `${averageBlocking}ms`,
 | 
			
		||||
        '# Network requests': averageNetworking,
 | 
			
		||||
        '# DB Queries': averageDatabase,
 | 
			
		||||
        '# Long Tasks': averageLongTasks,
 | 
			
		||||
        '# runs': totalRuns,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Sort tests
 | 
			
		||||
const tests = Object.keys(performanceMeasures).sort();
 | 
			
		||||
const sortedPerformanceMeasures = {};
 | 
			
		||||
 | 
			
		||||
for (const test of tests) {
 | 
			
		||||
    sortedPerformanceMeasures[test] = performanceMeasures[test];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Display data
 | 
			
		||||
console.table(sortedPerformanceMeasures);
 | 
			
		||||
							
								
								
									
										32
									
								
								scripts/serve.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										32
									
								
								scripts/serve.sh
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
 | 
			
		||||
# This script is necessary because @ionic/cli is passing one argument to the ionic:serve hook
 | 
			
		||||
# that is unsupported by angular cli: https://github.com/ionic-team/ionic-cli/issues/4743
 | 
			
		||||
#
 | 
			
		||||
# Once the issue is fixed, this script can be replaced adding the following npm script:
 | 
			
		||||
#
 | 
			
		||||
#     "ionic:serve": "gulp watch & NODE_OPTIONS=--max-old-space-size=4096 ng serve"
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
# Run gulp watch.
 | 
			
		||||
echo "> gulp watch &"
 | 
			
		||||
gulp watch &
 | 
			
		||||
 | 
			
		||||
# Remove unknown arguments and prepare angular target.
 | 
			
		||||
args=("$@")
 | 
			
		||||
angulartarget="serve"
 | 
			
		||||
total=${#args[@]}
 | 
			
		||||
for ((i=0; i<total; ++i)); do
 | 
			
		||||
    case ${args[i]} in
 | 
			
		||||
        --project=*)
 | 
			
		||||
            unset args[i];
 | 
			
		||||
            ;;
 | 
			
		||||
        --platform=*)
 | 
			
		||||
            angulartarget="ionic-cordova-serve";
 | 
			
		||||
            ;;
 | 
			
		||||
    esac
 | 
			
		||||
done
 | 
			
		||||
 | 
			
		||||
# Serve app.
 | 
			
		||||
echo "> NODE_OPTIONS=--max-old-space-size=4096 ng run app:$angulartarget ${args[@]}"
 | 
			
		||||
NODE_OPTIONS=--max-old-space-size=4096 ng run "app:$angulartarget" ${args[@]}
 | 
			
		||||
							
								
								
									
										32
									
								
								scripts/templates/behat-plugin/classes/privacy/provider.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								scripts/templates/behat-plugin/classes/privacy/provider.php
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
<?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/>.
 | 
			
		||||
 | 
			
		||||
namespace local_moodleappbehat\privacy;
 | 
			
		||||
 | 
			
		||||
use core_privacy\local\metadata\null_provider;
 | 
			
		||||
 | 
			
		||||
defined('MOODLE_INTERNAL') || die();
 | 
			
		||||
 | 
			
		||||
class provider implements null_provider {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    public static function get_reason() : string {
 | 
			
		||||
        return 'privacy_metadata';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
$string['pluginname'] = 'Moodle App Behat (auto-generated)';
 | 
			
		||||
$string['privacy_metadata'] = 'This plugin should only be used in development environments, and it does not store any user data.';
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ source "lang_functions.sh"
 | 
			
		||||
forceLang=$1
 | 
			
		||||
 | 
			
		||||
print_title 'Getting local mobile langs'
 | 
			
		||||
git clone --depth 1 https://github.com/moodlehq/moodle-local_moodlemobileapp.git ../../moodle-local_moodlemobileapp
 | 
			
		||||
git clone --branch integration --depth 1 https://github.com/moodlehq/moodle-local_moodlemobileapp.git ../../moodle-local_moodlemobileapp
 | 
			
		||||
 | 
			
		||||
if [ -z $forceLang ]; then
 | 
			
		||||
    get_languages
 | 
			
		||||
 | 
			
		||||
@ -30,6 +30,7 @@ import { AddonPrivateFilesModule } from './privatefiles/privatefiles.module';
 | 
			
		||||
import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
 | 
			
		||||
import { AddonQtypeModule } from './qtype/qtype.module';
 | 
			
		||||
import { AddonRemoteThemesModule } from './remotethemes/remotethemes.module';
 | 
			
		||||
import { AddonReportModule } from './report/report.module';
 | 
			
		||||
import { AddonStorageManagerModule } from './storagemanager/storagemanager.module';
 | 
			
		||||
import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module';
 | 
			
		||||
 | 
			
		||||
@ -51,6 +52,7 @@ import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield
 | 
			
		||||
        AddonQbehaviourModule,
 | 
			
		||||
        AddonQtypeModule,
 | 
			
		||||
        AddonRemoteThemesModule,
 | 
			
		||||
        AddonReportModule,
 | 
			
		||||
        AddonStorageManagerModule,
 | 
			
		||||
        AddonUserProfileFieldModule,
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
@ -44,8 +44,7 @@ const mainMenuRoutes: Routes = [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
            useValue: () => {
 | 
			
		||||
                CoreContentLinksDelegate.registerHandler(AddonBadgesMyBadgesLinkHandler.instance);
 | 
			
		||||
                CoreContentLinksDelegate.registerHandler(AddonBadgesBadgeLinkHandler.instance);
 | 
			
		||||
                CoreUserDelegate.registerHandler(AddonBadgesUserHandler.instance);
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										60
									
								
								src/addons/badges/classes/user-badges-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/addons/badges/classes/user-badges-source.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
// (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 { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source';
 | 
			
		||||
import { AddonBadges, AddonBadgesUserBadge } from '../services/badges';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Provides a collection of user badges.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonBadgesUserBadgesSource extends CoreRoutedItemsManagerSource<AddonBadgesUserBadge> {
 | 
			
		||||
 | 
			
		||||
    readonly COURSE_ID: number;
 | 
			
		||||
    readonly USER_ID: number;
 | 
			
		||||
 | 
			
		||||
    constructor(courseId: number, userId: number) {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        this.COURSE_ID = courseId;
 | 
			
		||||
        this.USER_ID = userId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    getItemPath(badge: AddonBadgesUserBadge): string {
 | 
			
		||||
        return badge.uniquehash;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    getItemQueryParams(): Params {
 | 
			
		||||
        return {
 | 
			
		||||
            courseId: this.COURSE_ID,
 | 
			
		||||
            userId: this.USER_ID,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    protected async loadPageItems(): Promise<{ items: AddonBadgesUserBadge[] }> {
 | 
			
		||||
        const badges = await AddonBadges.getUserBadges(this.COURSE_ID, this.USER_ID);
 | 
			
		||||
 | 
			
		||||
        return { items: badges };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -20,10 +20,10 @@
 | 
			
		||||
    "issuerurl": "Issuer URL",
 | 
			
		||||
    "language": "Language",
 | 
			
		||||
    "noalignment": "This badge does not have any external skills or standards specified.",
 | 
			
		||||
    "nobadges": "There are no badges available.",
 | 
			
		||||
    "nobadges": "There are currently no badges available for users to earn.",
 | 
			
		||||
    "norelated": "This badge does not have any related badges.",
 | 
			
		||||
    "recipientdetails": "Recipient details",
 | 
			
		||||
    "relatedbages": "Related badges",
 | 
			
		||||
    "version": "Version",
 | 
			
		||||
    "warnexpired": "(This badge has expired!)"
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -3,11 +3,13 @@
 | 
			
		||||
        <ion-buttons slot="start">
 | 
			
		||||
            <ion-back-button [text]="'core.back' | translate"></ion-back-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
        <h1 *ngIf="badge">{{ badge.name }}</h1>
 | 
			
		||||
        <h1 *ngIf="!badge">{{ 'addon.badges.badges' | translate }}</h1>
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <h1 *ngIf="badge">{{ badge.name }}</h1>
 | 
			
		||||
            <h1 *ngIf="!badge">{{ 'addon.badges.badges' | translate }}</h1>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
<ion-content [core-swipe-navigation]="badges" class="limited-width">
 | 
			
		||||
    <ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)">
 | 
			
		||||
        <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
 | 
			
		||||
    </ion-refresher>
 | 
			
		||||
@ -53,7 +55,9 @@
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="badge.issuercontact">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.contact' | translate}}</h2>
 | 
			
		||||
                        <p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no"> {{ badge.issuercontact }} </a></p>
 | 
			
		||||
                        <p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no" [showBrowserWarning]="false">
 | 
			
		||||
                                {{ badge.issuercontact }}
 | 
			
		||||
                            </a></p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-item-group>
 | 
			
		||||
@ -97,7 +101,9 @@
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.imageauthoremail' | translate}}</h2>
 | 
			
		||||
                        <p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no"> {{ badge.imageauthoremail }} </a></p>
 | 
			
		||||
                        <p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no" [showBrowserWarning]="false">
 | 
			
		||||
                                {{ badge.imageauthoremail }}
 | 
			
		||||
                            </a></p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl">
 | 
			
		||||
@ -153,7 +159,9 @@
 | 
			
		||||
            <!-- Endorsement -->
 | 
			
		||||
            <ion-item-group *ngIf="badge.endorsement">
 | 
			
		||||
                <ion-item-divider>
 | 
			
		||||
                    <ion-label><h2>{{ 'addon.badges.bendorsement' | translate}}</h2></ion-label>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.bendorsement' | translate}}</h2>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item-divider>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
@ -165,7 +173,7 @@
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.issueremail' | translate}}</h2>
 | 
			
		||||
                        <p>
 | 
			
		||||
                            <a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no">
 | 
			
		||||
                            <a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no" [showBrowserWarning]="false">
 | 
			
		||||
                                {{ badge.endorsement.issueremail }}
 | 
			
		||||
                            </a>
 | 
			
		||||
                        </p>
 | 
			
		||||
@ -200,27 +208,39 @@
 | 
			
		||||
            <!-- Related badges -->
 | 
			
		||||
            <ion-item-group *ngIf="badge.relatedbadges">
 | 
			
		||||
                <ion-item-divider>
 | 
			
		||||
                    <ion-label><h2>{{ 'addon.badges.relatedbages' | translate}}</h2></ion-label>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.relatedbages' | translate}}</h2>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item-divider>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges">
 | 
			
		||||
                    <ion-label><h2>{{ relatedBadge.name }}</h2></ion-label>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ relatedBadge.name }}</h2>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0">
 | 
			
		||||
                    <ion-label><h2>{{ 'addon.badges.norelated' | translate}}</h2></ion-label>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.norelated' | translate}}</h2>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-item-group>
 | 
			
		||||
 | 
			
		||||
            <!-- Competencies alignment -->
 | 
			
		||||
            <ion-item-group *ngIf="badge.alignment">
 | 
			
		||||
                <ion-item-divider>
 | 
			
		||||
                    <ion-label><h2>{{ 'addon.badges.alignment' | translate}}</h2></ion-label>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.alignment' | translate}}</h2>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item-divider>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link
 | 
			
		||||
                    auto-login="no">
 | 
			
		||||
                    <ion-label><h2>{{ alignment.targetname }}</h2></ion-label>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ alignment.targetname }}</h2>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0">
 | 
			
		||||
                    <ion-label><h2>{{ 'addon.badges.noalignment' | translate}}</h2></ion-label>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.badges.noalignment' | translate}}</h2>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-item-group>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,7 @@
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { Component, OnDestroy, OnInit } from '@angular/core';
 | 
			
		||||
import { IonRefresher } from '@ionic/angular';
 | 
			
		||||
import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
@ -23,6 +23,9 @@ import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { ActivatedRoute } from '@angular/router';
 | 
			
		||||
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
 | 
			
		||||
import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source';
 | 
			
		||||
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Page that displays the list of calendar events.
 | 
			
		||||
@ -31,7 +34,7 @@ import { ActivatedRoute } from '@angular/router';
 | 
			
		||||
    selector: 'page-addon-badges-issued-badge',
 | 
			
		||||
    templateUrl: 'issued-badge.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonBadgesIssuedBadgePage implements OnInit {
 | 
			
		||||
export class AddonBadgesIssuedBadgePage implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    protected badgeHash = '';
 | 
			
		||||
    protected userId!: number;
 | 
			
		||||
@ -40,24 +43,39 @@ export class AddonBadgesIssuedBadgePage implements OnInit {
 | 
			
		||||
    user?: CoreUserProfile;
 | 
			
		||||
    course?: CoreEnrolledCourseData;
 | 
			
		||||
    badge?: AddonBadgesUserBadge;
 | 
			
		||||
    badges: CoreSwipeNavigationItemsManager;
 | 
			
		||||
    badgeLoaded = false;
 | 
			
		||||
    currentTime = 0;
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected route: ActivatedRoute,
 | 
			
		||||
    ) { }
 | 
			
		||||
    constructor(protected route: ActivatedRoute) {
 | 
			
		||||
        this.courseId = CoreNavigator.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
 | 
			
		||||
        this.userId = CoreNavigator.getRouteNumberParam('userId') || CoreSites.getRequiredCurrentSite().getUserId();
 | 
			
		||||
        this.badgeHash = CoreNavigator.getRouteParam('badgeHash') || '';
 | 
			
		||||
 | 
			
		||||
        const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
 | 
			
		||||
            AddonBadgesUserBadgesSource,
 | 
			
		||||
            [this.courseId, this.userId],
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        this.badges = new CoreSwipeNavigationItemsManager(source);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * View loaded.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        this.courseId = CoreNavigator.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
 | 
			
		||||
        this.userId = CoreNavigator.getRouteNumberParam('userId') || CoreSites.getCurrentSite()!.getUserId();
 | 
			
		||||
        this.badgeHash = CoreNavigator.getRouteParam('badgeHash') || '';
 | 
			
		||||
 | 
			
		||||
        this.fetchIssuedBadge().finally(() => {
 | 
			
		||||
            this.badgeLoaded = true;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.badges.start();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.badges.destroy();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,9 @@
 | 
			
		||||
        <ion-buttons slot="start">
 | 
			
		||||
            <ion-back-button [text]="'core.back' | translate"></ion-back-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
        <h1>{{ 'addon.badges.badges' | translate }}</h1>
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <h1>{{ 'addon.badges.badges' | translate }}</h1>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
@ -12,8 +14,7 @@
 | 
			
		||||
            <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
 | 
			
		||||
        </ion-refresher>
 | 
			
		||||
        <core-loading [hideUntil]="badges.loaded">
 | 
			
		||||
            <core-empty-box *ngIf="badges.empty" icon="fas-trophy"
 | 
			
		||||
                [message]="'addon.badges.nobadges' | translate">
 | 
			
		||||
            <core-empty-box *ngIf="badges.empty" icon="fas-trophy" [message]="'addon.badges.nobadges' | translate">
 | 
			
		||||
            </core-empty-box>
 | 
			
		||||
 | 
			
		||||
            <ion-list *ngIf="!badges.empty" class="ion-no-margin">
 | 
			
		||||
 | 
			
		||||
@ -19,10 +19,11 @@ import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CorePageItemsListManager } from '@classes/page-items-list-manager';
 | 
			
		||||
import { Params } from '@angular/router';
 | 
			
		||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreListItemsManager } from '@classes/items-management/list-items-manager';
 | 
			
		||||
import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source';
 | 
			
		||||
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Page that displays the list of calendar events.
 | 
			
		||||
@ -34,15 +35,23 @@ import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    currentTime = 0;
 | 
			
		||||
    badges: AddonBadgesUserBadgesManager;
 | 
			
		||||
    badges: CoreListItemsManager<AddonBadgesUserBadge, AddonBadgesUserBadgesSource>;
 | 
			
		||||
 | 
			
		||||
    @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        const courseId = CoreNavigator.getRouteNumberParam('courseId') ?? 0; // Use 0 for site badges.
 | 
			
		||||
        let courseId = CoreNavigator.getRouteNumberParam('courseId') ?? 0; // Use 0 for site badges.
 | 
			
		||||
        const userId = CoreNavigator.getRouteNumberParam('userId') ?? CoreSites.getCurrentSiteUserId();
 | 
			
		||||
 | 
			
		||||
        this.badges = new AddonBadgesUserBadgesManager(AddonBadgesUserBadgesPage, courseId, userId);
 | 
			
		||||
        if (courseId === CoreSites.getCurrentSiteHomeId()) {
 | 
			
		||||
            // Use courseId 0 for site home, otherwise the site doesn't return site badges.
 | 
			
		||||
            courseId = 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.badges = new CoreListItemsManager(
 | 
			
		||||
            CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]),
 | 
			
		||||
            AddonBadgesUserBadgesPage,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -67,8 +76,13 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
 | 
			
		||||
     * @param refresher Refresher.
 | 
			
		||||
     */
 | 
			
		||||
    async refreshBadges(refresher?: IonRefresher): Promise<void> {
 | 
			
		||||
        await CoreUtils.ignoreErrors(AddonBadges.invalidateUserBadges(this.badges.courseId, this.badges.userId));
 | 
			
		||||
        await CoreUtils.ignoreErrors(this.fetchBadges());
 | 
			
		||||
        await CoreUtils.ignoreErrors(
 | 
			
		||||
            AddonBadges.invalidateUserBadges(
 | 
			
		||||
                this.badges.getSource().COURSE_ID,
 | 
			
		||||
                this.badges.getSource().USER_ID,
 | 
			
		||||
            ),
 | 
			
		||||
        );
 | 
			
		||||
        await CoreUtils.ignoreErrors(this.badges.reload());
 | 
			
		||||
 | 
			
		||||
        refresher?.complete();
 | 
			
		||||
    }
 | 
			
		||||
@ -80,55 +94,12 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
 | 
			
		||||
        this.currentTime = CoreTimeUtils.timestamp();
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.fetchBadges();
 | 
			
		||||
            await this.badges.reload();
 | 
			
		||||
        } catch (message) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(message, 'Error loading badges');
 | 
			
		||||
 | 
			
		||||
            this.badges.setItems([]);
 | 
			
		||||
            this.badges.reset();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the list of badges.
 | 
			
		||||
     */
 | 
			
		||||
    private async fetchBadges(): Promise<void> {
 | 
			
		||||
        const badges = await AddonBadges.getUserBadges(this.badges.courseId, this.badges.userId);
 | 
			
		||||
 | 
			
		||||
        this.badges.setItems(badges);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Helper class to manage badges.
 | 
			
		||||
 */
 | 
			
		||||
class AddonBadgesUserBadgesManager extends CorePageItemsListManager<AddonBadgesUserBadge> {
 | 
			
		||||
 | 
			
		||||
    courseId: number;
 | 
			
		||||
    userId: number;
 | 
			
		||||
 | 
			
		||||
    constructor(pageComponent: unknown, courseId: number, userId: number) {
 | 
			
		||||
        super(pageComponent);
 | 
			
		||||
 | 
			
		||||
        this.courseId = courseId;
 | 
			
		||||
        this.userId = userId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    protected getItemPath(badge: AddonBadgesUserBadge): string {
 | 
			
		||||
        return badge.uniquehash;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    protected getItemQueryParams(): Params {
 | 
			
		||||
        return {
 | 
			
		||||
            courseId: this.courseId,
 | 
			
		||||
            userId: this.userId,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -39,7 +39,7 @@ export class AddonBadgesProvider {
 | 
			
		||||
    async isPluginEnabled(siteId?: string): Promise<boolean> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        return site.canUseAdvancedFeature('enablebadges') && site.wsAvailable('core_course_get_user_navigation_options');
 | 
			
		||||
        return site.canUseAdvancedFeature('enablebadges');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -83,7 +83,7 @@ export class AddonBadgesProvider {
 | 
			
		||||
            badge.alignment = badge.alignment || badge.competencies;
 | 
			
		||||
 | 
			
		||||
            // Check that the alignment is valid, they were broken in 3.7.
 | 
			
		||||
            if (badge.alignment && badge.alignment[0] && typeof badge.alignment[0].targetname == 'undefined') {
 | 
			
		||||
            if (badge.alignment && badge.alignment[0] && badge.alignment[0].targetname === undefined) {
 | 
			
		||||
                // If any badge lacks targetname it means they are affected by the Moodle bug, don't display them.
 | 
			
		||||
                delete badge.alignment;
 | 
			
		||||
            }
 | 
			
		||||
@ -194,7 +194,7 @@ export type AddonBadgesUserBadge = {
 | 
			
		||||
        targetframework?: string; // Target framework.
 | 
			
		||||
        targetcode?: string; // Target code.
 | 
			
		||||
    }[];
 | 
			
		||||
    competencies?: { // @deprecated from 3.7. @since 3.6. In 3.7 it was renamed to alignment.
 | 
			
		||||
    competencies?: { // @deprecatedonmoodle from 3.7. @since 3.6. In 3.7 it was renamed to alignment.
 | 
			
		||||
        id?: number; // Alignment id.
 | 
			
		||||
        badgeid?: number; // Badge id.
 | 
			
		||||
        targetname?: string; // Target name.
 | 
			
		||||
 | 
			
		||||
@ -14,8 +14,14 @@
 | 
			
		||||
 | 
			
		||||
import { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses';
 | 
			
		||||
import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate';
 | 
			
		||||
import {
 | 
			
		||||
    CoreUserDelegateContext,
 | 
			
		||||
    CoreUserDelegateService,
 | 
			
		||||
    CoreUserProfileHandler,
 | 
			
		||||
    CoreUserProfileHandlerData,
 | 
			
		||||
} from '@features/user/services/user-delegate';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonBadges } from '../badges';
 | 
			
		||||
 | 
			
		||||
@ -25,52 +31,58 @@ import { AddonBadges } from '../badges';
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonBadgesUserHandlerService implements CoreUserProfileHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonBadges';
 | 
			
		||||
    priority = 50;
 | 
			
		||||
    name = 'AddonBadges:fakename'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
 | 
			
		||||
    priority = 300;
 | 
			
		||||
    type = CoreUserDelegateService.TYPE_NEW_PAGE;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if handler is enabled.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Always enabled.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    isEnabled(): Promise<boolean> {
 | 
			
		||||
        return AddonBadges.isPluginEnabled();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if handler is enabled for this user in this context.
 | 
			
		||||
     *
 | 
			
		||||
     * @param courseId Course ID.
 | 
			
		||||
     * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions.
 | 
			
		||||
     * @return True if enabled, false otherwise.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabledForCourse(
 | 
			
		||||
    async isEnabledForContext(
 | 
			
		||||
        context: CoreUserDelegateContext,
 | 
			
		||||
        courseId: number,
 | 
			
		||||
        navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
 | 
			
		||||
    ): Promise<boolean> {
 | 
			
		||||
        if (navOptions && typeof navOptions.badges != 'undefined') {
 | 
			
		||||
        // Check if feature is disabled.
 | 
			
		||||
        const currentSite = CoreSites.getCurrentSite();
 | 
			
		||||
        if (!currentSite) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (context === CoreUserDelegateContext.USER_MENU) {
 | 
			
		||||
            if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBadges:account')) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        } else if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBadges')) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (navOptions && navOptions.badges !== undefined) {
 | 
			
		||||
            return navOptions.badges;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If we reach here, it means we are opening the user site profile.
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the data needed to render the handler.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Data needed to render the handler.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    getDisplayData(): CoreUserProfileHandlerData {
 | 
			
		||||
        return {
 | 
			
		||||
            icon: 'fas-trophy',
 | 
			
		||||
            title: 'addon.badges.badges',
 | 
			
		||||
            action: (event, user, courseId): void => {
 | 
			
		||||
            action: (event, user, context, contextId): void => {
 | 
			
		||||
                event.preventDefault();
 | 
			
		||||
                event.stopPropagation();
 | 
			
		||||
                CoreNavigator.navigateToSitePath('/badges', {
 | 
			
		||||
                    params: { courseId, userId: user.id },
 | 
			
		||||
                    params: { courseId: contextId, userId: user.id },
 | 
			
		||||
                });
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,13 @@
 | 
			
		||||
:host {
 | 
			
		||||
    --mod-icon-filter: brightness(0);
 | 
			
		||||
 | 
			
		||||
    core-mod-icon {
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        --filter: var(--mod-icon-filter);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host-context(body.dark) {
 | 
			
		||||
    --mod-icon-filter: brightness(0) invert(1);
 | 
			
		||||
}
 | 
			
		||||
@ -21,6 +21,7 @@ import { ContextLevel, CoreConstants } from '@/core/constants';
 | 
			
		||||
import { Translate } from '@singletons';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreCourseHelper } from '@features/course/services/course-helper';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render an "activity modules" block.
 | 
			
		||||
@ -28,6 +29,7 @@ import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-block-activitymodules',
 | 
			
		||||
    templateUrl: 'addon-block-activitymodules.html',
 | 
			
		||||
    styleUrls: ['activitymodules.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
@ -66,14 +68,14 @@ export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent i
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            section.modules.forEach((mod) => {
 | 
			
		||||
                if (mod.uservisible === false || !CoreCourse.moduleHasView(mod) ||
 | 
			
		||||
                    typeof modFullNames[mod.modname] != 'undefined') {
 | 
			
		||||
                if (!CoreCourseHelper.canUserViewModule(mod, section) || !CoreCourse.moduleHasView(mod) ||
 | 
			
		||||
                    modFullNames[mod.modname] !== undefined) {
 | 
			
		||||
                    // Ignore this module.
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Get the archetype of the module type.
 | 
			
		||||
                if (typeof archetypes[mod.modname] == 'undefined') {
 | 
			
		||||
                if (archetypes[mod.modname] === undefined) {
 | 
			
		||||
                    archetypes[mod.modname] = CoreCourseModuleDelegate.supportsFeature<number>(
 | 
			
		||||
                        mod.modname,
 | 
			
		||||
                        CoreConstants.FEATURE_MOD_ARCHETYPE,
 | 
			
		||||
@ -96,16 +98,13 @@ export class AddonBlockActivityModulesComponent extends CoreBlockBaseComponent i
 | 
			
		||||
        // Sort the modnames alphabetically.
 | 
			
		||||
        modFullNames = CoreUtils.sortValues(modFullNames);
 | 
			
		||||
        for (const modName in modFullNames) {
 | 
			
		||||
            let icon: string;
 | 
			
		||||
            const iconModName = modName === 'resources' ? 'page' : modName;
 | 
			
		||||
 | 
			
		||||
            if (modName === 'resources') {
 | 
			
		||||
                icon = CoreCourse.getModuleIconSrc('page', modIcons['page']);
 | 
			
		||||
            } else {
 | 
			
		||||
                icon = CoreCourseModuleDelegate.getModuleIconSrc(modName, modIcons[modName]) || '';
 | 
			
		||||
            }
 | 
			
		||||
            const icon = await CoreCourseModuleDelegate.getModuleIconSrc(iconModName, modIcons[iconModName]);
 | 
			
		||||
 | 
			
		||||
            this.entries.push({
 | 
			
		||||
                icon: icon,
 | 
			
		||||
                icon,
 | 
			
		||||
                iconModName,
 | 
			
		||||
                name: modFullNames[modName],
 | 
			
		||||
                modName,
 | 
			
		||||
            });
 | 
			
		||||
@ -145,4 +144,5 @@ type AddonBlockActivityModuleEntry = {
 | 
			
		||||
    icon: string;
 | 
			
		||||
    name: string;
 | 
			
		||||
    modName: string;
 | 
			
		||||
    iconModName: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,12 @@
 | 
			
		||||
<ion-item-divider sticky="true">
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h2>{{ 'addon.block_activitymodules.pluginname' | translate }}</h2>
 | 
			
		||||
        <h2>{{ 'addon.block_activitymodules.pluginname' | translate }}</h2>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item-divider>
 | 
			
		||||
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin">
 | 
			
		||||
    <ion-item class="ion-text-wrap item-media" *ngFor="let entry of entries" detail="true" button
 | 
			
		||||
        (click)="gotoCoureListModType(entry)">
 | 
			
		||||
        <img slot="start" [src]="entry.icon" alt="" role="presentation" class="core-module-icon">
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
    <ion-item class="ion-text-wrap" *ngFor="let entry of entries" detail="true" button (click)="gotoCoureListModType(entry)">
 | 
			
		||||
        <core-mod-icon slot="start" [modicon]="entry.icon" [modname]="entry.iconModName" [showAlt]="false">
 | 
			
		||||
        </core-mod-icon>
 | 
			
		||||
        <ion-label>{{ entry.name }}</ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
</core-loading>
 | 
			
		||||
 | 
			
		||||
@ -21,7 +21,7 @@
 | 
			
		||||
            text-align: start;
 | 
			
		||||
            padding-top: .75rem;
 | 
			
		||||
            padding-bottom: .75rem;
 | 
			
		||||
            color: var(--gray-darker);
 | 
			
		||||
            color: var(--medium);
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
            font-size: 18px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -1,21 +1,42 @@
 | 
			
		||||
:host .core-block-content ::ng-deep {
 | 
			
		||||
    ul.badges {
 | 
			
		||||
        list-style: none;
 | 
			
		||||
        margin-left: 0;
 | 
			
		||||
        margin-right: 0;
 | 
			
		||||
        -webkit-padding-start: 0;
 | 
			
		||||
:host {
 | 
			
		||||
    --badge-size: 100px;
 | 
			
		||||
    --badge-container-size: 150px;
 | 
			
		||||
 | 
			
		||||
        li {
 | 
			
		||||
            position: relative;
 | 
			
		||||
            display: inline-block;
 | 
			
		||||
            padding-top: 1em;
 | 
			
		||||
            text-align: center;
 | 
			
		||||
            vertical-align: top;
 | 
			
		||||
            width: 150px;
 | 
			
		||||
    .core-block-content ::ng-deep {
 | 
			
		||||
 | 
			
		||||
            .badge-name {
 | 
			
		||||
                display: block;
 | 
			
		||||
                padding: 5px;
 | 
			
		||||
        ul.badges {
 | 
			
		||||
            list-style: none;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
 | 
			
		||||
            li {
 | 
			
		||||
                position: relative;
 | 
			
		||||
                display: inline-block;
 | 
			
		||||
                text-align: center;
 | 
			
		||||
                margin-top: 1em;
 | 
			
		||||
                vertical-align: top;
 | 
			
		||||
                width: var(--badge-container-size);
 | 
			
		||||
 | 
			
		||||
                .badge-name {
 | 
			
		||||
                    display: block;
 | 
			
		||||
                    padding: 5px;
 | 
			
		||||
                }
 | 
			
		||||
                .badge-image {
 | 
			
		||||
                    width: var(--badge-size);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .expireimage {
 | 
			
		||||
                    content: 'expired';
 | 
			
		||||
                    background-image: url('/assets/img/expired.svg');
 | 
			
		||||
                    background-repeat: no-repeat;
 | 
			
		||||
                    background-size: var(--badge-size) var(--badge-size);
 | 
			
		||||
                    width: var(--badge-size);
 | 
			
		||||
                    height: var(--badge-size);
 | 
			
		||||
                    left: calc((var(--badge-container-size) - var(--badge-size)) /2);
 | 
			
		||||
                    top: 0;
 | 
			
		||||
                    position: absolute;
 | 
			
		||||
                    z-index: 2;
 | 
			
		||||
                    opacity: .85;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -24,6 +24,7 @@ import { AddonBlockCalendarMonthModule } from './calendarmonth/calendarmonth.mod
 | 
			
		||||
import { AddonBlockCalendarUpcomingModule } from './calendarupcoming/calendarupcoming.module';
 | 
			
		||||
import { AddonBlockCommentsModule } from './comments/comments.module';
 | 
			
		||||
import { AddonBlockCompletionStatusModule } from './completionstatus/completionstatus.module';
 | 
			
		||||
import { AddonBlockCourseListModule } from './courselist/courselist.module';
 | 
			
		||||
import { AddonBlockGlossaryRandomModule } from './glossaryrandom/glossaryrandom.module';
 | 
			
		||||
import { AddonBlockHtmlModule } from './html/html.module';
 | 
			
		||||
import { AddonBlockLearningPlansModule } from './learningplans/learningplans.module';
 | 
			
		||||
@ -53,6 +54,7 @@ import { AddonBlockTimelineModule } from './timeline/timeline.module';
 | 
			
		||||
        AddonBlockCalendarUpcomingModule,
 | 
			
		||||
        AddonBlockCommentsModule,
 | 
			
		||||
        AddonBlockCompletionStatusModule,
 | 
			
		||||
        AddonBlockCourseListModule,
 | 
			
		||||
        AddonBlockGlossaryRandomModule,
 | 
			
		||||
        AddonBlockHtmlModule,
 | 
			
		||||
        AddonBlockLearningPlansModule,
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,7 @@
 | 
			
		||||
:host .core-block-content ::ng-deep {
 | 
			
		||||
    ul.inline-list {
 | 
			
		||||
        font-size: 80%;
 | 
			
		||||
        list-style: none;
 | 
			
		||||
        margin-left: 0;
 | 
			
		||||
        margin-right: 0;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        -webkit-padding-start: 0;
 | 
			
		||||
 | 
			
		||||
        li {
 | 
			
		||||
@ -11,8 +9,8 @@
 | 
			
		||||
            display: inline-block;
 | 
			
		||||
 | 
			
		||||
            a {
 | 
			
		||||
                background: var(--ion-color-primary);
 | 
			
		||||
                color: var(--ion-color-primary-contrast);
 | 
			
		||||
                background: var(--primary);
 | 
			
		||||
                color: var(--primary-contrast);
 | 
			
		||||
                padding: 3px 8px;
 | 
			
		||||
                -webkit-font-smoothing: antialiased;
 | 
			
		||||
                display: inline-block;
 | 
			
		||||
@ -24,7 +22,7 @@
 | 
			
		||||
                contain: content;
 | 
			
		||||
                vertical-align: baseline;
 | 
			
		||||
                text-decoration: none;
 | 
			
		||||
                border-radius: 4px;
 | 
			
		||||
                border-radius: var(--small-radius);
 | 
			
		||||
            }
 | 
			
		||||
            .s20 {
 | 
			
		||||
                font-size: 1.5em;
 | 
			
		||||
 | 
			
		||||
@ -16,10 +16,10 @@ import { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
 | 
			
		||||
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
 | 
			
		||||
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
 | 
			
		||||
import { AddonCalendar } from '@/addons/calendar/services/calendar';
 | 
			
		||||
import { CoreCourseBlock } from '@features/course/services/course';
 | 
			
		||||
import { Params } from '@angular/router';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonCalendarMainMenuHandlerService } from '@addons/calendar/services/handlers/mainmenu';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Block handler.
 | 
			
		||||
@ -45,7 +45,7 @@ export class AddonBlockCalendarMonthHandlerService extends CoreBlockBaseHandler
 | 
			
		||||
            title: 'addon.block_calendarmonth.pluginname',
 | 
			
		||||
            class: 'addon-block-calendar-month',
 | 
			
		||||
            component: CoreBlockOnlyTitleComponent,
 | 
			
		||||
            link: AddonCalendar.getMainCalendarPagePath(),
 | 
			
		||||
            link: AddonCalendarMainMenuHandlerService.PAGE_NAME,
 | 
			
		||||
            linkParams: linkParams,
 | 
			
		||||
            navOptions: {
 | 
			
		||||
                preferCurrentTab: false,
 | 
			
		||||
 | 
			
		||||
@ -16,10 +16,11 @@ import { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
 | 
			
		||||
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
 | 
			
		||||
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
 | 
			
		||||
import { AddonCalendar } from '@/addons/calendar/services/calendar';
 | 
			
		||||
import { CoreCourseBlock } from '@features/course/services/course';
 | 
			
		||||
import { Params } from '@angular/router';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonCalendarMainMenuHandlerService } from '@addons/calendar/services/handlers/mainmenu';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Block handler.
 | 
			
		||||
@ -39,18 +40,18 @@ export class AddonBlockCalendarUpcomingHandlerService extends CoreBlockBaseHandl
 | 
			
		||||
     * @return Data or promise resolved with the data.
 | 
			
		||||
     */
 | 
			
		||||
    getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData {
 | 
			
		||||
        const linkParams: Params = contextLevel == 'course' ? { courseId: instanceId } : {};
 | 
			
		||||
        linkParams.upcoming = true;
 | 
			
		||||
        const linkParams: Params = { upcoming: true };
 | 
			
		||||
 | 
			
		||||
        if (contextLevel == 'course' && instanceId !== CoreSites.getCurrentSiteHomeId()) {
 | 
			
		||||
            linkParams.courseId = instanceId;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            title: 'addon.block_calendarupcoming.pluginname',
 | 
			
		||||
            class: 'addon-block-calendar-upcoming',
 | 
			
		||||
            component: CoreBlockOnlyTitleComponent,
 | 
			
		||||
            link: AddonCalendar.getMainCalendarPagePath(),
 | 
			
		||||
            link: AddonCalendarMainMenuHandlerService.PAGE_NAME,
 | 
			
		||||
            linkParams: linkParams,
 | 
			
		||||
            navOptions: {
 | 
			
		||||
                preferCurrentTab: false,
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										36
									
								
								src/addons/block/courselist/courselist.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/addons/block/courselist/courselist.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,36 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
import { IonicModule } from '@ionic/angular';
 | 
			
		||||
import { TranslateModule } from '@ngx-translate/core';
 | 
			
		||||
import { CoreBlockDelegate } from '@features/block/services/block-delegate';
 | 
			
		||||
import { AddonBlockCourseListHandler } from './services/block-handler';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [
 | 
			
		||||
        IonicModule,
 | 
			
		||||
        TranslateModule.forChild(),
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            useValue: () => {
 | 
			
		||||
                CoreBlockDelegate.registerHandler(AddonBlockCourseListHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonBlockCourseListModule {}
 | 
			
		||||
							
								
								
									
										49
									
								
								src/addons/block/courselist/services/block-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/addons/block/courselist/services/block-handler.ts
									
									
									
									
									
										Normal 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 { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
 | 
			
		||||
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
 | 
			
		||||
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Block handler.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonBlockCourseListHandlerService extends CoreBlockBaseHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonBlockCourseList';
 | 
			
		||||
    blockName = 'course_list';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    getDisplayData(): CoreBlockHandlerData {
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            title: 'core.courses.mycourses',
 | 
			
		||||
            class: 'addon-block-course-list',
 | 
			
		||||
            component: CoreBlockOnlyTitleComponent,
 | 
			
		||||
            link: 'courses/list',
 | 
			
		||||
            linkParams: { mode: 'my' },
 | 
			
		||||
            navOptions: {
 | 
			
		||||
                preferCurrentTab: false,
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const AddonBlockCourseListHandler = makeSingleton(AddonBlockCourseListHandlerService);
 | 
			
		||||
@ -17,7 +17,7 @@ import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
 | 
			
		||||
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
 | 
			
		||||
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonCompetencyMainMenuHandlerService } from '@addons/competency/services/handlers/mainmenu';
 | 
			
		||||
import { ADDON_COMPETENCY_LEARNING_PLANS_PAGE } from '@addons/competency/competency.module';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Block handler.
 | 
			
		||||
@ -38,7 +38,7 @@ export class AddonBlockLearningPlansHandlerService extends CoreBlockBaseHandler
 | 
			
		||||
            title: 'addon.block_learningplans.pluginname',
 | 
			
		||||
            class: 'addon-block-learning-plans',
 | 
			
		||||
            component: CoreBlockOnlyTitleComponent,
 | 
			
		||||
            link: AddonCompetencyMainMenuHandlerService.PAGE_NAME,
 | 
			
		||||
            link: ADDON_COMPETENCY_LEARNING_PLANS_PAGE,
 | 
			
		||||
            navOptions: {
 | 
			
		||||
                preferCurrentTab: false,
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
@ -4,97 +4,132 @@
 | 
			
		||||
    </ion-label>
 | 
			
		||||
    <div slot="end" class="flex-row">
 | 
			
		||||
        <!-- Download all courses. -->
 | 
			
		||||
        <div *ngIf="downloadCoursesEnabled && downloadEnabled && filteredCourses.length > 1 && !showFilter"
 | 
			
		||||
            class="core-button-spinner">
 | 
			
		||||
            <ion-button *ngIf="!prefetchCoursesData[selectedFilter].loading" fill="clear" color="dark" (click)="prefetchCourses()"
 | 
			
		||||
                [attr.aria-label]="'core.courses.downloadcourses' | translate">
 | 
			
		||||
                <ion-icon [name]="prefetchCoursesData[selectedFilter].icon" slot="icon-only" aria-hidden="true">
 | 
			
		||||
        <div *ngIf="downloadCoursesEnabled && filteredCourses.length > 0" class="core-button-spinner">
 | 
			
		||||
            <ion-button *ngIf="!prefetchCoursesData.loading" fill="clear" (click)="prefetchCourses()"
 | 
			
		||||
                [attr.aria-label]="prefetchCoursesData.statusTranslatable | translate">
 | 
			
		||||
                <ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true">
 | 
			
		||||
                </ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
            <ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData[selectedFilter].badge"
 | 
			
		||||
                role="progressbar" [attr.aria-valuemax]="prefetchCoursesData[selectedFilter].total"
 | 
			
		||||
                [attr.aria-valuenow]="prefetchCoursesData[selectedFilter].count"
 | 
			
		||||
                [attr.aria-valuetext]="prefetchCoursesData[selectedFilter].badgeA11yText">
 | 
			
		||||
                {{prefetchCoursesData[selectedFilter].badge}}
 | 
			
		||||
            <ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge" role="progressbar"
 | 
			
		||||
                [attr.aria-valuemax]="prefetchCoursesData.total" [attr.aria-valuenow]="prefetchCoursesData.count"
 | 
			
		||||
                [attr.aria-valuetext]="prefetchCoursesData.badgeA11yText">
 | 
			
		||||
                {{prefetchCoursesData.badge}}
 | 
			
		||||
            </ion-badge>
 | 
			
		||||
            <ion-spinner *ngIf="prefetchCoursesData[selectedFilter].loading" [attr.aria-label]="'core.loading' | translate">
 | 
			
		||||
            <ion-spinner *ngIf="prefetchCoursesData.loading" [attr.aria-label]="'core.loading' | translate">
 | 
			
		||||
            </ion-spinner>
 | 
			
		||||
        </div>
 | 
			
		||||
        <core-context-menu>
 | 
			
		||||
            <core-context-menu-item *ngIf="loaded && showFilterSwitchButton()" [priority]="1000"
 | 
			
		||||
                [content]="'core.courses.filtermycourses' | translate" (action)="switchFilter()" iconAction="fas-filter"
 | 
			
		||||
                (onClosed)="switchFilterClosed()"></core-context-menu-item>
 | 
			
		||||
            <core-context-menu-item *ngIf="loaded && showSortFilter" [priority]="900"
 | 
			
		||||
                content="{{('core.sortby' | translate) + ' ' + ('addon.block_myoverview.title' | translate)}}"
 | 
			
		||||
                (action)="switchSort('fullname')" [iconAction]="sort == 'fullname' ? 'far-dot-circle' : 'far-circle'">
 | 
			
		||||
            </core-context-menu-item>
 | 
			
		||||
            <core-context-menu-item *ngIf="loaded && showSortFilter && showSortByShortName" [priority]="800"
 | 
			
		||||
                content="{{('core.sortby' | translate) + ' ' + ('addon.block_myoverview.shortname' | translate)}}"
 | 
			
		||||
                (action)="switchSort('shortname')" [iconAction]="sort == 'shortname' ? 'far-dot-circle' : 'far-circle'">
 | 
			
		||||
            </core-context-menu-item>
 | 
			
		||||
            <core-context-menu-item *ngIf="loaded && showSortFilter" [priority]="700"
 | 
			
		||||
                content="{{('core.sortby' | translate) + ' ' + ('addon.block_myoverview.lastaccessed' | translate)}}"
 | 
			
		||||
                (action)="switchSort('lastaccess')" [iconAction]="sort == 'lastaccess' ? 'far-dot-circle' : 'far-circle'">
 | 
			
		||||
            </core-context-menu-item>
 | 
			
		||||
        </core-context-menu>
 | 
			
		||||
    </div>
 | 
			
		||||
</ion-item-divider>
 | 
			
		||||
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin">
 | 
			
		||||
    <div class="safe-padding-horizontal" [hidden]="showFilter || !showSelectorFilter">
 | 
			
		||||
        <!-- "Time" selector. -->
 | 
			
		||||
        <core-combobox [label]="'core.show' | translate" [selection]="selectedFilter" (onChange)="selectedChanged($event)">
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="allincludinghidden" *ngIf="showFilters.allincludinghidden != 'hidden'">
 | 
			
		||||
                {{ 'addon.block_myoverview.allincludinghidden' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="all" *ngIf="showFilters.all != 'hidden'">
 | 
			
		||||
                {{ 'addon.block_myoverview.all' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="inprogress" *ngIf="showFilters.inprogress != 'hidden'"
 | 
			
		||||
                [disabled]="showFilters.inprogress == 'disabled'">
 | 
			
		||||
                {{ 'addon.block_myoverview.inprogress' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="future" *ngIf="showFilters.future != 'hidden'"
 | 
			
		||||
                [disabled]="showFilters.future == 'disabled'">
 | 
			
		||||
                {{ 'addon.block_myoverview.future' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="past" *ngIf="showFilters.past != 'hidden'"
 | 
			
		||||
                [disabled]="showFilters.past == 'disabled'">
 | 
			
		||||
                {{ 'addon.block_myoverview.past' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ng-container *ngIf="showFilters.custom != 'hidden'">
 | 
			
		||||
                <ng-container *ngFor="let customOption of customFilter; let index = index">
 | 
			
		||||
                    <ion-select-option class="ion-text-wrap" value="custom-{{index}}">{{ customOption.name }}</ion-select-option>
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
 | 
			
		||||
    <ion-row class="ion-hide-md-up addon-block-myoverview-filter" *ngIf="hasCourses">
 | 
			
		||||
        <ion-col>
 | 
			
		||||
            <!-- Filter courses. -->
 | 
			
		||||
            <ion-searchbar [(ngModel)]="textFilter" (ionInput)="filterTextChanged($event.target)"
 | 
			
		||||
                (ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate">
 | 
			
		||||
            </ion-searchbar>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
    </ion-row>
 | 
			
		||||
    <ion-row class="ion-justify-content-between ion-align-items-center addon-block-myoverview-filter" *ngIf="hasCourses">
 | 
			
		||||
        <ion-col size="auto" *ngIf="filters.enabled">
 | 
			
		||||
            <core-combobox [label]="'core.courses.filtermycourses' | translate" [selection]="filters.timeFilterSelected"
 | 
			
		||||
                (onChange)="filterOptionsChanged($event)">
 | 
			
		||||
                <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="allincludinghidden"
 | 
			
		||||
                    *ngIf="filters.show.allincludinghidden">
 | 
			
		||||
                    {{ 'addon.block_myoverview.allincludinghidden' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="all" *ngIf="filters.show.all">
 | 
			
		||||
                    {{ 'addon.block_myoverview.all' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap"
 | 
			
		||||
                    [class.core-select-option-border-bottom]="!filters.show.past && !filters.show.future" value="inprogress"
 | 
			
		||||
                    *ngIf="filters.show.inprogress">
 | 
			
		||||
                    {{ 'addon.block_myoverview.inprogress' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" [class.core-select-option-border-bottom]="!filters.show.past" value="future"
 | 
			
		||||
                    *ngIf="filters.show.future">
 | 
			
		||||
                    {{ 'addon.block_myoverview.future' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="past" *ngIf="filters.show.past">
 | 
			
		||||
                    {{ 'addon.block_myoverview.past' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ng-container *ngIf="filters.show.custom">
 | 
			
		||||
                    <ng-container *ngFor="let customOption of filters.customFilters; let index = index; let last = last">
 | 
			
		||||
                        <ion-select-option class="ion-text-wrap" value="custom-{{index}}" [class.core-select-option-border-bottom]="last">
 | 
			
		||||
                            {{ customOption.name }}</ion-select-option>
 | 
			
		||||
                    </ng-container>
 | 
			
		||||
                </ng-container>
 | 
			
		||||
            </ng-container>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="favourite" *ngIf="showFilters.favourite != 'hidden'"
 | 
			
		||||
                [disabled]="showFilters.favourite == 'disabled'">
 | 
			
		||||
                {{ 'addon.block_myoverview.favourites' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="hidden" *ngIf="showFilters.hidden != 'hidden'"
 | 
			
		||||
                [disabled]="showFilters.hidden == 'disabled'">
 | 
			
		||||
                {{ 'addon.block_myoverview.hiddencourses' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
        </core-combobox>
 | 
			
		||||
    </div>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="favourite" *ngIf="filters.show.favourite">
 | 
			
		||||
                    {{ 'addon.block_myoverview.favourites' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="hidden" *ngIf="filters.show.hidden">
 | 
			
		||||
                    {{ 'addon.block_myoverview.hiddencourses' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
            </core-combobox>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
        <ion-col>
 | 
			
		||||
            <!-- Filter courses. -->
 | 
			
		||||
            <ion-searchbar class="ion-hide-md-down" [(ngModel)]="textFilter" (ionInput)="filterTextChanged($event.target)"
 | 
			
		||||
                (ionCancel)="filterTextChanged($event.target)" [placeholder]="'core.courses.filtermycourses' | translate">
 | 
			
		||||
            </ion-searchbar>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
        <ion-col size="auto" *ngIf="sort.enabled">
 | 
			
		||||
            <core-combobox [label]="'core.sortby' | translate" [selection]="sort.selected" (onChange)="sortCourses($event)"
 | 
			
		||||
                icon="fas-sort-amount-down-alt">
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="fullname">
 | 
			
		||||
                    {{'addon.block_myoverview.title' | translate}}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="shortname" *ngIf="sort.shortnameEnabled">
 | 
			
		||||
                    {{'addon.block_myoverview.shortname' | translate}}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="lastaccess">
 | 
			
		||||
                    {{'addon.block_myoverview.lastaccessed' | translate}}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
            </core-combobox>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
        <ion-col size="auto" *ngIf="isLayoutSwitcherAvailable">
 | 
			
		||||
            <ion-button *ngIf="layout == 'card'" fill="outline" (click)="toggleLayout('list')"
 | 
			
		||||
                [attr.aria-label]="'addon.block_myoverview.list' | translate">
 | 
			
		||||
                <ion-icon slot="icon-only" name="fas-list" aria-hidden="true"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
            <ion-button *ngIf="layout == 'list'" fill="outline" (click)="toggleLayout('card')"
 | 
			
		||||
                [attr.aria-label]="'addon.block_myoverview.card' | translate">
 | 
			
		||||
                <ion-icon slot="icon-only" name="fas-th" aria-hidden="true"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
    </ion-row>
 | 
			
		||||
 | 
			
		||||
    <!-- Filter courses. -->
 | 
			
		||||
    <ion-searchbar #searchbar *ngIf="showFilter" [(ngModel)]="courses.filter" (ionInput)="filterChanged($event)"
 | 
			
		||||
        (ionCancel)="filterChanged($event)" [placeholder]="'core.courses.filtermycourses' | translate">
 | 
			
		||||
    </ion-searchbar>
 | 
			
		||||
 | 
			
		||||
    <core-empty-box *ngIf="filteredCourses.length == 0" image="assets/img/icons/courses.svg"
 | 
			
		||||
        [message]="'addon.block_myoverview.nocourses' | translate" inline="true">
 | 
			
		||||
    <core-empty-box *ngIf="filteredCourses.length == 0" image="assets/img/icons/courses.svg">
 | 
			
		||||
        <p *ngIf="hasCourses" class="item-heading">
 | 
			
		||||
            {{'addon.block_myoverview.noresult' | translate}}
 | 
			
		||||
        </p>
 | 
			
		||||
        <p *ngIf="!hasCourses" class="item-heading">
 | 
			
		||||
            {{'addon.block_myoverview.nocoursesenrolled' | translate}}
 | 
			
		||||
        </p>
 | 
			
		||||
        <ng-container *ngIf="searchEnabled">
 | 
			
		||||
            <p *ngIf="hasCourses" class="subdued">
 | 
			
		||||
                {{'addon.block_myoverview.noresultdescription' | translate}}
 | 
			
		||||
            </p>
 | 
			
		||||
            <p *ngIf="!hasCourses" class="subdued">
 | 
			
		||||
                {{'addon.block_myoverview.nocoursesenrolleddescription' | translate}}
 | 
			
		||||
            </p>
 | 
			
		||||
            <ion-button (click)="openSearch()" fill="outline">
 | 
			
		||||
                <ion-icon name="fas-search" slot="start" aria-hidden="true">
 | 
			
		||||
                </ion-icon>
 | 
			
		||||
                {{'addon.block_myoverview.browseallcourses' | translate}}
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
    </core-empty-box>
 | 
			
		||||
 | 
			
		||||
    <!-- List of courses. -->
 | 
			
		||||
    <div class="safe-area-page">
 | 
			
		||||
        <ion-grid class="ion-no-padding">
 | 
			
		||||
    <div class="safe-area-padding" *ngIf="hasCourses">
 | 
			
		||||
        <ion-grid class="ion-no-padding" [class.core-no-grid]="layout != 'card'" [class.list-item-limited-width]="layout != 'card'">
 | 
			
		||||
            <ion-row class="ion-no-padding">
 | 
			
		||||
                <ion-col *ngFor="let course of filteredCourses" class="ion-no-padding"
 | 
			
		||||
                    size="12" size-sm="6" size-md="6" size-lg="4" size-xl="3">
 | 
			
		||||
                    <core-courses-course-progress [course]="course" class="core-courseoverview" showAll="true"
 | 
			
		||||
                        [showDownload]="downloadCourseEnabled && downloadEnabled">
 | 
			
		||||
                    </core-courses-course-progress>
 | 
			
		||||
                <ion-col *ngFor="let course of filteredCourses" class="ion-no-padding" size="12" size-sm="6" size-md="6" size-lg="4"
 | 
			
		||||
                    size-xl="3">
 | 
			
		||||
                    <core-courses-course-list-item [course]="course" class="core-courseoverview" [showDownload]="downloadCourseEnabled"
 | 
			
		||||
                        [layout]="layout">
 | 
			
		||||
                    </core-courses-course-list-item>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
            </ion-row>
 | 
			
		||||
        </ion-grid>
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,48 @@
 | 
			
		||||
:host {
 | 
			
		||||
    ion-row.addon-block-myoverview-filter {
 | 
			
		||||
        margin: 8px;
 | 
			
		||||
        padding: 0;
 | 
			
		||||
 | 
			
		||||
        ion-col {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            margin-right: 2px;
 | 
			
		||||
            margin-left: 2px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ion-button,
 | 
			
		||||
        core-combobox ::ng-deep ion-button {
 | 
			
		||||
            --border-width: 0;
 | 
			
		||||
            --a11y-min-target-size: 40px;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
 | 
			
		||||
            .select-icon {
 | 
			
		||||
                display: none;
 | 
			
		||||
            }
 | 
			
		||||
            ion-icon {
 | 
			
		||||
                font-size: 20px;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        core-combobox ::ng-deep ion-select {
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            --a11y-min-target-size: 40px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
       ion-searchbar {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            --height: 40px;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    core-empty-box {
 | 
			
		||||
        .item-heading {
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
            margin-bottom: 0;
 | 
			
		||||
            font-size: 16px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .subdued {
 | 
			
		||||
            color: var(--subdued-text-color);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -1,12 +1,18 @@
 | 
			
		||||
{
 | 
			
		||||
    "all": "All (except removed from view)",
 | 
			
		||||
    "allincludinghidden": "All",
 | 
			
		||||
    "all": "All",
 | 
			
		||||
    "allincludinghidden": "All (including archived)",
 | 
			
		||||
    "browseallcourses": "Browse all courses",
 | 
			
		||||
    "card": "Card",
 | 
			
		||||
    "favourites": "Starred",
 | 
			
		||||
    "future": "Future",
 | 
			
		||||
    "hiddencourses": "Removed from view",
 | 
			
		||||
    "hiddencourses": "Archived",
 | 
			
		||||
    "inprogress": "In progress",
 | 
			
		||||
    "lastaccessed": "Last accessed",
 | 
			
		||||
    "nocourses": "No courses",
 | 
			
		||||
    "list": "List",
 | 
			
		||||
    "nocoursesenrolled": "You're not enrolled in any courses yet.",
 | 
			
		||||
    "nocoursesenrolleddescription": "Browse all available courses below and start learning.",
 | 
			
		||||
    "noresult": "Your search didn't match any courses.",
 | 
			
		||||
    "noresultdescription": "Try adjusting your filters or browse all courses below.",
 | 
			
		||||
    "past": "Past",
 | 
			
		||||
    "pluginname": "Course overview",
 | 
			
		||||
    "shortname": "Short name",
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
@import "~theme/globals";
 | 
			
		||||
 | 
			
		||||
:host .core-block-content ::ng-deep {
 | 
			
		||||
    max-height: 200px;
 | 
			
		||||
    overflow-y: auto;
 | 
			
		||||
@ -17,23 +19,22 @@
 | 
			
		||||
            list-style-type: none;
 | 
			
		||||
 | 
			
		||||
            .user {
 | 
			
		||||
                float: left;
 | 
			
		||||
                @include float(start);
 | 
			
		||||
                position: relative;
 | 
			
		||||
                padding-bottom: 16px;
 | 
			
		||||
 | 
			
		||||
                .core-adapted-img-container {
 | 
			
		||||
                    display: inline;
 | 
			
		||||
                    margin-left: 0;
 | 
			
		||||
                    margin-right: 8px;
 | 
			
		||||
                    @include margin-horizontal(0px, 8px);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .userpicture {
 | 
			
		||||
                    vertical-align: text-bottom;
 | 
			
		||||
                    border-radius: 50%;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .message {
 | 
			
		||||
                float: right;
 | 
			
		||||
                @include float(end);
 | 
			
		||||
                margin-top: 3px;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@ -48,20 +49,3 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host-context([dir=rtl]) .core-block-content ::ng-deep {
 | 
			
		||||
    .list li.listentry {
 | 
			
		||||
        .user {
 | 
			
		||||
            float: right;
 | 
			
		||||
 | 
			
		||||
            .core-adapted-img-container {
 | 
			
		||||
                margin-left: 8px;
 | 
			
		||||
                margin-right: 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .message {
 | 
			
		||||
            float: left;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreBlockHandlerData } from '@features/block/services/block-delegate';
 | 
			
		||||
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
 | 
			
		||||
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
 | 
			
		||||
import { AddonPrivateFilesMainMenuHandlerService } from '@/addons/privatefiles/services/handlers/mainmenu';
 | 
			
		||||
import { AddonPrivateFilesUserHandlerService } from '@addons/privatefiles/services/handlers/user';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@ -39,7 +39,7 @@ export class AddonBlockPrivateFilesHandlerService extends CoreBlockBaseHandler {
 | 
			
		||||
            title: 'addon.block_privatefiles.pluginname',
 | 
			
		||||
            class: 'addon-block-private-files',
 | 
			
		||||
            component: CoreBlockOnlyTitleComponent,
 | 
			
		||||
            link: AddonPrivateFilesMainMenuHandlerService.PAGE_NAME,
 | 
			
		||||
            link: AddonPrivateFilesUserHandlerService.PAGE_NAME,
 | 
			
		||||
            linkParams: { root: 'my' },
 | 
			
		||||
            navOptions: {
 | 
			
		||||
                preferCurrentTab: false,
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
@import "~theme/globals";
 | 
			
		||||
 | 
			
		||||
:host .core-block-content ::ng-deep {
 | 
			
		||||
    .activitydate, .activityhead {
 | 
			
		||||
        text-align: center;
 | 
			
		||||
@ -12,14 +14,8 @@
 | 
			
		||||
            margin-bottom: 1em;
 | 
			
		||||
 | 
			
		||||
            .head .date {
 | 
			
		||||
                float: right;
 | 
			
		||||
                @include float(end);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host-context([dir=rtl]) .core-block-content ::ng-deep {
 | 
			
		||||
    .unlist li .head .date {
 | 
			
		||||
        float: left;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,40 +1,25 @@
 | 
			
		||||
<ion-item-divider sticky="true">
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2>
 | 
			
		||||
        <h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
    <div slot="end" class="flex-row">
 | 
			
		||||
        <div *ngIf="downloadCoursesEnabled && downloadEnabled && courses && courses.length > 1" class="core-button-spinner">
 | 
			
		||||
            <ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark"
 | 
			
		||||
                (click)="prefetchCourses()" [attr.aria-label]="'core.courses.downloadcourses' | translate">
 | 
			
		||||
                <ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
            <ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge"
 | 
			
		||||
                role="progressbar" [attr.aria-valuemax]="prefetchCoursesData.total"
 | 
			
		||||
                [attr.aria-valuenow]="prefetchCoursesData.count" [attr.aria-valuetext]="prefetchCoursesData.badgeA11yText">
 | 
			
		||||
                {{prefetchCoursesData.badge}}
 | 
			
		||||
            </ion-badge>
 | 
			
		||||
            <ion-spinner *ngIf="!prefetchCoursesData.icon || prefetchCoursesData.loading"
 | 
			
		||||
                [attr.aria-label]="'core.loading' | translate"></ion-spinner>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
 | 
			
		||||
        </core-horizontal-scroll-controls>
 | 
			
		||||
    </div>
 | 
			
		||||
</ion-item-divider>
 | 
			
		||||
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page margin">
 | 
			
		||||
    <core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" inline="true"
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
    <core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
 | 
			
		||||
        [message]="'addon.block_recentlyaccessedcourses.nocourses' | translate"></core-empty-box>
 | 
			
		||||
    <!-- List of courses. -->
 | 
			
		||||
    <div
 | 
			
		||||
        [id]="scrollElementId"
 | 
			
		||||
        class="core-horizontal-scroll"
 | 
			
		||||
        (scroll)="scrollControls.updateScrollPosition()"
 | 
			
		||||
    >
 | 
			
		||||
    <div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
 | 
			
		||||
        (scroll)="scrollControls.updateScrollPosition()">
 | 
			
		||||
        <div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
 | 
			
		||||
            <div class="safe-area-pseudo-padding-start"></div>
 | 
			
		||||
            <ng-container *ngFor="let course of courses">
 | 
			
		||||
                <core-courses-course-progress [course]="course" class="core-recentlyaccessedcourses"
 | 
			
		||||
                    [showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
 | 
			
		||||
                <core-courses-course-list-item [course]="course" class="core-recentlyaccessedcourses" layout="summarycard">
 | 
			
		||||
                </core-courses-course-list-item>
 | 
			
		||||
            </ng-container>
 | 
			
		||||
            <div class="safe-area-pseudo-padding-end"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</core-loading>
 | 
			
		||||
 | 
			
		||||
@ -12,17 +12,25 @@
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, OnDestroy, Input, OnChanges, SimpleChange } from '@angular/core';
 | 
			
		||||
import { Component, OnInit, OnDestroy } from '@angular/core';
 | 
			
		||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses';
 | 
			
		||||
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
 | 
			
		||||
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper';
 | 
			
		||||
import {
 | 
			
		||||
    CoreCoursesProvider,
 | 
			
		||||
    CoreCoursesMyCoursesUpdatedEventData,
 | 
			
		||||
    CoreCourses,
 | 
			
		||||
    CoreCourseSummaryData,
 | 
			
		||||
} from '@features/courses/services/courses';
 | 
			
		||||
import {
 | 
			
		||||
    CoreCourseSearchedDataWithExtraInfoAndOptions,
 | 
			
		||||
    CoreCoursesHelper,
 | 
			
		||||
    CoreEnrolledCourseDataWithOptions,
 | 
			
		||||
} from '@features/courses/services/courses-helper';
 | 
			
		||||
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
 | 
			
		||||
import { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion';
 | 
			
		||||
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
 | 
			
		||||
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreSite } from '@classes/site';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render a recent courses block.
 | 
			
		||||
@ -31,36 +39,25 @@ import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
    selector: 'addon-block-recentlyaccessedcourses',
 | 
			
		||||
    templateUrl: 'addon-block-recentlyaccessedcourses.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy {
 | 
			
		||||
export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    @Input() downloadEnabled = false;
 | 
			
		||||
    courses: AddonBlockRecentlyAccessedCourse[] = [];
 | 
			
		||||
 | 
			
		||||
    courses: CoreEnrolledCourseDataWithOptions [] = [];
 | 
			
		||||
    prefetchCoursesData: CorePrefetchStatusInfo = {
 | 
			
		||||
        icon: '',
 | 
			
		||||
        statusTranslatable: 'core.loading',
 | 
			
		||||
        status: '',
 | 
			
		||||
        loading: true,
 | 
			
		||||
        badge: '',
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    downloadCourseEnabled = false;
 | 
			
		||||
    downloadCoursesEnabled = false;
 | 
			
		||||
    scrollElementId!: string;
 | 
			
		||||
 | 
			
		||||
    protected prefetchIconsInitialized = false;
 | 
			
		||||
    protected site!: CoreSite;
 | 
			
		||||
    protected isDestroyed = false;
 | 
			
		||||
    protected coursesObserver?: CoreEventObserver;
 | 
			
		||||
    protected updateSiteObserver?: CoreEventObserver;
 | 
			
		||||
    protected courseIds = [];
 | 
			
		||||
    protected fetchContentDefaultError = 'Error getting recent courses data.';
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonBlockRecentlyAccessedCoursesComponent');
 | 
			
		||||
 | 
			
		||||
        this.site = CoreSites.getRequiredCurrentSite();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        // Generate unique id for scroll element.
 | 
			
		||||
@ -68,175 +65,149 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom
 | 
			
		||||
 | 
			
		||||
        this.scrollElementId = `addon-block-recentlyaccessedcourses-scroll-${scrollId}`;
 | 
			
		||||
 | 
			
		||||
        // Refresh the enabled flags if enabled.
 | 
			
		||||
        this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
 | 
			
		||||
        this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
 | 
			
		||||
 | 
			
		||||
        // Refresh the enabled flags if site is updated.
 | 
			
		||||
        this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
 | 
			
		||||
            this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
 | 
			
		||||
            this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
 | 
			
		||||
 | 
			
		||||
        }, CoreSites.getCurrentSiteId());
 | 
			
		||||
 | 
			
		||||
        this.coursesObserver = CoreEvents.on(
 | 
			
		||||
            CoreCoursesProvider.EVENT_MY_COURSES_UPDATED,
 | 
			
		||||
            (data) => {
 | 
			
		||||
 | 
			
		||||
                if (this.shouldRefreshOnUpdatedEvent(data)) {
 | 
			
		||||
                    this.refreshCourseList();
 | 
			
		||||
                }
 | 
			
		||||
                this.refreshCourseList(data);
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
            CoreSites.getCurrentSiteId(),
 | 
			
		||||
            this.site.getId(),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        super.ngOnInit();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Detect changes on input properties.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnChanges(changes: {[name: string]: SimpleChange}): void {
 | 
			
		||||
        if (changes.downloadEnabled && !changes.downloadEnabled.previousValue && this.downloadEnabled && this.loaded) {
 | 
			
		||||
            // Download all courses is enabled now, initialize it.
 | 
			
		||||
            this.initPrefetchCoursesIcons();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Perform the invalidate content function.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Resolved when done.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    protected async invalidateContent(): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
        const courseIds = this.courses.map((course) => course.id);
 | 
			
		||||
 | 
			
		||||
        promises.push(CoreCourses.invalidateUserCourses().finally(() =>
 | 
			
		||||
            // Invalidate course completion data.
 | 
			
		||||
            CoreUtils.allPromises(this.courseIds.map((courseId) =>
 | 
			
		||||
                AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
 | 
			
		||||
 | 
			
		||||
        promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
 | 
			
		||||
        if (this.courseIds.length > 0) {
 | 
			
		||||
            promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIds.join(',')));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.allPromises(promises).finally(() => {
 | 
			
		||||
            this.prefetchIconsInitialized = false;
 | 
			
		||||
        });
 | 
			
		||||
        await this.invalidateCourses(courseIds);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fetch the courses for recent courses.
 | 
			
		||||
     * Invalidate list of courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async invalidateCourseList(): Promise<void> {
 | 
			
		||||
        return this.site.isVersionGreaterEqualThan('3.8')
 | 
			
		||||
            ? CoreCourses.invalidateRecentCourses()
 | 
			
		||||
            : CoreCourses.invalidateUserCourses();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Helper function to invalidate only selected courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @param courseIds Course Id array.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async invalidateCourses(courseIds: number[]): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
        // Invalidate course completion data.
 | 
			
		||||
        promises.push(this.invalidateCourseList().finally(() =>
 | 
			
		||||
            CoreUtils.allPromises(courseIds.map((courseId) =>
 | 
			
		||||
                AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
 | 
			
		||||
 | 
			
		||||
        if (courseIds.length  == 1) {
 | 
			
		||||
            promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(courseIds[0]));
 | 
			
		||||
        } else {
 | 
			
		||||
            promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
 | 
			
		||||
        }
 | 
			
		||||
        if (courseIds.length > 0) {
 | 
			
		||||
            promises.push(CoreCourses.invalidateCoursesByField('ids', courseIds.join(',')));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.allPromises(promises);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchContent(): Promise<void> {
 | 
			
		||||
        const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories &&
 | 
			
		||||
            this.block.configsRecord.displaycategories.value == '1';
 | 
			
		||||
 | 
			
		||||
        this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('lastaccess', 10, undefined, showCategories);
 | 
			
		||||
        this.initPrefetchCoursesIcons();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Refresh the list of courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async refreshCourseList(): Promise<void> {
 | 
			
		||||
        CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED);
 | 
			
		||||
 | 
			
		||||
        let recentCourses: CoreCourseSummaryData[] = [];
 | 
			
		||||
        try {
 | 
			
		||||
            await CoreCourses.invalidateUserCourses();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            // Ignore errors.
 | 
			
		||||
        }
 | 
			
		||||
            recentCourses = await CoreCourses.getRecentCourses();
 | 
			
		||||
        } catch {
 | 
			
		||||
            // WS is failing on 3.7 and bellow, use a fallback.
 | 
			
		||||
            this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('lastaccess', 10, undefined, showCategories);
 | 
			
		||||
 | 
			
		||||
        await this.loadContent(true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize the prefetch icon for selected courses.
 | 
			
		||||
     */
 | 
			
		||||
    protected async initPrefetchCoursesIcons(): Promise<void> {
 | 
			
		||||
        if (this.prefetchIconsInitialized || !this.downloadEnabled) {
 | 
			
		||||
            // Already initialized.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.prefetchIconsInitialized = true;
 | 
			
		||||
        const courseIds = recentCourses.map((course) => course.id);
 | 
			
		||||
 | 
			
		||||
        this.prefetchCoursesData = await CoreCourseHelper.initPrefetchCoursesIcons(this.courses, this.prefetchCoursesData);
 | 
			
		||||
        // Get the courses using getCoursesByField to get more info about each course.
 | 
			
		||||
        const courses = await CoreCourses.getCoursesByField('ids', courseIds.join(','));
 | 
			
		||||
 | 
			
		||||
        this.courses = recentCourses.map((recentCourse) => {
 | 
			
		||||
            const course = courses.find((course) => recentCourse.id == course.id);
 | 
			
		||||
 | 
			
		||||
            return Object.assign(recentCourse, course);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Get course options and extra info.
 | 
			
		||||
        const options = await CoreCourses.getCoursesAdminAndNavOptions(courseIds);
 | 
			
		||||
        this.courses.forEach((course) => {
 | 
			
		||||
            course.navOptions = options.navOptions[course.id];
 | 
			
		||||
            course.admOptions = options.admOptions[course.id];
 | 
			
		||||
 | 
			
		||||
            if (!showCategories) {
 | 
			
		||||
                course.categoryname = '';
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether list should be refreshed based on a EVENT_MY_COURSES_UPDATED event.
 | 
			
		||||
     * Refresh course list based on a EVENT_MY_COURSES_UPDATED event.
 | 
			
		||||
     *
 | 
			
		||||
     * @param data Event data.
 | 
			
		||||
     * @return Whether to refresh.
 | 
			
		||||
     */
 | 
			
		||||
    protected shouldRefreshOnUpdatedEvent(data: CoreCoursesMyCoursesUpdatedEventData): boolean {
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_ENROL) {
 | 
			
		||||
            // Always update if user enrolled in a course.
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId() &&
 | 
			
		||||
                this.courses[0] && data.courseId != this.courses[0].id) {
 | 
			
		||||
            // Update list if user viewed a course that isn't the most recent one and isn't site home.
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED && data.state == CoreCoursesProvider.STATE_FAVOURITE &&
 | 
			
		||||
                data.courseId && this.hasCourse(data.courseId)) {
 | 
			
		||||
            // Update list if a visible course is now favourite or unfavourite.
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a certain course is in the list of courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @param courseId Course ID to search.
 | 
			
		||||
     * @return Whether it's in the list.
 | 
			
		||||
     */
 | 
			
		||||
    protected hasCourse(courseId: number): boolean {
 | 
			
		||||
        if (!this.courses) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return !!this.courses.find((course) => course.id == courseId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetch all the shown courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async prefetchCourses(): Promise<void> {
 | 
			
		||||
        const initialIcon = this.prefetchCoursesData.icon;
 | 
			
		||||
    protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> {
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_ENROL) {
 | 
			
		||||
            // Always update if user enrolled in a course.
 | 
			
		||||
            return await this.refreshContent();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await CoreCourseHelper.prefetchCourses(this.courses, this.prefetchCoursesData);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            if (!this.isDestroyed) {
 | 
			
		||||
                CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
 | 
			
		||||
                this.prefetchCoursesData.icon = initialIcon;
 | 
			
		||||
        const courseIndex = this.courses.findIndex((course) => course.id == data.courseId);
 | 
			
		||||
        const course = this.courses[courseIndex];
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId()) {
 | 
			
		||||
            if (!course) {
 | 
			
		||||
                // Not found, use WS update.
 | 
			
		||||
                return await this.refreshContent();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Place at the begining.
 | 
			
		||||
            this.courses.splice(courseIndex, 1);
 | 
			
		||||
            this.courses.unshift(course);
 | 
			
		||||
 | 
			
		||||
            await this.invalidateCourseList();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED &&
 | 
			
		||||
            data.state == CoreCoursesProvider.STATE_FAVOURITE && course) {
 | 
			
		||||
            course.isfavourite = !!data.value;
 | 
			
		||||
            await this.invalidateCourseList();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being destroyed.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.isDestroyed = true;
 | 
			
		||||
        this.coursesObserver?.off();
 | 
			
		||||
        this.updateSiteObserver?.off();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AddonBlockRecentlyAccessedCourse =
 | 
			
		||||
    (Omit<CoreCourseSummaryData, 'visible'> & CoreCourseSearchedDataWithExtraInfoAndOptions) |
 | 
			
		||||
    (CoreEnrolledCourseDataWithOptions & {
 | 
			
		||||
        categoryname?: string; // Category name,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -1,23 +1,23 @@
 | 
			
		||||
<ion-item-divider sticky="true">
 | 
			
		||||
    <ion-label><h2>{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}</h2></ion-label>
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h2>{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}</h2>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
    <div slot="end">
 | 
			
		||||
        <core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
 | 
			
		||||
        </core-horizontal-scroll-controls>
 | 
			
		||||
    </div>
 | 
			
		||||
</ion-item-divider>
 | 
			
		||||
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page">
 | 
			
		||||
    <div
 | 
			
		||||
        [id]="scrollElementId"
 | 
			
		||||
        [hidden]="!items || items.length === 0"
 | 
			
		||||
        class="core-horizontal-scroll"
 | 
			
		||||
        (scroll)="scrollControls.updateScrollPosition()"
 | 
			
		||||
    >
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
    <div [id]="scrollElementId" [hidden]="!items || items.length === 0" class="core-horizontal-scroll"
 | 
			
		||||
        (scroll)="scrollControls.updateScrollPosition()">
 | 
			
		||||
        <div *ngIf="items" (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
 | 
			
		||||
            <div *ngFor="let item of items">
 | 
			
		||||
            <div class="safe-area-pseudo-padding-start"></div>
 | 
			
		||||
            <div *ngFor="let item of items" class="core-horizontal-scroll-item">
 | 
			
		||||
                <ion-card>
 | 
			
		||||
                    <ion-item class="core-course-module-handler item-media ion-text-wrap" detail="false" (click)="action($event, item)"
 | 
			
		||||
                        button>
 | 
			
		||||
                        <img slot="start" [src]="item.iconUrl" alt="" role="presentation" *ngIf="item.iconUrl" class="core-module-icon">
 | 
			
		||||
                    <ion-item class="core-course-module-handler ion-text-wrap" detail="false" (click)="action($event, item)" button>
 | 
			
		||||
                        <core-mod-icon slot="start" *ngIf="item.iconUrl" [modicon]="item.iconUrl" [modname]="item.modname"
 | 
			
		||||
                            [componentId]="item.cmid" [showAlt]="false">
 | 
			
		||||
                        </core-mod-icon>
 | 
			
		||||
                        <ion-label>
 | 
			
		||||
                            <!-- Add the icon title so accessibility tools read it. -->
 | 
			
		||||
                            <span class="sr-only" *ngIf="item.iconTitle">{{ item.iconTitle }}</span>
 | 
			
		||||
@ -33,10 +33,11 @@
 | 
			
		||||
                    </ion-item>
 | 
			
		||||
                </ion-card>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="safe-area-pseudo-padding-end"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <core-empty-box *ngIf="items.length <= 0" image="assets/img/icons/activities.svg" inline="true"
 | 
			
		||||
    <core-empty-box *ngIf="items.length <= 0" image="assets/img/icons/activities.svg"
 | 
			
		||||
        [message]="'addon.block_recentlyaccesseditems.noitems' | translate"></core-empty-box>
 | 
			
		||||
 | 
			
		||||
</core-loading>
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,23 @@
 | 
			
		||||
@import "~theme/globals";
 | 
			
		||||
 | 
			
		||||
:host {
 | 
			
		||||
    .core-horizontal-scroll > div > div {
 | 
			
		||||
    .core-horizontal-scroll div.core-horizontal-scroll-item {
 | 
			
		||||
        @include horizontal_scroll_item(80%, 250px, 300px);
 | 
			
		||||
        ion-card {
 | 
			
		||||
            height: auto;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .ion-text-wrap ion-label {
 | 
			
		||||
            .item-heading, h2, p {
 | 
			
		||||
                white-space: nowrap;
 | 
			
		||||
                overflow: hidden;
 | 
			
		||||
                text-overflow: ellipsis;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .core-course-module-handler {
 | 
			
		||||
        --inner-border-width: 0;
 | 
			
		||||
        --inner-border-width: 0px;
 | 
			
		||||
    }
 | 
			
		||||
    core-loading {
 | 
			
		||||
        --loading-inline-min-height: 102px;
 | 
			
		||||
 | 
			
		||||
@ -49,17 +49,41 @@ export class AddonBlockRecentlyAccessedItemsProvider {
 | 
			
		||||
            cacheKey: this.getRecentItemsCacheKey(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const items: AddonBlockRecentlyAccessedItemsItem[] =
 | 
			
		||||
        let items: AddonBlockRecentlyAccessedItemsItem[] =
 | 
			
		||||
            await site.read('block_recentlyaccesseditems_get_recent_items', undefined, preSets);
 | 
			
		||||
 | 
			
		||||
        return items.map((item) => {
 | 
			
		||||
        const cmIds: number[] = [];
 | 
			
		||||
 | 
			
		||||
        items = await Promise.all(items.map(async (item) => {
 | 
			
		||||
            const modicon = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'src');
 | 
			
		||||
 | 
			
		||||
            item.iconUrl = CoreCourse.getModuleIconSrc(item.modname, modicon || undefined);
 | 
			
		||||
            item.iconUrl = await CoreCourse.getModuleIconSrc(item.modname, modicon || undefined);
 | 
			
		||||
            item.iconTitle = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'title');
 | 
			
		||||
            cmIds.push(item.cmid);
 | 
			
		||||
 | 
			
		||||
            return item;
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        // Check if the viewed module should be updated for each activity.
 | 
			
		||||
        const lastViewedMap = await CoreCourse.getCertainModulesViewed(cmIds, site.getId());
 | 
			
		||||
 | 
			
		||||
        items.forEach((recentItem) => {
 | 
			
		||||
            const timeAccess = recentItem.timeaccess * 1000;
 | 
			
		||||
            const lastViewed = lastViewedMap[recentItem.cmid];
 | 
			
		||||
 | 
			
		||||
            if (lastViewed && lastViewed.timeaccess >= timeAccess) {
 | 
			
		||||
                return; // No need to update.
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Update access.
 | 
			
		||||
            CoreCourse.storeModuleViewed(recentItem.courseid, recentItem.cmid, {
 | 
			
		||||
                timeaccess: recentItem.timeaccess * 1000,
 | 
			
		||||
                sectionId: lastViewed && lastViewed.sectionId,
 | 
			
		||||
                siteId: site.getId(),
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return items;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@
 | 
			
		||||
        -webkit-padding-start: 0;
 | 
			
		||||
 | 
			
		||||
        li {
 | 
			
		||||
            border-top: 1px solid var(--gray);
 | 
			
		||||
            border-top: 1px solid var(--stroke);
 | 
			
		||||
            padding: 5px;
 | 
			
		||||
            padding-bottom: 8px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -1,18 +1,17 @@
 | 
			
		||||
<ion-item-divider sticky="true">
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h2>{{ 'addon.block_sitemainmenu.pluginname' | translate }}</h2>
 | 
			
		||||
        <h2>{{ 'addon.block_sitemainmenu.pluginname' | translate }}</h2>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item-divider>
 | 
			
		||||
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin">
 | 
			
		||||
    <ng-container *ngIf="mainMenuBlock">
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
    <ion-list *ngIf="mainMenuBlock" class="core-course-module-list-wrapper">
 | 
			
		||||
        <ion-item class="ion-text-wrap" *ngIf="mainMenuBlock.summary">
 | 
			
		||||
            <ion-label>
 | 
			
		||||
                <core-format-text [text]="mainMenuBlock.summary" [component]="component" [componentId]="siteHomeId"
 | 
			
		||||
                    contextLevel="course" [contextInstanceId]="siteHomeId"></core-format-text>
 | 
			
		||||
                <core-format-text [text]="mainMenuBlock.summary" [component]="component" [componentId]="siteHomeId" contextLevel="course"
 | 
			
		||||
                    [contextInstanceId]="siteHomeId"></core-format-text>
 | 
			
		||||
            </ion-label>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
 | 
			
		||||
        <core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [courseId]="siteHomeId"
 | 
			
		||||
            [downloadEnabled]="downloadEnabled" [section]="mainMenuBlock"></core-course-module>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
        <core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [section]="mainMenuBlock"></core-course-module>
 | 
			
		||||
    </ion-list>
 | 
			
		||||
</core-loading>
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,7 @@
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, Input } from '@angular/core';
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreCourse } from '@features/course/services/course';
 | 
			
		||||
import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper';
 | 
			
		||||
@ -29,8 +29,6 @@ import { CoreBlockBaseComponent } from '@features/block/classes/base-block-compo
 | 
			
		||||
})
 | 
			
		||||
export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @Input() downloadEnabled = false;
 | 
			
		||||
 | 
			
		||||
    component = 'AddonBlockSiteMainMenu';
 | 
			
		||||
    mainMenuBlock?: CoreCourseSection;
 | 
			
		||||
    siteHomeId = 1;
 | 
			
		||||
@ -91,7 +89,7 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl
 | 
			
		||||
        const items = config.frontpageloggedin.split(',');
 | 
			
		||||
        const hasNewsItem = items.find((item) => parseInt(item, 10) == FrontPageItemNames['NEWS_ITEMS']);
 | 
			
		||||
 | 
			
		||||
        const result = CoreCourseHelper.addHandlerDataForModules(
 | 
			
		||||
        const result = await CoreCourseHelper.addHandlerDataForModules(
 | 
			
		||||
            [mainMenuBlock],
 | 
			
		||||
            this.siteHomeId,
 | 
			
		||||
            undefined,
 | 
			
		||||
 | 
			
		||||
@ -1,41 +1,25 @@
 | 
			
		||||
<ion-item-divider sticky="true">
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2>
 | 
			
		||||
        <h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
    <div slot="end" class="flex-row">
 | 
			
		||||
        <div *ngIf="downloadCoursesEnabled && downloadEnabled && courses && courses.length > 1" class="core-button-spinner">
 | 
			
		||||
            <ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark"
 | 
			
		||||
                (click)="prefetchCourses()" [attr.aria-label]="'core.courses.downloadcourses' | translate">
 | 
			
		||||
                <ion-icon [name]="prefetchCoursesData.icon" slot="icon-only" aria-hidden="true"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
            <ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge"
 | 
			
		||||
                role="progressbar" [attr.aria-valuemax]="prefetchCoursesData.total"
 | 
			
		||||
                [attr.aria-valuenow]="prefetchCoursesData.count" [attr.aria-valuetext]="prefetchCoursesData.badgeA11yText">
 | 
			
		||||
                {{prefetchCoursesData.badge}}
 | 
			
		||||
            </ion-badge>
 | 
			
		||||
            <ion-spinner *ngIf="!prefetchCoursesData.icon || prefetchCoursesData.loading"
 | 
			
		||||
                [attr.aria-label]="'core.loading' | translate"></ion-spinner>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <core-horizontal-scroll-controls #scrollControls [aria-controls]="scrollElementId">
 | 
			
		||||
        </core-horizontal-scroll-controls>
 | 
			
		||||
    </div>
 | 
			
		||||
</ion-item-divider>
 | 
			
		||||
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="safe-area-page margin">
 | 
			
		||||
    <core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" inline="true"
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
    <core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
 | 
			
		||||
        [message]="'addon.block_starredcourses.nocourses' | translate"></core-empty-box>
 | 
			
		||||
    <!-- List of courses. -->
 | 
			
		||||
    <div
 | 
			
		||||
        [hidden]="courses.length === 0"
 | 
			
		||||
        [id]="scrollElementId"
 | 
			
		||||
        class="core-horizontal-scroll"
 | 
			
		||||
        (scroll)="scrollControls.updateScrollPosition()"
 | 
			
		||||
    >
 | 
			
		||||
    <div [hidden]="courses.length === 0" [id]="scrollElementId" class="core-horizontal-scroll"
 | 
			
		||||
        (scroll)="scrollControls.updateScrollPosition()">
 | 
			
		||||
        <div (onResize)="scrollControls.updateScrollPosition()" class="flex-row">
 | 
			
		||||
            <div class="safe-area-pseudo-padding-start"></div>
 | 
			
		||||
            <ng-container *ngFor="let course of courses">
 | 
			
		||||
                <core-courses-course-progress [course]="course" class="core-block_starredcourses"
 | 
			
		||||
                    [showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
 | 
			
		||||
                <core-courses-course-list-item [course]="course" class="core-block_starredcourses" layout="summarycard">
 | 
			
		||||
                </core-courses-course-list-item>
 | 
			
		||||
            </ng-container>
 | 
			
		||||
            <div class="safe-area-pseudo-padding-end"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</core-loading>
 | 
			
		||||
 | 
			
		||||
@ -12,17 +12,20 @@
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit, OnDestroy, Input, OnChanges, SimpleChange } from '@angular/core';
 | 
			
		||||
import { Component, OnInit, OnDestroy } from '@angular/core';
 | 
			
		||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses';
 | 
			
		||||
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
 | 
			
		||||
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper';
 | 
			
		||||
import {
 | 
			
		||||
    CoreCourseSearchedDataWithExtraInfoAndOptions,
 | 
			
		||||
    CoreEnrolledCourseDataWithOptions,
 | 
			
		||||
} from '@features/courses/services/courses-helper';
 | 
			
		||||
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
 | 
			
		||||
import { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion';
 | 
			
		||||
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
 | 
			
		||||
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreSite } from '@classes/site';
 | 
			
		||||
import { AddonBlockStarredCourse, AddonBlockStarredCourses } from '../../services/starredcourses';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render a starred courses block.
 | 
			
		||||
@ -31,36 +34,25 @@ import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
    selector: 'addon-block-starredcourses',
 | 
			
		||||
    templateUrl: 'addon-block-starredcourses.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy {
 | 
			
		||||
export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    @Input() downloadEnabled = false;
 | 
			
		||||
    courses: AddonBlockStarredCoursesCourse[] = [];
 | 
			
		||||
 | 
			
		||||
    courses: CoreEnrolledCourseDataWithOptions [] = [];
 | 
			
		||||
    prefetchCoursesData: CorePrefetchStatusInfo = {
 | 
			
		||||
        icon: '',
 | 
			
		||||
        statusTranslatable: 'core.loading',
 | 
			
		||||
        status: '',
 | 
			
		||||
        loading: true,
 | 
			
		||||
        badge: '',
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    downloadCourseEnabled = false;
 | 
			
		||||
    downloadCoursesEnabled = false;
 | 
			
		||||
    scrollElementId!: string;
 | 
			
		||||
 | 
			
		||||
    protected prefetchIconsInitialized = false;
 | 
			
		||||
    protected site: CoreSite;
 | 
			
		||||
    protected isDestroyed = false;
 | 
			
		||||
    protected coursesObserver?: CoreEventObserver;
 | 
			
		||||
    protected updateSiteObserver?: CoreEventObserver;
 | 
			
		||||
    protected courseIds: number[] = [];
 | 
			
		||||
    protected fetchContentDefaultError = 'Error getting starred courses data.';
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonBlockStarredCoursesComponent');
 | 
			
		||||
 | 
			
		||||
        this.site = CoreSites.getRequiredCurrentSite();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        // Generate unique id for scroll element.
 | 
			
		||||
@ -68,25 +60,10 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im
 | 
			
		||||
 | 
			
		||||
        this.scrollElementId = `addon-block-starredcourses-scroll-${scrollId}`;
 | 
			
		||||
 | 
			
		||||
        // Refresh the enabled flags if enabled.
 | 
			
		||||
        this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
 | 
			
		||||
        this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
 | 
			
		||||
 | 
			
		||||
        // Refresh the enabled flags if site is updated.
 | 
			
		||||
        this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
 | 
			
		||||
            this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
 | 
			
		||||
            this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
 | 
			
		||||
 | 
			
		||||
        }, CoreSites.getCurrentSiteId());
 | 
			
		||||
 | 
			
		||||
        this.coursesObserver = CoreEvents.on(
 | 
			
		||||
            CoreCoursesProvider.EVENT_MY_COURSES_UPDATED,
 | 
			
		||||
            (data) => {
 | 
			
		||||
 | 
			
		||||
                if (this.shouldRefreshOnUpdatedEvent(data)) {
 | 
			
		||||
                    this.refreshCourseList();
 | 
			
		||||
                }
 | 
			
		||||
                this.refreshContent();
 | 
			
		||||
                this.refreshCourseList(data);
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
            CoreSites.getCurrentSiteId(),
 | 
			
		||||
@ -96,128 +73,130 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Detect changes on input properties.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnChanges(changes: {[name: string]: SimpleChange}): void {
 | 
			
		||||
        if (changes.downloadEnabled && !changes.downloadEnabled.previousValue && this.downloadEnabled && this.loaded) {
 | 
			
		||||
            // Download all courses is enabled now, initialize it.
 | 
			
		||||
            this.initPrefetchCoursesIcons();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Perform the invalidate content function.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Resolved when done.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    protected async invalidateContent(): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
        const courseIds = this.courses.map((course) => course.id);
 | 
			
		||||
 | 
			
		||||
        promises.push(CoreCourses.invalidateUserCourses().finally(() =>
 | 
			
		||||
            // Invalidate course completion data.
 | 
			
		||||
            CoreUtils.allPromises(this.courseIds.map((courseId) =>
 | 
			
		||||
                AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
 | 
			
		||||
 | 
			
		||||
        promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
 | 
			
		||||
        if (this.courseIds.length > 0) {
 | 
			
		||||
            promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIds.join(',')));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.allPromises(promises).finally(() => {
 | 
			
		||||
            this.prefetchIconsInitialized = false;
 | 
			
		||||
        });
 | 
			
		||||
        await this.invalidateCourses(courseIds);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fetch the courses.
 | 
			
		||||
     * Invalidate list of courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async invalidateCourseList(): Promise<void> {
 | 
			
		||||
        return AddonBlockStarredCourses.invalidateStarredCourses();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Helper function to invalidate only selected courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @param courseIds Course Id array.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async invalidateCourses(courseIds: number[]): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
        // Invalidate course completion data.
 | 
			
		||||
        promises.push(this.invalidateCourseList().finally(() =>
 | 
			
		||||
            CoreUtils.allPromises(courseIds.map((courseId) =>
 | 
			
		||||
                AddonCourseCompletion.invalidateCourseCompletion(courseId)))));
 | 
			
		||||
 | 
			
		||||
        if (courseIds.length  == 1) {
 | 
			
		||||
            promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(courseIds[0]));
 | 
			
		||||
        } else {
 | 
			
		||||
            promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
 | 
			
		||||
        }
 | 
			
		||||
        if (courseIds.length > 0) {
 | 
			
		||||
            promises.push(CoreCourses.invalidateCoursesByField('ids', courseIds.join(',')));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.allPromises(promises);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchContent(): Promise<void> {
 | 
			
		||||
        const showCategories = this.block.configsRecord && this.block.configsRecord.displaycategories &&
 | 
			
		||||
            this.block.configsRecord.displaycategories.value == '1';
 | 
			
		||||
 | 
			
		||||
        this.courses = await CoreCoursesHelper.getUserCoursesWithOptions('timemodified', 0, 'isfavourite', showCategories);
 | 
			
		||||
        this.initPrefetchCoursesIcons();
 | 
			
		||||
        // Timemodified not present, use the block WS to retrieve the info.
 | 
			
		||||
        const starredCourses = await AddonBlockStarredCourses.getStarredCourses();
 | 
			
		||||
 | 
			
		||||
        const courseIds = starredCourses.map((course) => course.id);
 | 
			
		||||
 | 
			
		||||
        // Get the courses using getCoursesByField to get more info about each course.
 | 
			
		||||
        const courses = await CoreCourses.getCoursesByField('ids', courseIds.join(','));
 | 
			
		||||
 | 
			
		||||
        this.courses = starredCourses.map((recentCourse) => {
 | 
			
		||||
            const course = courses.find((course) => recentCourse.id == course.id);
 | 
			
		||||
 | 
			
		||||
            return Object.assign(recentCourse, course);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Get course options and extra info.
 | 
			
		||||
        const options = await CoreCourses.getCoursesAdminAndNavOptions(courseIds);
 | 
			
		||||
        this.courses.forEach((course) => {
 | 
			
		||||
            course.navOptions = options.navOptions[course.id];
 | 
			
		||||
            course.admOptions = options.admOptions[course.id];
 | 
			
		||||
 | 
			
		||||
            if (!showCategories) {
 | 
			
		||||
                course.categoryname = '';
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Refresh the list of courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async refreshCourseList(): Promise<void> {
 | 
			
		||||
        CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED);
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await CoreCourses.invalidateUserCourses();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            // Ignore errors.
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.loadContent(true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether list should be refreshed based on a EVENT_MY_COURSES_UPDATED event.
 | 
			
		||||
     * Refresh course list based on a EVENT_MY_COURSES_UPDATED event.
 | 
			
		||||
     *
 | 
			
		||||
     * @param data Event data.
 | 
			
		||||
     * @return Whether to refresh.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected shouldRefreshOnUpdatedEvent(data: CoreCoursesMyCoursesUpdatedEventData): boolean {
 | 
			
		||||
    protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> {
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_ENROL) {
 | 
			
		||||
            // Always update if user enrolled in a course.
 | 
			
		||||
            // New courses shouldn't be favourite by default, but just in case.
 | 
			
		||||
            return true;
 | 
			
		||||
            return await this.refreshContent();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED && data.state == CoreCoursesProvider.STATE_FAVOURITE) {
 | 
			
		||||
            // Update list when making a course favourite or not.
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize the prefetch icon for selected courses.
 | 
			
		||||
     */
 | 
			
		||||
    protected async initPrefetchCoursesIcons(): Promise<void> {
 | 
			
		||||
        if (this.prefetchIconsInitialized || !this.downloadEnabled) {
 | 
			
		||||
            // Already initialized.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.prefetchIconsInitialized = true;
 | 
			
		||||
 | 
			
		||||
        this.prefetchCoursesData = await CoreCourseHelper.initPrefetchCoursesIcons(this.courses, this.prefetchCoursesData);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetch all the shown courses.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async prefetchCourses(): Promise<void> {
 | 
			
		||||
        const initialIcon = this.prefetchCoursesData.icon;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            return CoreCourseHelper.prefetchCourses(this.courses, this.prefetchCoursesData);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            if (!this.isDestroyed) {
 | 
			
		||||
                CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true);
 | 
			
		||||
                this.prefetchCoursesData.icon = initialIcon;
 | 
			
		||||
            const courseIndex = this.courses.findIndex((course) => course.id == data.courseId);
 | 
			
		||||
            if (courseIndex < 0) {
 | 
			
		||||
                // Not found, use WS update. Usually new favourite.
 | 
			
		||||
                return await this.refreshContent();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const course = this.courses[courseIndex];
 | 
			
		||||
            if (data.value === false) {
 | 
			
		||||
                // Unfavourite, just remove.
 | 
			
		||||
                this.courses.splice(courseIndex, 1);
 | 
			
		||||
            } else {
 | 
			
		||||
                // List is not synced, favourite course and place it at the begining.
 | 
			
		||||
                course.isfavourite = !!data.value;
 | 
			
		||||
 | 
			
		||||
                this.courses.splice(courseIndex, 1);
 | 
			
		||||
                this.courses.unshift(course);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await this.invalidateCourseList();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being destroyed.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.isDestroyed = true;
 | 
			
		||||
        this.coursesObserver?.off();
 | 
			
		||||
        this.updateSiteObserver?.off();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AddonBlockStarredCoursesCourse =
 | 
			
		||||
    (AddonBlockStarredCourse & CoreCourseSearchedDataWithExtraInfoAndOptions) |
 | 
			
		||||
    (CoreEnrolledCourseDataWithOptions & {
 | 
			
		||||
        categoryname?: string; // Category name,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										101
									
								
								src/addons/block/starredcourses/services/starredcourses.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/addons/block/starredcourses/services/starredcourses.ts
									
									
									
									
									
										Normal 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 { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreSiteWSPreSets } from '@classes/site';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
const ROOT_CACHE_KEY = 'AddonBlockStarredCourses:';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Service that provides some features regarding starred courses.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable( { providedIn: 'root' })
 | 
			
		||||
export class AddonBlockStarredCoursesProvider {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get cache key for get starred courrses value WS call.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Cache key.
 | 
			
		||||
     */
 | 
			
		||||
    protected getStarredCoursesCacheKey(): string {
 | 
			
		||||
        return ROOT_CACHE_KEY + ':starredcourses';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get starred courrses.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID. If not defined, use current site.
 | 
			
		||||
     * @return Promise resolved when the info is retrieved.
 | 
			
		||||
     */
 | 
			
		||||
    async getStarredCourses(siteId?: string): Promise<AddonBlockStarredCourse[]> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        const preSets: CoreSiteWSPreSets = {
 | 
			
		||||
            cacheKey: this.getStarredCoursesCacheKey(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return await site.read<AddonBlockStarredCourse[]>('block_starredcourses_get_starred_courses', undefined, preSets);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Invalidates get starred courrses WS call.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID to invalidate. If not defined, use current site.
 | 
			
		||||
     * @return Promise resolved when the data is invalidated.
 | 
			
		||||
     */
 | 
			
		||||
    async invalidateStarredCourses(siteId?: string): Promise<void> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        await site.invalidateWsCacheForKey(this.getStarredCoursesCacheKey());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
export const AddonBlockStarredCourses = makeSingleton(AddonBlockStarredCoursesProvider);
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Params of block_starredcourses_get_starred_courses WS.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonBlockStarredCoursesGetStarredCoursesWSParams = {
 | 
			
		||||
    limit?: number; // Limit.
 | 
			
		||||
    offset?: number; // Offset.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data returned by block_starredcourses_get_starred_courses WS.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonBlockStarredCourse = {
 | 
			
		||||
    id: number; // Id.
 | 
			
		||||
    fullname: string; // Fullname.
 | 
			
		||||
    shortname: string; // Shortname.
 | 
			
		||||
    idnumber: string; // Idnumber.
 | 
			
		||||
    summary: string; // Summary.
 | 
			
		||||
    summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
 | 
			
		||||
    startdate: number; // Startdate.
 | 
			
		||||
    enddate: number; // Enddate.
 | 
			
		||||
    visible: boolean; // Visible.
 | 
			
		||||
    showactivitydates: boolean; // Showactivitydates.
 | 
			
		||||
    showcompletionconditions: boolean; // Showcompletionconditions.
 | 
			
		||||
    fullnamedisplay: string; // Fullnamedisplay.
 | 
			
		||||
    viewurl: string; // Viewurl.
 | 
			
		||||
    courseimage: string; // Courseimage.
 | 
			
		||||
    progress?: number; // Progress.
 | 
			
		||||
    hasprogress: boolean; // Hasprogress.
 | 
			
		||||
    isfavourite: boolean; // Isfavourite.
 | 
			
		||||
    hidden: boolean; // Hidden.
 | 
			
		||||
    timeaccess?: number; // Timeaccess.
 | 
			
		||||
    showshortname: boolean; // Showshortname.
 | 
			
		||||
    coursecategory: string; // Coursecategory.
 | 
			
		||||
};
 | 
			
		||||
@ -1,11 +1,9 @@
 | 
			
		||||
:host .core-block-content ::ng-deep {
 | 
			
		||||
    .tag_cloud {
 | 
			
		||||
        font-size: 80%;
 | 
			
		||||
        text-align: center;
 | 
			
		||||
        ul.inline-list {
 | 
			
		||||
            list-style: none;
 | 
			
		||||
            margin-left: 0;
 | 
			
		||||
            margin-right: 0;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            -webkit-padding-start: 0;
 | 
			
		||||
 | 
			
		||||
            li {
 | 
			
		||||
@ -13,8 +11,8 @@
 | 
			
		||||
                display: inline-block;
 | 
			
		||||
 | 
			
		||||
                a {
 | 
			
		||||
                    background: var(--ion-color-primary);
 | 
			
		||||
                    color: var(--ion-color-primary-contrast);
 | 
			
		||||
                    background: var(--primary);
 | 
			
		||||
                    color: var(--primary-contrast);
 | 
			
		||||
                    padding: 3px 8px;
 | 
			
		||||
                    -webkit-font-smoothing: antialiased;
 | 
			
		||||
                    display: inline-block;
 | 
			
		||||
@ -26,7 +24,7 @@
 | 
			
		||||
                    contain: content;
 | 
			
		||||
                    vertical-align: baseline;
 | 
			
		||||
                    text-decoration: none;
 | 
			
		||||
                    border-radius: 4px;
 | 
			
		||||
                    border-radius: var(--small-radius);
 | 
			
		||||
                }
 | 
			
		||||
                .s20 {
 | 
			
		||||
                    font-size: 2.7em;
 | 
			
		||||
 | 
			
		||||
@ -15,11 +15,10 @@
 | 
			
		||||
import { NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { CoreCoursesComponentsModule } from '@features/courses/components/components.module';
 | 
			
		||||
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
 | 
			
		||||
 | 
			
		||||
import { AddonBlockTimelineComponent } from './timeline/timeline';
 | 
			
		||||
import { AddonBlockTimelineEventsComponent } from './events/events';
 | 
			
		||||
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
@ -28,8 +27,7 @@ import { AddonBlockTimelineEventsComponent } from './events/events';
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
        CoreCoursesComponentsModule,
 | 
			
		||||
        CoreCourseComponentsModule,
 | 
			
		||||
        CoreSearchComponentsModule,
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonBlockTimelineComponent,
 | 
			
		||||
 | 
			
		||||
@ -1,62 +1,83 @@
 | 
			
		||||
<ion-item *ngIf="course">
 | 
			
		||||
    <ion-label class="ion-text-wrap">
 | 
			
		||||
        <h3>
 | 
			
		||||
            <span class="sr-only">{{ 'core.courses.aria:coursename' | translate }}</span>
 | 
			
		||||
            <core-format-text [text]="course.displayname || course.fullname" contextLevel="course" [contextInstanceId]="course.id">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </h3>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item>
 | 
			
		||||
<ion-item-group *ngFor="let dayEvents of filteredEvents">
 | 
			
		||||
    <ion-item-divider [color]="dayEvents.color">
 | 
			
		||||
        <ion-label><h3>{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedayshort" }}</h3></ion-label>
 | 
			
		||||
    </ion-item-divider>
 | 
			
		||||
    <ion-item>
 | 
			
		||||
        <ion-label>
 | 
			
		||||
            <h4 [class.core-bold]="!course">{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }}</h4>
 | 
			
		||||
        </ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
    <ng-container *ngFor="let event of dayEvents.events">
 | 
			
		||||
        <ion-item class="ion-text-wrap core-course-module-handler item-media" detail="false" (click)="action($event, event.url)"
 | 
			
		||||
            [attr.aria-label]="event.name" button>
 | 
			
		||||
            <img slot="start" [src]="event.iconUrl" alt="" role="presentation" *ngIf="event.iconUrl" class="core-module-icon">
 | 
			
		||||
        <ion-item class="addon-block-timeline-activity" detail="false" (click)="action($event, event.url)" [attr.aria-label]="event.name"
 | 
			
		||||
            button lines="full">
 | 
			
		||||
            <ion-label>
 | 
			
		||||
                <!-- Add the icon title so accessibility tools read it. -->
 | 
			
		||||
                <span class="sr-only" *ngIf="event.iconTitle">{{ event.iconTitle }}</span>
 | 
			
		||||
                <p class="item-heading">
 | 
			
		||||
                    <core-format-text [text]="event.name" contextLevel="module" [contextInstanceId]="event.id"
 | 
			
		||||
                        [courseId]="event.course && event.course.id">
 | 
			
		||||
                    </core-format-text>
 | 
			
		||||
                </p>
 | 
			
		||||
                <p *ngIf="showCourse && event.course">
 | 
			
		||||
                    <core-format-text [text]="event.course.fullnamedisplay" contextLevel="course"
 | 
			
		||||
                        [contextInstanceId]="event.course.id">
 | 
			
		||||
                    </core-format-text>
 | 
			
		||||
                </p>
 | 
			
		||||
 | 
			
		||||
                <ion-button fill="clear" class="ion-hide-md-up ion-text-wrap" (click)="action($event, event.action.url)"
 | 
			
		||||
                    [title]="event.action.name" [disabled]="!event.action.actionable" *ngIf="event.action">
 | 
			
		||||
                    {{event.action.name}}
 | 
			
		||||
                    <ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">{{event.action.itemcount}}
 | 
			
		||||
                    </ion-badge>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
                <ion-row class="ion-justify-content-between ion-align-items-center ion-no-padding">
 | 
			
		||||
                    <ion-col class="addon-block-timeline-activity-main ion-no-padding">
 | 
			
		||||
                        <ion-row class="ion-justify-content-between ion-align-items-center ion-nowrap ion-no-padding">
 | 
			
		||||
                            <ion-col class="addon-block-timeline-activity-time ion-no-padding">
 | 
			
		||||
                                <small>{{event.timesort * 1000 | coreFormatDate:"strftimetime24" }}</small>
 | 
			
		||||
                                <core-mod-icon *ngIf="event.iconUrl" [modicon]="event.iconUrl" [componentId]="event.instance"
 | 
			
		||||
                                    [modname]="event.modulename">
 | 
			
		||||
                                </core-mod-icon>
 | 
			
		||||
                            </ion-col>
 | 
			
		||||
                            <ion-col class="addon-block-timeline-activity-name ion-no-padding">
 | 
			
		||||
                                <p class="item-heading addon-block-timeline-activity-name-with-status">
 | 
			
		||||
                                    <span>
 | 
			
		||||
                                        <core-format-text [text]="event.activityname || event.name" contextLevel="module"
 | 
			
		||||
                                            [contextInstanceId]="event.id" [courseId]="event.course && event.course.id">
 | 
			
		||||
                                        </core-format-text>
 | 
			
		||||
                                    </span>
 | 
			
		||||
                                    <ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }}
 | 
			
		||||
                                    </ion-badge>
 | 
			
		||||
                                </p>
 | 
			
		||||
                                <p *ngIf="(showCourse && event.course) || event.activitystr"
 | 
			
		||||
                                    class="addon-block-timeline-activity-course-activity">
 | 
			
		||||
                                    <span *ngIf="showCourse && event.course">
 | 
			
		||||
                                        <core-format-text [text]="event.course.fullnamedisplay" contextLevel="course"
 | 
			
		||||
                                            [contextInstanceId]="event.course.id">
 | 
			
		||||
                                        </core-format-text>
 | 
			
		||||
                                    </span>
 | 
			
		||||
                                    <span *ngIf="event.activitystr">
 | 
			
		||||
                                        <core-format-text *ngIf="event.activitystr" [text]="event.activitystr" contextLevel="module"
 | 
			
		||||
                                            [contextInstanceId]="event.id">
 | 
			
		||||
                                        </core-format-text>
 | 
			
		||||
                                    </span>
 | 
			
		||||
                                </p>
 | 
			
		||||
                            </ion-col>
 | 
			
		||||
                        </ion-row>
 | 
			
		||||
                    </ion-col>
 | 
			
		||||
                    <ion-col class="addon-block-timeline-activity-action ion-no-padding" *ngIf="event.action?.actionable">
 | 
			
		||||
                        <ion-button fill="outline" (click)="action($event, event.action.url)" [title]="event.action.name" class="chip">
 | 
			
		||||
                            {{event.action.name}}
 | 
			
		||||
                            <ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">
 | 
			
		||||
                                {{event.action.itemcount}}
 | 
			
		||||
                            </ion-badge>
 | 
			
		||||
                        </ion-button>
 | 
			
		||||
                    </ion-col>
 | 
			
		||||
                </ion-row>
 | 
			
		||||
            </ion-label>
 | 
			
		||||
 | 
			
		||||
            <div slot="end" class="events-info">
 | 
			
		||||
                <div>
 | 
			
		||||
                    <ion-badge color="light">{{event.timesort * 1000 | coreFormatDate:"strftimetime24" }}</ion-badge>
 | 
			
		||||
                </div>
 | 
			
		||||
                <ion-button
 | 
			
		||||
                    class="ion-hide-md-down"
 | 
			
		||||
                    fill="clear"
 | 
			
		||||
                    (click)="action($event, event.action.url)"
 | 
			
		||||
                    [title]="event.action.name"
 | 
			
		||||
                    [disabled]="!event.action.actionable" *ngIf="event.action"
 | 
			
		||||
                >
 | 
			
		||||
                    {{event.action.name}}
 | 
			
		||||
                    <ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">
 | 
			
		||||
                        {{event.action.itemcount}}
 | 
			
		||||
                    </ion-badge>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
            </div>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
</ion-item-group>
 | 
			
		||||
 | 
			
		||||
<div class="ion-padding ion-text-center" *ngIf="canLoadMore && !empty">
 | 
			
		||||
    <!-- Button and spinner to show more attempts. -->
 | 
			
		||||
    <ion-button expand="block" (click)="loadMoreEvents()" color="light" *ngIf="!loadingMore">
 | 
			
		||||
    <ion-button expand="block" (click)="loadMoreEvents()" fill="outline" *ngIf="!loadingMore">
 | 
			
		||||
        {{ 'core.loadmore' | translate }}
 | 
			
		||||
    </ion-button>
 | 
			
		||||
    <ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<core-empty-box *ngIf="empty" image="assets/img/icons/activities.svg" [message]="'addon.block_timeline.noevents' | translate"
 | 
			
		||||
    inline="true">
 | 
			
		||||
<ion-item *ngIf="empty && course">
 | 
			
		||||
    <ion-label class="ion-text-wrap">
 | 
			
		||||
        <p>{{'addon.block_timeline.noevents' | translate}}</p>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item>
 | 
			
		||||
<core-empty-box *ngIf="empty && !course" image="assets/img/icons/activities.svg" [message]="'addon.block_timeline.noevents' | translate">
 | 
			
		||||
</core-empty-box>
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,81 @@
 | 
			
		||||
.events-info {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    text-align: end;
 | 
			
		||||
    padding: 10px 0;
 | 
			
		||||
@import "~theme/globals";
 | 
			
		||||
 | 
			
		||||
h3 {
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
    font-size: 18px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h4 {
 | 
			
		||||
    font-size: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h4.core-bold {
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.addon-block-timeline-activity {
 | 
			
		||||
    ion-badge {
 | 
			
		||||
        @include margin-horizontal(0.25rem, 0.5rem);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    small {
 | 
			
		||||
        @include margin-horizontal(null, 0.5rem);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    core-mod-icon {
 | 
			
		||||
        padding: 8px;
 | 
			
		||||
        --margin-end: 0.5rem;
 | 
			
		||||
        --margin-vertical: 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.addon-block-timeline-activity-time {
 | 
			
		||||
    flex-grow: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.addon-block-timeline-activity-action {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.addon-block-timeline-activity-name-with-status {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    span {
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        text-overflow: ellipsis;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.addon-block-timeline-activity-course-activity {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    span {
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        text-overflow: ellipsis;
 | 
			
		||||
    }
 | 
			
		||||
    span::after {
 | 
			
		||||
        content: "·";
 | 
			
		||||
        display: inline;
 | 
			
		||||
        padding-left: .3rem;
 | 
			
		||||
        padding-right: .3rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    span:last-child::after {
 | 
			
		||||
        display: none;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.addon-block-timeline-activity-main,
 | 
			
		||||
.addon-block-timeline-activity-name {
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    p {
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        text-overflow: ellipsis;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.addon-block-timeline-activity-name {
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -17,11 +17,11 @@ import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreCourse } from '@features/course/services/course';
 | 
			
		||||
import moment from 'moment';
 | 
			
		||||
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
 | 
			
		||||
import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
 | 
			
		||||
import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
 | 
			
		||||
import { AddonBlockTimeline } from '../../services/timeline';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Directive to render a list of events in course overview.
 | 
			
		||||
@ -34,34 +34,33 @@ import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
 | 
			
		||||
export class AddonBlockTimelineEventsComponent implements OnChanges {
 | 
			
		||||
 | 
			
		||||
    @Input() events: AddonBlockTimelineEvent[] = []; // The events to render.
 | 
			
		||||
    @Input() showCourse?: boolean | string; // Whether to show the course name.
 | 
			
		||||
    @Input() course?: CoreEnrolledCourseDataWithOptions; // Whether to show the course name.
 | 
			
		||||
    @Input() from = 0; // Number of days from today to offset the events.
 | 
			
		||||
    @Input() to?: number; // Number of days from today to limit the events to. If not defined, no limit.
 | 
			
		||||
    @Input() canLoadMore?: boolean; // Whether more events can be loaded.
 | 
			
		||||
    @Output() loadMore: EventEmitter<void>; // Notify that more events should be loaded.
 | 
			
		||||
    @Input() overdue = false; // If filtering overdue events or not.
 | 
			
		||||
    @Input() canLoadMore = false; // Whether more events can be loaded.
 | 
			
		||||
    @Output() loadMore = new EventEmitter(); // Notify that more events should be loaded.
 | 
			
		||||
 | 
			
		||||
    showCourse = false; // Whether to show the course name.
 | 
			
		||||
    empty = true;
 | 
			
		||||
    loadingMore = false;
 | 
			
		||||
    filteredEvents: AddonBlockTimelineEventFilteredEvent[] = [];
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.loadMore = new EventEmitter();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Detect changes on input properties.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    ngOnChanges(changes: {[name: string]: SimpleChange}): void {
 | 
			
		||||
        this.showCourse = CoreUtils.isTrueOrOne(this.showCourse);
 | 
			
		||||
    async ngOnChanges(changes: {[name: string]: SimpleChange}): Promise<void> {
 | 
			
		||||
        this.showCourse = !this.course;
 | 
			
		||||
 | 
			
		||||
        if (changes.events || changes.from || changes.to) {
 | 
			
		||||
            if (this.events && this.events.length > 0) {
 | 
			
		||||
                const filteredEvents = this.filterEventsByTime(this.from, this.to);
 | 
			
		||||
            if (this.events) {
 | 
			
		||||
                const filteredEvents = await this.filterEventsByTime();
 | 
			
		||||
                this.empty = !filteredEvents || filteredEvents.length <= 0;
 | 
			
		||||
 | 
			
		||||
                const eventsByDay: Record<number, AddonCalendarEvent[]> = {};
 | 
			
		||||
                const eventsByDay: Record<number, AddonBlockTimelineEvent[]> = {};
 | 
			
		||||
                filteredEvents.forEach((event) => {
 | 
			
		||||
                    const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort);
 | 
			
		||||
 | 
			
		||||
                    if (eventsByDay[dayTimestamp]) {
 | 
			
		||||
                        eventsByDay[dayTimestamp].push(event);
 | 
			
		||||
                    } else {
 | 
			
		||||
@ -69,16 +68,15 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                const todaysMidnight = CoreTimeUtils.getMidnightForTimestamp();
 | 
			
		||||
                this.filteredEvents = [];
 | 
			
		||||
                Object.keys(eventsByDay).forEach((key) => {
 | 
			
		||||
                this.filteredEvents =  Object.keys(eventsByDay).map((key) => {
 | 
			
		||||
                    const dayTimestamp = parseInt(key);
 | 
			
		||||
                    this.filteredEvents.push({
 | 
			
		||||
                        color: dayTimestamp < todaysMidnight ? 'danger' : 'light',
 | 
			
		||||
 | 
			
		||||
                    return {
 | 
			
		||||
                        dayTimestamp,
 | 
			
		||||
                        events: eventsByDay[dayTimestamp],
 | 
			
		||||
                    });
 | 
			
		||||
                    };
 | 
			
		||||
                });
 | 
			
		||||
                this.loadingMore = false;
 | 
			
		||||
            } else {
 | 
			
		||||
                this.empty = true;
 | 
			
		||||
            }
 | 
			
		||||
@ -88,26 +86,41 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
 | 
			
		||||
    /**
 | 
			
		||||
     * Filter the events by time.
 | 
			
		||||
     *
 | 
			
		||||
     * @param start Number of days to start getting events from today. E.g. -1 will get events from yesterday.
 | 
			
		||||
     * @param end Number of days after the start.
 | 
			
		||||
     * @return Filtered events.
 | 
			
		||||
     */
 | 
			
		||||
    protected filterEventsByTime(start: number, end?: number): AddonBlockTimelineEvent[] {
 | 
			
		||||
        start = moment().add(start, 'days').startOf('day').unix();
 | 
			
		||||
        end = typeof end != 'undefined' ? moment().add(end, 'days').startOf('day').unix() : end;
 | 
			
		||||
    protected async filterEventsByTime(): Promise<AddonBlockTimelineEvent[]> {
 | 
			
		||||
        const start = AddonBlockTimeline.getDayStart(this.from);
 | 
			
		||||
        const end = this.to !== undefined
 | 
			
		||||
            ? AddonBlockTimeline.getDayStart(this.to)
 | 
			
		||||
            : undefined;
 | 
			
		||||
 | 
			
		||||
        return this.events.filter((event) => {
 | 
			
		||||
            if (end) {
 | 
			
		||||
                return start <= event.timesort && event.timesort < end;
 | 
			
		||||
        const now = CoreTimeUtils.timestamp();
 | 
			
		||||
        const midnight = AddonBlockTimeline.getDayStart();
 | 
			
		||||
 | 
			
		||||
        return await Promise.all(this.events.filter((event) => {
 | 
			
		||||
            if (start > event.timesort || (end && event.timesort >= end)) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return start <= event.timesort;
 | 
			
		||||
        }).map((event) => {
 | 
			
		||||
            event.iconUrl = CoreCourse.getModuleIconSrc(event.icon.component);
 | 
			
		||||
            event.iconTitle = event.modulename && CoreCourse.translateModuleName(event.modulename);
 | 
			
		||||
            // Already calculated on 4.0 onwards but this will be live.
 | 
			
		||||
            event.overdue = event.timesort < now;
 | 
			
		||||
 | 
			
		||||
            if (event.eventtype === 'open' || event.eventtype === 'opensubmission') {
 | 
			
		||||
                const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort);
 | 
			
		||||
 | 
			
		||||
                return dayTimestamp > midnight;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // When filtering by overdue, we fetch all events due today, in case any have elapsed already and are overdue.
 | 
			
		||||
            // This means if filtering by overdue, some events fetched might not be required (eg if due later today).
 | 
			
		||||
            return (!this.overdue || event.overdue);
 | 
			
		||||
        }).map(async (event) => {
 | 
			
		||||
            event.iconUrl = await CoreCourse.getModuleIconSrc(event.icon.component);
 | 
			
		||||
            event.modulename = event.modulename || event.icon.component;
 | 
			
		||||
            event.iconTitle = CoreCourse.translateModuleName(event.modulename);
 | 
			
		||||
 | 
			
		||||
            return event;
 | 
			
		||||
        });
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -121,12 +134,12 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
 | 
			
		||||
    /**
 | 
			
		||||
     * Action clicked.
 | 
			
		||||
     *
 | 
			
		||||
     * @param e Click event.
 | 
			
		||||
     * @param event Click event.
 | 
			
		||||
     * @param url Url of the action.
 | 
			
		||||
     */
 | 
			
		||||
    async action(e: Event, url: string): Promise<void> {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        e.stopPropagation();
 | 
			
		||||
    async action(event: Event, url: string): Promise<void> {
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
        event.stopPropagation();
 | 
			
		||||
 | 
			
		||||
        // Fix URL format.
 | 
			
		||||
        url = CoreTextUtils.decodeHTMLEntities(url);
 | 
			
		||||
@ -136,7 +149,7 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
 | 
			
		||||
        try {
 | 
			
		||||
            const treated = await CoreContentLinksHelper.handleLink(url);
 | 
			
		||||
            if (!treated) {
 | 
			
		||||
                return CoreSites.getCurrentSite()?.openInBrowserWithAutoLoginIfSameSite(url);
 | 
			
		||||
                return CoreSites.getRequiredCurrentSite().openInBrowserWithAutoLoginIfSameSite(url);
 | 
			
		||||
            }
 | 
			
		||||
        } finally {
 | 
			
		||||
            modal.dismiss();
 | 
			
		||||
@ -145,7 +158,8 @@ export class AddonBlockTimelineEventsComponent implements OnChanges {
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AddonBlockTimelineEvent = AddonCalendarEvent & {
 | 
			
		||||
type AddonBlockTimelineEvent = Omit<AddonCalendarEvent, 'eventtype'> & {
 | 
			
		||||
    eventtype: string;
 | 
			
		||||
    iconUrl?: string;
 | 
			
		||||
    iconTitle?: string;
 | 
			
		||||
};
 | 
			
		||||
@ -153,5 +167,4 @@ type AddonBlockTimelineEvent = AddonCalendarEvent & {
 | 
			
		||||
type AddonBlockTimelineEventFilteredEvent = {
 | 
			
		||||
    events: AddonBlockTimelineEvent[];
 | 
			
		||||
    dayTimestamp: number;
 | 
			
		||||
    color: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,57 +1,73 @@
 | 
			
		||||
<ion-item-divider sticky="true">
 | 
			
		||||
    <ion-label><h2>{{ 'addon.block_timeline.pluginname' | translate }}</h2></ion-label>
 | 
			
		||||
    <core-context-menu slot="end">
 | 
			
		||||
        <core-context-menu-item *ngIf="loaded" [priority]="900" [content]="'addon.block_timeline.sortbydates' | translate"
 | 
			
		||||
            (action)="switchSort('sortbydates')" [iconAction]="sort == 'sortbydates' ? 'far-dot-circle' : 'far-circle'">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="loaded" [priority]="800" [content]="'addon.block_timeline.sortbycourses' | translate"
 | 
			
		||||
            (action)="switchSort('sortbycourses')" [iconAction]="sort == 'sortbycourses' ? 'far-dot-circle' : 'far-circle'">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
    </core-context-menu>
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h2>{{ 'addon.block_timeline.pluginname' | translate }}</h2>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item-divider>
 | 
			
		||||
<core-loading [hideUntil]="loaded" [fullscreen]="false" class="margin">
 | 
			
		||||
    <div class="safe-padding-horizontal">
 | 
			
		||||
        <core-combobox [selection]="filter" (onChange)="switchFilter($event)">
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="all">
 | 
			
		||||
                {{ 'core.all' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="overdue">
 | 
			
		||||
                {{ 'addon.block_timeline.overdue' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" disabled value="disabled">
 | 
			
		||||
                {{ 'addon.block_timeline.duedate' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="next7days">
 | 
			
		||||
                {{ 'addon.block_timeline.next7days' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="next30days">
 | 
			
		||||
                {{ 'addon.block_timeline.next30days' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="next3months">
 | 
			
		||||
                {{ 'addon.block_timeline.next3months' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
            <ion-select-option class="ion-text-wrap" value="next6months">
 | 
			
		||||
                {{ 'addon.block_timeline.next6months' | translate }}
 | 
			
		||||
            </ion-select-option>
 | 
			
		||||
        </core-combobox>
 | 
			
		||||
    </div>
 | 
			
		||||
    <core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'" [fullscreen]="false" class="margin">
 | 
			
		||||
        <addon-block-timeline-events [events]="timeline.events" showCourse="true" [canLoadMore]="timeline.canLoadMore"
 | 
			
		||||
            (loadMore)="loadMoreTimeline()" [from]="dataFrom" [to]="dataTo"></addon-block-timeline-events>
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
    <ion-row class="ion-hide-md-up addon-block-timeline-filter" *ngIf="searchEnabled">
 | 
			
		||||
        <ion-col>
 | 
			
		||||
            <!-- Filter courses. -->
 | 
			
		||||
            <core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()"
 | 
			
		||||
                [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
 | 
			
		||||
                searchArea="AddonBlockTimeline"></core-search-box>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
    </ion-row>
 | 
			
		||||
    <ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter">
 | 
			
		||||
        <ion-col size="auto">
 | 
			
		||||
            <core-combobox [selection]="filter" (onChange)="switchFilter($event)">
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="all">
 | 
			
		||||
                    {{ 'core.all' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="overdue">
 | 
			
		||||
                    {{ 'addon.block_timeline.overdue' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap core-select-option-title" disabled value="disabled">
 | 
			
		||||
                    {{ 'addon.block_timeline.duedate' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="next7days">
 | 
			
		||||
                    {{ 'addon.block_timeline.next7days' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="next30days">
 | 
			
		||||
                    {{ 'addon.block_timeline.next30days' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="next3months">
 | 
			
		||||
                    {{ 'addon.block_timeline.next3months' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="next6months">
 | 
			
		||||
                    {{ 'addon.block_timeline.next6months' | translate }}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
            </core-combobox>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
        <ion-col class="ion-hide-md-down" *ngIf="searchEnabled">
 | 
			
		||||
            <!-- Filter courses. -->
 | 
			
		||||
            <core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()"
 | 
			
		||||
                [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
 | 
			
		||||
                searchArea="AddonBlockTimeline"></core-search-box>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
        <ion-col size="auto">
 | 
			
		||||
            <core-combobox [label]="'core.sortby' | translate" [selection]="sort" (onChange)="switchSort($event)"
 | 
			
		||||
                icon="fas-sort-amount-down-alt">
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="sortbydates">
 | 
			
		||||
                    {{'addon.block_timeline.sortbydates' | translate}}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
                <ion-select-option class="ion-text-wrap" value="sortbycourses">
 | 
			
		||||
                    {{'addon.block_timeline.sortbycourses' | translate}}
 | 
			
		||||
                </ion-select-option>
 | 
			
		||||
            </core-combobox>
 | 
			
		||||
        </ion-col>
 | 
			
		||||
    </ion-row>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    <core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'">
 | 
			
		||||
        <addon-block-timeline-events [events]="timeline.events" [canLoadMore]="timeline.canLoadMore" (loadMore)="loadMore()"
 | 
			
		||||
            [from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events>
 | 
			
		||||
    </core-loading>
 | 
			
		||||
    <core-loading [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'"
 | 
			
		||||
        [fullscreen]="false" class="safe-area-page margin">
 | 
			
		||||
        <ion-grid class="ion-no-padding">
 | 
			
		||||
            <ion-row class="ion-no-padding">
 | 
			
		||||
                <ion-col *ngFor="let course of timelineCourses.courses" class="ion-no-padding" size="12" size-md="6">
 | 
			
		||||
                    <core-courses-course-progress [course]="course">
 | 
			
		||||
                        <addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore"
 | 
			
		||||
                            (loadMore)="loadMoreCourse(course)" [from]="dataFrom" [to]="dataTo"></addon-block-timeline-events>
 | 
			
		||||
                    </core-courses-course-progress>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
            </ion-row>
 | 
			
		||||
        </ion-grid>
 | 
			
		||||
        <core-empty-box *ngIf="timelineCourses.courses.length == 0" image="assets/img/icons/courses.svg" inline="true"
 | 
			
		||||
            [message]="'addon.block_timeline.nocoursesinprogress' | translate"></core-empty-box>
 | 
			
		||||
    <core-loading [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'">
 | 
			
		||||
        <ng-container *ngFor="let course of timelineCourses.courses">
 | 
			
		||||
            <addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore" (loadMore)="loadMore(course)"
 | 
			
		||||
                [course]="course" [from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
        <core-empty-box *ngIf="timelineCourses.courses.length == 0" image="assets/img/icons/courses.svg"
 | 
			
		||||
            [message]="'addon.block_timeline.noevents' | translate"></core-empty-box>
 | 
			
		||||
    </core-loading>
 | 
			
		||||
</core-loading>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										38
									
								
								src/addons/block/timeline/components/timeline/timeline.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/addons/block/timeline/components/timeline/timeline.scss
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,38 @@
 | 
			
		||||
:host {
 | 
			
		||||
    ion-row.addon-block-timeline-filter {
 | 
			
		||||
        margin: 8px;
 | 
			
		||||
        padding: 0;
 | 
			
		||||
 | 
			
		||||
        ion-col {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            margin-right: 2px;
 | 
			
		||||
            margin-left: 2px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ion-button,
 | 
			
		||||
        core-combobox ::ng-deep ion-button {
 | 
			
		||||
            --border-width: 0;
 | 
			
		||||
            --a11y-min-target-size: 40px;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
 | 
			
		||||
            .select-icon {
 | 
			
		||||
                display: none;
 | 
			
		||||
            }
 | 
			
		||||
            ion-icon {
 | 
			
		||||
                font-size: 20px;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        core-combobox ::ng-deep ion-select {
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            --a11y-min-target-size: 40px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
       core-search-box {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            --height: 40px;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -13,7 +13,6 @@
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
 | 
			
		||||
import { AddonBlockTimeline } from '../../services/timeline';
 | 
			
		||||
@ -24,6 +23,7 @@ import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/
 | 
			
		||||
import { CoreSite } from '@classes/site';
 | 
			
		||||
import { CoreCourses } from '@features/courses/services/courses';
 | 
			
		||||
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render a timeline block.
 | 
			
		||||
@ -31,12 +31,13 @@ import { CoreCourseOptionsDelegate } from '@features/course/services/course-opti
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-block-timeline',
 | 
			
		||||
    templateUrl: 'addon-block-timeline.html',
 | 
			
		||||
    styleUrls: ['timeline.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    sort = 'sortbydates';
 | 
			
		||||
    filter = 'next30days';
 | 
			
		||||
    currentSite?: CoreSite;
 | 
			
		||||
    currentSite!: CoreSite;
 | 
			
		||||
    timeline: {
 | 
			
		||||
        events: AddonCalendarEvent[];
 | 
			
		||||
        loaded: boolean;
 | 
			
		||||
@ -57,24 +58,40 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
 | 
			
		||||
    dataFrom?: number;
 | 
			
		||||
    dataTo?: number;
 | 
			
		||||
    overdue = false;
 | 
			
		||||
 | 
			
		||||
    protected courseIds: number[] = [];
 | 
			
		||||
    searchEnabled = false;
 | 
			
		||||
    searchText = '';
 | 
			
		||||
 | 
			
		||||
    protected courseIdsToInvalidate: number[] = [];
 | 
			
		||||
    protected fetchContentDefaultError = 'Error getting timeline data.';
 | 
			
		||||
    protected gradePeriodAfter = 0;
 | 
			
		||||
    protected gradePeriodBefore = 0;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonBlockTimelineComponent');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        this.currentSite = CoreSites.getCurrentSite();
 | 
			
		||||
        try {
 | 
			
		||||
            this.currentSite = CoreSites.getRequiredCurrentSite();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModal(error);
 | 
			
		||||
 | 
			
		||||
        this.filter = await this.currentSite!.getLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
 | 
			
		||||
            CoreNavigator.back();
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.filter = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
 | 
			
		||||
        this.switchFilter(this.filter);
 | 
			
		||||
 | 
			
		||||
        this.sort = await this.currentSite!.getLocalSiteConfig('AddonBlockTimelineSort', this.sort);
 | 
			
		||||
        this.sort = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineSort', this.sort);
 | 
			
		||||
 | 
			
		||||
        this.searchEnabled = this.currentSite.isVersionGreaterEqualThan('4.0');
 | 
			
		||||
 | 
			
		||||
        super.ngOnInit();
 | 
			
		||||
    }
 | 
			
		||||
@ -91,8 +108,8 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
        promises.push(AddonBlockTimeline.invalidateActionEventsByCourses());
 | 
			
		||||
        promises.push(CoreCourses.invalidateUserCourses());
 | 
			
		||||
        promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions());
 | 
			
		||||
        if (this.courseIds.length > 0) {
 | 
			
		||||
            promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIds.join(',')));
 | 
			
		||||
        if (this.courseIdsToInvalidate.length > 0) {
 | 
			
		||||
            promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(',')));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return CoreUtils.allPromises(promises);
 | 
			
		||||
@ -117,28 +134,22 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load more events.
 | 
			
		||||
     */
 | 
			
		||||
    async loadMoreTimeline(): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            await this.fetchMyOverviewTimeline(this.timeline.canLoadMore);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load more events.
 | 
			
		||||
     *
 | 
			
		||||
     * @param course Course.
 | 
			
		||||
     * @param course Course. If defined, it will update the course events, timeline otherwise.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async loadMoreCourse(course: AddonBlockTimelineCourse): Promise<void> {
 | 
			
		||||
    async loadMore(course?: AddonBlockTimelineCourse): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            const courseEvents = await AddonBlockTimeline.getActionEventsByCourse(course.id, course.canLoadMore);
 | 
			
		||||
            course.events = course.events?.concat(courseEvents.events);
 | 
			
		||||
            course.canLoadMore = courseEvents.canLoadMore;
 | 
			
		||||
            if (course) {
 | 
			
		||||
                const courseEvents =
 | 
			
		||||
                    await AddonBlockTimeline.getActionEventsByCourse(course.id, course.canLoadMore, this.searchText);
 | 
			
		||||
                course.events = course.events?.concat(courseEvents.events);
 | 
			
		||||
                course.canLoadMore = courseEvents.canLoadMore;
 | 
			
		||||
            } else {
 | 
			
		||||
                await this.fetchMyOverviewTimeline(this.timeline.canLoadMore);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError);
 | 
			
		||||
        }
 | 
			
		||||
@ -151,9 +162,9 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchMyOverviewTimeline(afterEventId?: number): Promise<void> {
 | 
			
		||||
        const events = await AddonBlockTimeline.getActionEventsByTimesort(afterEventId);
 | 
			
		||||
        const events = await AddonBlockTimeline.getActionEventsByTimesort(afterEventId, this.searchText);
 | 
			
		||||
 | 
			
		||||
        this.timeline.events = events.events;
 | 
			
		||||
        this.timeline.events = afterEventId ? this.timeline.events.concat(events.events) : events.events;
 | 
			
		||||
        this.timeline.canLoadMore = events.canLoadMore;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -163,20 +174,36 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchMyOverviewTimelineByCourses(): Promise<void> {
 | 
			
		||||
        const courses = await CoreCoursesHelper.getUserCoursesWithOptions();
 | 
			
		||||
        const today = CoreTimeUtils.timestamp();
 | 
			
		||||
        try {
 | 
			
		||||
            this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter'), 10);
 | 
			
		||||
            this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore'), 10);
 | 
			
		||||
        } catch {
 | 
			
		||||
            this.gradePeriodAfter = 0;
 | 
			
		||||
            this.gradePeriodBefore = 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.timelineCourses.courses = courses.filter((course) =>
 | 
			
		||||
            (course.startdate || 0) <= today && (!course.enddate || course.enddate >= today));
 | 
			
		||||
        // Do not filter courses by date because they can contain activities due.
 | 
			
		||||
        this.timelineCourses.courses = await CoreCoursesHelper.getUserCoursesWithOptions();
 | 
			
		||||
        this.courseIdsToInvalidate = this.timelineCourses.courses.map((course) => course.id);
 | 
			
		||||
 | 
			
		||||
        // Filter only in progress courses.
 | 
			
		||||
        this.timelineCourses.courses = this.timelineCourses.courses.filter((course) =>
 | 
			
		||||
            !course.hidden &&
 | 
			
		||||
            !CoreCoursesHelper.isPastCourse(course, this.gradePeriodAfter) &&
 | 
			
		||||
            !CoreCoursesHelper.isFutureCourse(course, this.gradePeriodAfter, this.gradePeriodBefore));
 | 
			
		||||
 | 
			
		||||
        if (this.timelineCourses.courses.length > 0) {
 | 
			
		||||
            this.courseIds = this.timelineCourses.courses.map((course) => course.id);
 | 
			
		||||
            const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(this.courseIdsToInvalidate, this.searchText);
 | 
			
		||||
 | 
			
		||||
            const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(this.courseIds);
 | 
			
		||||
            this.timelineCourses.courses = this.timelineCourses.courses.filter((course) => {
 | 
			
		||||
                if (courseEvents[course.id].events.length == 0) {
 | 
			
		||||
                    return false;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            this.timelineCourses.courses.forEach((course) => {
 | 
			
		||||
                course.events = courseEvents[course.id].events;
 | 
			
		||||
                course.canLoadMore = courseEvents[course.id].canLoadMore;
 | 
			
		||||
 | 
			
		||||
                return true;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -188,12 +215,13 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
     */
 | 
			
		||||
    switchFilter(filter: string): void {
 | 
			
		||||
        this.filter = filter;
 | 
			
		||||
        this.currentSite?.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
 | 
			
		||||
        this.currentSite.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter);
 | 
			
		||||
        this.overdue = this.filter === 'overdue';
 | 
			
		||||
 | 
			
		||||
        switch (this.filter) {
 | 
			
		||||
            case 'overdue':
 | 
			
		||||
                this.dataFrom = -14;
 | 
			
		||||
                this.dataTo = 0;
 | 
			
		||||
                this.dataTo = 1;
 | 
			
		||||
                break;
 | 
			
		||||
            case 'next7days':
 | 
			
		||||
                this.dataFrom = 0;
 | 
			
		||||
@ -226,7 +254,7 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
     */
 | 
			
		||||
    switchSort(sort: string): void {
 | 
			
		||||
        this.sort = sort;
 | 
			
		||||
        this.currentSite?.setLocalSiteConfig('AddonBlockTimelineSort', this.sort);
 | 
			
		||||
        this.currentSite.setLocalSiteConfig('AddonBlockTimelineSort', this.sort);
 | 
			
		||||
 | 
			
		||||
        if (!this.timeline.loaded && this.sort == 'sortbydates') {
 | 
			
		||||
            this.fetchContent();
 | 
			
		||||
@ -235,9 +263,20 @@ export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implemen
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Search text changed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param searchValue Search value
 | 
			
		||||
     */
 | 
			
		||||
    searchTextChanged(searchValue = ''): void {
 | 
			
		||||
        this.searchText = searchValue || '';
 | 
			
		||||
 | 
			
		||||
        this.fetchContent();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & {
 | 
			
		||||
export type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & {
 | 
			
		||||
    events?: AddonCalendarEvent[];
 | 
			
		||||
    canLoadMore?: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -5,9 +5,10 @@
 | 
			
		||||
    "next6months": "Next 6 months",
 | 
			
		||||
    "next7days": "Next 7 days",
 | 
			
		||||
    "nocoursesinprogress": "No in-progress courses",
 | 
			
		||||
    "noevents": "No upcoming activities due",
 | 
			
		||||
    "noevents": "No activities require action",
 | 
			
		||||
    "overdue": "Overdue",
 | 
			
		||||
    "pluginname": "Timeline",
 | 
			
		||||
    "searchevents": "Search by activity type or name",
 | 
			
		||||
    "sortbycourses": "Sort by courses",
 | 
			
		||||
    "sortbydates": "Sort by dates"
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,7 @@ import { CoreCourses } from '@features/courses/services/courses';
 | 
			
		||||
import { AddonBlockTimelineComponent } from '@addons/block/timeline/components/timeline/timeline';
 | 
			
		||||
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonBlockTimeline } from './timeline';
 | 
			
		||||
import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Block handler.
 | 
			
		||||
@ -36,7 +36,7 @@ export class AddonBlockTimelineHandlerService extends CoreBlockBaseHandler {
 | 
			
		||||
     * @return Whether or not the handler is enabled on a site level.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        const enabled = await AddonBlockTimeline.isAvailable();
 | 
			
		||||
        const enabled = !CoreCoursesDashboard.isDisabledInSite();
 | 
			
		||||
        const currentSite = CoreSites.getCurrentSite();
 | 
			
		||||
 | 
			
		||||
        return enabled && ((currentSite && currentSite.isVersionGreaterEqualThan('3.6')) ||
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,6 @@
 | 
			
		||||
 | 
			
		||||
import { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
 | 
			
		||||
import {
 | 
			
		||||
    AddonCalendarEvents,
 | 
			
		||||
    AddonCalendarEventsGroupedByCourse,
 | 
			
		||||
@ -26,7 +25,6 @@ import {
 | 
			
		||||
import moment from 'moment';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { CoreSiteWSPreSets } from '@classes/site';
 | 
			
		||||
import { CoreError } from '@classes/errors/error';
 | 
			
		||||
 | 
			
		||||
// Cache key was maintained from block myoverview when blocks were splitted.
 | 
			
		||||
const ROOT_CACHE_KEY = 'myoverview:';
 | 
			
		||||
@ -45,17 +43,19 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
     *
 | 
			
		||||
     * @param courseId Only events in this course.
 | 
			
		||||
     * @param afterEventId The last seen event id.
 | 
			
		||||
     * @param searchValue The value a user wishes to search against.
 | 
			
		||||
     * @param siteId Site ID. If not defined, use current site.
 | 
			
		||||
     * @return Promise resolved when the info is retrieved.
 | 
			
		||||
     */
 | 
			
		||||
    async getActionEventsByCourse(
 | 
			
		||||
        courseId: number,
 | 
			
		||||
        afterEventId?: number,
 | 
			
		||||
        searchValue = '',
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        const time = moment().subtract(14, 'days').unix(); // Check two weeks ago.
 | 
			
		||||
        const time = this.getDayStart(-14); // Check two weeks ago.
 | 
			
		||||
 | 
			
		||||
        const data: AddonCalendarGetActionEventsByCourseWSParams = {
 | 
			
		||||
            timesortfrom: time,
 | 
			
		||||
@ -70,17 +70,18 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
            cacheKey: this.getActionEventsByCourseCacheKey(courseId),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (searchValue != '') {
 | 
			
		||||
            data.searchvalue = searchValue;
 | 
			
		||||
            preSets.getFromCache = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const courseEvents = await site.read<AddonCalendarEvents>(
 | 
			
		||||
            'core_calendar_get_action_events_by_course',
 | 
			
		||||
            data,
 | 
			
		||||
            preSets,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (courseEvents && courseEvents.events) {
 | 
			
		||||
            return this.treatCourseEvents(courseEvents, time);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new CoreError('No events returned on core_calendar_get_action_events_by_course.');
 | 
			
		||||
        return this.treatCourseEvents(courseEvents, time);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -98,15 +99,17 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
     *
 | 
			
		||||
     * @param courseIds Course IDs.
 | 
			
		||||
     * @param siteId Site ID. If not defined, use current site.
 | 
			
		||||
     * @param searchValue The value a user wishes to search against.
 | 
			
		||||
     * @return Promise resolved when the info is retrieved.
 | 
			
		||||
     */
 | 
			
		||||
    async getActionEventsByCourses(
 | 
			
		||||
        courseIds: number[],
 | 
			
		||||
        searchValue = '',
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        const time = moment().subtract(14, 'days').unix(); // Check two weeks ago.
 | 
			
		||||
        const time = this.getDayStart(-14); // Check two weeks ago.
 | 
			
		||||
 | 
			
		||||
        const data: AddonCalendarGetActionEventsByCoursesWSParams = {
 | 
			
		||||
            timesortfrom: time,
 | 
			
		||||
@ -117,6 +120,11 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
            cacheKey: this.getActionEventsByCoursesCacheKey(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (searchValue != '') {
 | 
			
		||||
            data.searchvalue = searchValue;
 | 
			
		||||
            preSets.getFromCache = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const events = await site.read<AddonCalendarEventsGroupedByCourse>(
 | 
			
		||||
            'core_calendar_get_action_events_by_courses',
 | 
			
		||||
            data,
 | 
			
		||||
@ -145,16 +153,18 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
     * Get calendar action events based on the timesort value.
 | 
			
		||||
     *
 | 
			
		||||
     * @param afterEventId The last seen event id.
 | 
			
		||||
     * @param searchValue The value a user wishes to search against.
 | 
			
		||||
     * @param siteId Site ID. If not defined, use current site.
 | 
			
		||||
     * @return Promise resolved when the info is retrieved.
 | 
			
		||||
     */
 | 
			
		||||
    async getActionEventsByTimesort(
 | 
			
		||||
        afterEventId?: number,
 | 
			
		||||
        searchValue = '',
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        const timesortfrom = moment().subtract(14, 'days').unix(); // Check two weeks ago.
 | 
			
		||||
        const timesortfrom = this.getDayStart(-14); // Check two weeks ago.
 | 
			
		||||
        const limitnum = AddonBlockTimelineProvider.EVENTS_LIMIT;
 | 
			
		||||
 | 
			
		||||
        const data: AddonCalendarGetActionEventsByTimesortWSParams = {
 | 
			
		||||
@ -171,25 +181,27 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
            uniqueCacheKey: true,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (searchValue != '') {
 | 
			
		||||
            data.searchvalue = searchValue;
 | 
			
		||||
            preSets.getFromCache = false;
 | 
			
		||||
            preSets.cacheKey += ':' + searchValue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const result = await site.read<AddonCalendarEvents>(
 | 
			
		||||
            'core_calendar_get_action_events_by_timesort',
 | 
			
		||||
            data,
 | 
			
		||||
            preSets,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (result && result.events) {
 | 
			
		||||
            const canLoadMore = result.events.length >= limitnum ? result.lastid : undefined;
 | 
			
		||||
        const canLoadMore = result.events.length >= limitnum ? result.lastid : undefined;
 | 
			
		||||
 | 
			
		||||
            // Filter events by time in case it uses cache.
 | 
			
		||||
            const events = result.events.filter((element) => element.timesort >= timesortfrom);
 | 
			
		||||
        // Filter events by time in case it uses cache.
 | 
			
		||||
        const events = result.events.filter((element) => element.timesort >= timesortfrom);
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                events,
 | 
			
		||||
                canLoadMore,
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new CoreError('No events returned on core_calendar_get_action_events_by_timesort.');
 | 
			
		||||
        return {
 | 
			
		||||
            events,
 | 
			
		||||
            canLoadMore,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -239,24 +251,6 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
        await site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByTimesortPrefixCacheKey());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns whether or not My Overview is available for a certain site.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with true if available, resolved with false or rejected otherwise.
 | 
			
		||||
     */
 | 
			
		||||
    async isAvailable(siteId?: string): Promise<boolean> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        // First check if dashboard is disabled.
 | 
			
		||||
        if (CoreCoursesDashboard.isDisabledInSite(site)) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return site.wsAvailable('core_calendar_get_action_events_by_courses') &&
 | 
			
		||||
            site.wsAvailable('core_calendar_get_action_events_by_timesort');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handles course events, filtering and treating if more can be loaded.
 | 
			
		||||
     *
 | 
			
		||||
@ -281,6 +275,16 @@ export class AddonBlockTimelineProvider {
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the timestamp at the start of the day with an optional offset.
 | 
			
		||||
     *
 | 
			
		||||
     * @param daysOffset Offset days to add or substract.
 | 
			
		||||
     * @return timestamp.
 | 
			
		||||
     */
 | 
			
		||||
    getDayStart(daysOffset = 0): number {
 | 
			
		||||
        return moment().startOf('day').add(daysOffset, 'days').unix();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const AddonBlockTimeline = makeSingleton(AddonBlockTimelineProvider);
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,7 @@ import { CoreCommentsComponentsModule } from '@features/comments/components/comp
 | 
			
		||||
import { CoreTagComponentsModule } from '@features/tag/components/components.module';
 | 
			
		||||
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
 | 
			
		||||
import { AddonBlogMainMenuHandlerService } from './services/handlers/mainmenu';
 | 
			
		||||
import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module';
 | 
			
		||||
 | 
			
		||||
function buildRoutes(injector: Injector): Routes {
 | 
			
		||||
    return [
 | 
			
		||||
@ -39,6 +40,7 @@ function buildRoutes(injector: Injector): Routes {
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
        CoreCommentsComponentsModule,
 | 
			
		||||
        CoreTagComponentsModule,
 | 
			
		||||
        CoreMainMenuComponentsModule,
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [RouterModule],
 | 
			
		||||
    providers: [
 | 
			
		||||
 | 
			
		||||
@ -51,8 +51,7 @@ const routes: Routes = [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => async () => {
 | 
			
		||||
            useValue: () => {
 | 
			
		||||
                CoreContentLinksDelegate.registerHandler(AddonBlogIndexLinkHandler.instance);
 | 
			
		||||
                CoreMainMenuDelegate.registerHandler(AddonBlogMainMenuHandler.instance);
 | 
			
		||||
                CoreUserDelegate.registerHandler(AddonBlogUserHandler.instance);
 | 
			
		||||
 | 
			
		||||
@ -3,21 +3,24 @@
 | 
			
		||||
        <ion-buttons slot="start">
 | 
			
		||||
            <ion-back-button [text]="'core.back' | translate"></ion-back-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
        <h1>{{ title | translate }}</h1>
 | 
			
		||||
        <ion-buttons slot="end"></ion-buttons>
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <h1>{{ title | translate }}</h1>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <core-user-menu-button></core-user-menu-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
<ion-content class="limited-width">
 | 
			
		||||
    <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refresh($event.target)">
 | 
			
		||||
        <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
 | 
			
		||||
    </ion-refresher>
 | 
			
		||||
    <core-loading [hideUntil]="loaded">
 | 
			
		||||
        <ion-item *ngIf="showMyEntriesToggle">
 | 
			
		||||
            <ion-label>{{ 'addon.blog.showonlyyourentries' | translate }}</ion-label>
 | 
			
		||||
            <ion-label>{{ 'addon.blog.showonlyyourentries' | translate }}</ion-label>
 | 
			
		||||
            <ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)"></ion-toggle>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
        <core-empty-box *ngIf="entries && entries.length == 0" icon="far-newspaper"
 | 
			
		||||
            [message]="'addon.blog.noentriesyet' | translate">
 | 
			
		||||
        <core-empty-box *ngIf="entries && entries.length == 0" icon="far-newspaper" [message]="'addon.blog.noentriesyet' | translate">
 | 
			
		||||
        </core-empty-box>
 | 
			
		||||
        <ng-container *ngFor="let entry of entries">
 | 
			
		||||
            <ion-card *ngIf="!onlyMyEntries || entry.userid == currentUserId">
 | 
			
		||||
@ -25,8 +28,7 @@
 | 
			
		||||
                    <core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid"></core-user-avatar>
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <p class="item-heading">
 | 
			
		||||
                            <core-format-text [text]="entry.subject" [contextLevel]="contextLevel"
 | 
			
		||||
                                [contextInstanceId]="contextInstanceId">
 | 
			
		||||
                            <core-format-text [text]="entry.subject" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId">
 | 
			
		||||
                            </core-format-text>
 | 
			
		||||
                            <ion-note class="ion-float-end ion-padding-start ion-text-end">
 | 
			
		||||
                                {{ 'addon.blog.' + entry.publishTranslated! | translate}}
 | 
			
		||||
@ -66,8 +68,9 @@
 | 
			
		||||
                </ion-card-content>
 | 
			
		||||
                <div class="ion-text-center ion-margin-bottom" *ngIf="entry.lastmodified > entry.created">
 | 
			
		||||
                    <ion-note>
 | 
			
		||||
                        <ion-icon name="fas-clock"
 | 
			
		||||
                            [attr.aria-label]="'core.lastmodified' | translate"></ion-icon> {{entry.lastmodified | coreTimeAgo}}
 | 
			
		||||
                        <ion-icon name="fas-clock" [attr.aria-label]="'core.lastmodified' | translate"></ion-icon> {{entry.lastmodified
 | 
			
		||||
                        |
 | 
			
		||||
                        coreTimeAgo}}
 | 
			
		||||
                    </ion-note>
 | 
			
		||||
                </div>
 | 
			
		||||
            </ion-card>
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,7 @@ import { ContextLevel } from '@/core/constants';
 | 
			
		||||
import { AddonBlog, AddonBlogFilter, AddonBlogPost, AddonBlogProvider } from '@addons/blog/services/blog';
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { CoreComments } from '@features/comments/services/comments';
 | 
			
		||||
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
 | 
			
		||||
import { CoreTag } from '@features/tag/services/tag';
 | 
			
		||||
import { CoreUser, CoreUserProfile } from '@features/user/services/user';
 | 
			
		||||
import { IonRefresher } from '@ionic/angular';
 | 
			
		||||
@ -42,6 +43,7 @@ export class AddonBlogEntriesPage implements OnInit {
 | 
			
		||||
    protected canLoadMoreEntries = false;
 | 
			
		||||
    protected canLoadMoreUserEntries = true;
 | 
			
		||||
    protected siteHomeId: number;
 | 
			
		||||
    protected fetchSuccess = false;
 | 
			
		||||
 | 
			
		||||
    loaded = false;
 | 
			
		||||
    canLoadMore = false;
 | 
			
		||||
@ -118,9 +120,10 @@ export class AddonBlogEntriesPage implements OnInit {
 | 
			
		||||
        this.commentsEnabled = !CoreComments.areCommentsDisabledInSite();
 | 
			
		||||
        this.tagsEnabled = CoreTag.areTagsAvailableInSite();
 | 
			
		||||
 | 
			
		||||
        await this.fetchEntries();
 | 
			
		||||
        const deepLinkManager = new CoreMainMenuDeepLinkManager();
 | 
			
		||||
        deepLinkManager.treatLink();
 | 
			
		||||
 | 
			
		||||
        CoreUtils.ignoreErrors(AddonBlog.logView(this.filter));
 | 
			
		||||
        await this.fetchEntries();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -170,15 +173,9 @@ export class AddonBlogEntriesPage implements OnInit {
 | 
			
		||||
                    entry.contextInstanceId = entry.userid;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                entry.summary = CoreTextUtils.instance.replacePluginfileUrls(entry.summary, entry.summaryfiles || []);
 | 
			
		||||
                entry.summary = CoreTextUtils.replacePluginfileUrls(entry.summary, entry.summaryfiles || []);
 | 
			
		||||
 | 
			
		||||
                return CoreUser.getProfile(entry.userid, entry.courseid, true).then((user) => {
 | 
			
		||||
                    entry.user = user;
 | 
			
		||||
 | 
			
		||||
                    return;
 | 
			
		||||
                }).catch(() => {
 | 
			
		||||
                    // Ignore errors.
 | 
			
		||||
                });
 | 
			
		||||
                entry.user = await CoreUtils.ignoreErrors(CoreUser.getProfile(entry.userid, entry.courseid, true));
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (refresh) {
 | 
			
		||||
@ -201,6 +198,11 @@ export class AddonBlogEntriesPage implements OnInit {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await Promise.all(promises);
 | 
			
		||||
 | 
			
		||||
            if (!this.fetchSuccess) {
 | 
			
		||||
                this.fetchSuccess = true;
 | 
			
		||||
                CoreUtils.ignoreErrors(AddonBlog.logView(this.filter));
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true);
 | 
			
		||||
            this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading.
 | 
			
		||||
 | 
			
		||||
@ -40,11 +40,12 @@ export class AddonBlogProvider {
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with true if enabled, resolved with false or rejected otherwise.
 | 
			
		||||
     * @since 3.6
 | 
			
		||||
     */
 | 
			
		||||
    async isPluginEnabled(siteId?: string): Promise<boolean> {
 | 
			
		||||
        const site = await CoreSites.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        return site.wsAvailable('core_blog_get_entries') &&site.canUseAdvancedFeature('enableblogs');
 | 
			
		||||
        return site.wsAvailable('core_blog_get_entries') && site.canUseAdvancedFeature('enableblogs');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -62,7 +62,7 @@ export class AddonBlogCourseOptionHandlerService implements CoreCourseOptionsHan
 | 
			
		||||
    ): Promise<boolean> {
 | 
			
		||||
        const enabled = await CoreCourseHelper.hasABlockNamed(courseId, 'blog_menu');
 | 
			
		||||
 | 
			
		||||
        if (enabled && navOptions && typeof navOptions.blogs != 'undefined') {
 | 
			
		||||
        if (enabled && navOptions && navOptions.blogs !== undefined) {
 | 
			
		||||
            return navOptions.blogs;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -26,7 +26,7 @@ export class AddonBlogMainMenuHandlerService implements CoreMainMenuHandler {
 | 
			
		||||
    static readonly PAGE_NAME = 'blog';
 | 
			
		||||
 | 
			
		||||
    name = 'AddonBlog';
 | 
			
		||||
    priority = 450;
 | 
			
		||||
    priority = 500;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
 | 
			
		||||
@ -13,8 +13,14 @@
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreUserProfileHandler, CoreUserProfileHandlerData, CoreUserDelegateService } from '@features/user/services/user-delegate';
 | 
			
		||||
import {
 | 
			
		||||
    CoreUserProfileHandler,
 | 
			
		||||
    CoreUserProfileHandlerData,
 | 
			
		||||
    CoreUserDelegateService,
 | 
			
		||||
    CoreUserDelegateContext,
 | 
			
		||||
} from '@features/user/services/user-delegate';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonBlog } from '../blog';
 | 
			
		||||
 | 
			
		||||
@ -24,8 +30,8 @@ import { AddonBlog } from '../blog';
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonBlog:blogs';
 | 
			
		||||
    priority = 300;
 | 
			
		||||
    name = 'AddonBlog'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
 | 
			
		||||
    priority = 200;
 | 
			
		||||
    type = CoreUserDelegateService.TYPE_NEW_PAGE;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -35,6 +41,27 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
 | 
			
		||||
        return AddonBlog.isPluginEnabled();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabledForContext(context: CoreUserDelegateContext): Promise<boolean> {
 | 
			
		||||
        // Check if feature is disabled.
 | 
			
		||||
        const currentSite = CoreSites.getCurrentSite();
 | 
			
		||||
        if (!currentSite) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (context === CoreUserDelegateContext.USER_MENU) {
 | 
			
		||||
            if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBlog:account')) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        } else if (currentSite.isFeatureDisabled('CoreUserDelegate_AddonBlog:blogs')) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
@ -43,11 +70,11 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
 | 
			
		||||
            icon: 'far-newspaper',
 | 
			
		||||
            title: 'addon.blog.blogentries',
 | 
			
		||||
            class: 'addon-blog-handler',
 | 
			
		||||
            action: (event, user, courseId): void => {
 | 
			
		||||
            action: (event, user, context, contextId): void => {
 | 
			
		||||
                event.preventDefault();
 | 
			
		||||
                event.stopPropagation();
 | 
			
		||||
                CoreNavigator.navigateToSitePath('/blog', {
 | 
			
		||||
                    params: { courseId, userId: user.id },
 | 
			
		||||
                    params: { courseId: contextId, userId: user.id },
 | 
			
		||||
                });
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
@ -1,28 +1,27 @@
 | 
			
		||||
@import "~theme/globals";
 | 
			
		||||
 | 
			
		||||
:host {
 | 
			
		||||
 | 
			
		||||
    --addon-calendar-blank-day-background-color: var(--gray-lighter);
 | 
			
		||||
    --addon-calendar-blank-day-background-color: var(--light);
 | 
			
		||||
 | 
			
		||||
    .item.addon-calendar-event {
 | 
			
		||||
        > ion-icon {
 | 
			
		||||
            color: white;
 | 
			
		||||
            border-radius: 50%;
 | 
			
		||||
            padding: 6px;
 | 
			
		||||
            padding: 0.7rem;
 | 
			
		||||
            --margin-vertical: 12px;
 | 
			
		||||
            --margin-end: 12px;
 | 
			
		||||
            margin-top: var(--margin-vertical);
 | 
			
		||||
            margin-bottom: var(--margin-vertical);
 | 
			
		||||
            @include margin-horizontal(null, var(--margin-end));
 | 
			
		||||
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &.addon-calendar-eventtype-category > ion-icon {
 | 
			
		||||
            background-color: var(--addon-calendar-event-category-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.addon-calendar-eventtype-course > ion-icon {
 | 
			
		||||
            background-color: var(--addon-calendar-event-course-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.addon-calendar-eventtype-group > ion-icon {
 | 
			
		||||
            background-color: var(--addon-calendar-event-group-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.addon-calendar-eventtype-user > ion-icon {
 | 
			
		||||
            background-color: var(--addon-calendar-event-user-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.addon-calendar-eventtype-site > ion-icon {
 | 
			
		||||
            background-color: var(--addon-calendar-event-site-color);
 | 
			
		||||
        @each $category, $value in $calendar-event-category-colors {
 | 
			
		||||
            &.addon-calendar-eventtype-#{$category} > ion-icon {
 | 
			
		||||
                background-color: $value;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -13,22 +13,11 @@
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injector, NgModule } from '@angular/core';
 | 
			
		||||
import { Route, RouterModule, ROUTES, Routes } from '@angular/router';
 | 
			
		||||
import { RouterModule, ROUTES, Routes } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
 | 
			
		||||
import { AddonCalendarMainMenuHandlerService } from './services/handlers/mainmenu';
 | 
			
		||||
 | 
			
		||||
export const AddonCalendarEditRoute: Route = {
 | 
			
		||||
    path: 'edit/:eventId',
 | 
			
		||||
    loadChildren: () =>
 | 
			
		||||
        import('@/addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AddonCalendarEventRoute: Route ={
 | 
			
		||||
    path: 'event/:id',
 | 
			
		||||
    loadChildren: () => import('@/addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function buildRoutes(injector: Injector): Routes {
 | 
			
		||||
    return [
 | 
			
		||||
        {
 | 
			
		||||
@ -36,27 +25,27 @@ function buildRoutes(injector: Injector): Routes {
 | 
			
		||||
            data: {
 | 
			
		||||
                mainMenuTabRoot: AddonCalendarMainMenuHandlerService.PAGE_NAME,
 | 
			
		||||
            },
 | 
			
		||||
            loadChildren: () => import('@/addons/calendar/pages/index/index.module').then(m => m.AddonCalendarIndexPageModule),
 | 
			
		||||
            loadChildren: () => import('@addons/calendar/pages/index/index.module').then(m => m.AddonCalendarIndexPageModule),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            path: 'list',
 | 
			
		||||
            data: {
 | 
			
		||||
                mainMenuTabRoot: AddonCalendarMainMenuHandlerService.PAGE_NAME,
 | 
			
		||||
            },
 | 
			
		||||
            loadChildren: () => import('@/addons/calendar/pages/list/list.module').then(m => m.AddonCalendarListPageModule),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            path: 'settings',
 | 
			
		||||
            path: 'calendar-settings',
 | 
			
		||||
            loadChildren: () =>
 | 
			
		||||
                import('@/addons/calendar/pages/settings/settings.module').then(m => m.AddonCalendarSettingsPageModule),
 | 
			
		||||
                import('@addons/calendar/pages/settings/settings.module').then(m => m.AddonCalendarSettingsPageModule),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            path: 'day',
 | 
			
		||||
            loadChildren: () =>
 | 
			
		||||
                import('@/addons/calendar/pages/day/day.module').then(m => m.AddonCalendarDayPageModule),
 | 
			
		||||
                import('@addons/calendar/pages/day/day.module').then(m => m.AddonCalendarDayPageModule),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            path: 'event/:id',
 | 
			
		||||
            loadChildren: () => import('@addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            path: 'edit/:eventId',
 | 
			
		||||
            loadChildren: () =>
 | 
			
		||||
                import('@addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule),
 | 
			
		||||
        },
 | 
			
		||||
        AddonCalendarEventRoute,
 | 
			
		||||
        AddonCalendarEditRoute,
 | 
			
		||||
        ...buildTabMainRoutes(injector, {
 | 
			
		||||
            redirectTo: 'index',
 | 
			
		||||
            pathMatch: 'full',
 | 
			
		||||
 | 
			
		||||
@ -63,8 +63,7 @@ const mainMenuChildrenRoutes: Routes = [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => async () => {
 | 
			
		||||
            useValue: async () => {
 | 
			
		||||
                CoreContentLinksDelegate.registerHandler(AddonCalendarViewLinkHandler.instance);
 | 
			
		||||
                CoreMainMenuDelegate.registerHandler(AddonCalendarMainMenuHandler.instance);
 | 
			
		||||
                CoreCronDelegate.register(AddonCalendarSyncCronHandler.instance);
 | 
			
		||||
 | 
			
		||||
@ -1,112 +1,110 @@
 | 
			
		||||
 | 
			
		||||
<!-- Add buttons to the nav bar. -->
 | 
			
		||||
<core-navbar-buttons slot="end" prepend>
 | 
			
		||||
    <core-context-menu>
 | 
			
		||||
        <core-context-menu-item *ngIf="canNavigate && !isCurrentMonth && displayNavButtons" [priority]="900"
 | 
			
		||||
        [content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day"
 | 
			
		||||
        (action)="goToCurrentMonth()"></core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="canNavigate && !selectedMonthIsCurrent() && displayNavButtons" [priority]="900"
 | 
			
		||||
            [content]="'addon.calendar.currentmonth' | translate" iconAction="fas-calendar-day" (action)="goToCurrentMonth()">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
    </core-context-menu>
 | 
			
		||||
</core-navbar-buttons>
 | 
			
		||||
 | 
			
		||||
<core-loading [hideUntil]="loaded" class="safe-area-page">
 | 
			
		||||
    <!-- Period name and arrows to navigate. -->
 | 
			
		||||
    <ion-grid class="ion-no-padding addon-calendar-navigation">
 | 
			
		||||
        <ion-row class="ion-align-items-center">
 | 
			
		||||
            <ion-col class="ion-text-start" *ngIf="canNavigate">
 | 
			
		||||
                <ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'core.previous' | translate">
 | 
			
		||||
                    <ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
            </ion-col>
 | 
			
		||||
            <ion-col class="ion-text-center addon-calendar-period">
 | 
			
		||||
                <h2 id="addon-calendar-monthname">{{ periodName }}</h2>
 | 
			
		||||
            </ion-col>
 | 
			
		||||
            <ion-col class="ion-text-end" *ngIf="canNavigate">
 | 
			
		||||
                <ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'core.next' | translate">
 | 
			
		||||
                    <ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
            </ion-col>
 | 
			
		||||
        </ion-row>
 | 
			
		||||
    </ion-grid>
 | 
			
		||||
 | 
			
		||||
    <!-- Calendar view. -->
 | 
			
		||||
    <ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname">
 | 
			
		||||
        <div role="rowgroup">
 | 
			
		||||
            <!-- List of days. -->
 | 
			
		||||
            <ion-row role="row">
 | 
			
		||||
                <ion-col class="ion-text-center addon-calendar-weekday" *ngFor="let day of weekDays" role="columnheader">
 | 
			
		||||
                    <span class="sr-only">{{ day.fullname | translate }}</span>
 | 
			
		||||
                    <span class="ion-hide-md-up" aria-hidden="true">{{ day.shortname | translate }}</span>
 | 
			
		||||
                    <span class="ion-hide-md-down" aria-hidden="true">{{ day.fullname | translate }}</span>
 | 
			
		||||
<core-loading [hideUntil]="loaded">
 | 
			
		||||
    <div class="core-swipe-slides-container">
 | 
			
		||||
        <!-- Period name and arrows to navigate. -->
 | 
			
		||||
        <ion-grid class="ion-no-padding addon-calendar-navigation">
 | 
			
		||||
            <ion-row class="ion-align-items-center">
 | 
			
		||||
                <ion-col class="ion-text-start" *ngIf="canNavigate">
 | 
			
		||||
                    <ion-button fill="clear" (click)="loadPrevious()" [attr.aria-label]="'core.previous' | translate">
 | 
			
		||||
                        <ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
 | 
			
		||||
                    </ion-button>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
                <ion-col class="ion-text-center addon-calendar-period">
 | 
			
		||||
                    <h2 id="addon-calendar-monthname">
 | 
			
		||||
                        {{ periodName }}
 | 
			
		||||
                        <ion-spinner *ngIf="!selectedMonthLoaded()" class="addon-calendar-loading-month">
 | 
			
		||||
                        </ion-spinner>
 | 
			
		||||
                    </h2>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
                <ion-col class="ion-text-end" *ngIf="canNavigate">
 | 
			
		||||
                    <ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'core.next' | translate">
 | 
			
		||||
                        <ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
 | 
			
		||||
                    </ion-button>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
            </ion-row>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div role="rowgroup">
 | 
			
		||||
        </ion-grid>
 | 
			
		||||
 | 
			
		||||
            <!-- Weeks. -->
 | 
			
		||||
            <ion-row *ngFor="let week of weeks" class="addon-calendar-week" role="row">
 | 
			
		||||
                <!-- Empty slots (first week). -->
 | 
			
		||||
                <ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell"></ion-col>
 | 
			
		||||
                <ion-col
 | 
			
		||||
                    *ngFor="let day of week.days"
 | 
			
		||||
                    class="addon-calendar-day ion-text-center"
 | 
			
		||||
                    [ngClass]='{
 | 
			
		||||
                        "hasevents": day.hasevents,
 | 
			
		||||
                        "today": isCurrentMonth && day.istoday,
 | 
			
		||||
                        "weekend": day.isweekend,
 | 
			
		||||
                        "duration_finish": day.haslastdayofevent
 | 
			
		||||
                    }'
 | 
			
		||||
                    [class.addon-calendar-event-past-day]="isPastMonth || day.ispast"
 | 
			
		||||
                    role="cell"
 | 
			
		||||
                    tabindex="0"
 | 
			
		||||
                    (ariaButtonClick)="dayClicked(day.mday)"
 | 
			
		||||
                >
 | 
			
		||||
                    <p class="addon-calendar-day-number" role="button">
 | 
			
		||||
                        <span aria-hidden="true">{{ day.mday }}</span>
 | 
			
		||||
                        <span class="sr-only">{{ day.periodName | translate }}</span>
 | 
			
		||||
                    </p>
 | 
			
		||||
 | 
			
		||||
                    <!-- In phone, display some dots to indicate the type of events. -->
 | 
			
		||||
                    <p class="ion-hide-md-up addon-calendar-dot-types"><span *ngFor="let type of day.calendareventtypes"
 | 
			
		||||
                        class="calendar_event_type calendar_event_{{type}}"></span></p>
 | 
			
		||||
 | 
			
		||||
                    <!-- In tablet, display list of events. -->
 | 
			
		||||
                    <div class="ion-hide-md-down addon-calendar-day-events">
 | 
			
		||||
                        <ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index">
 | 
			
		||||
                            <div
 | 
			
		||||
                                *ngIf="index < 3 || day.filteredEvents.length == 4"
 | 
			
		||||
                                class="addon-calendar-event"
 | 
			
		||||
                                [class.addon-calendar-event-past]="event.ispast"
 | 
			
		||||
                                role="button"
 | 
			
		||||
                                tabindex="0"
 | 
			
		||||
                                (ariaButtonClick)="eventClicked(event, $event)"
 | 
			
		||||
                            >
 | 
			
		||||
                                <span class="calendar_event_type calendar_event_{{event.formattedType}}"></span>
 | 
			
		||||
                                <ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock"
 | 
			
		||||
                                    [attr.aria-label]="'core.notsent' | translate"></ion-icon>
 | 
			
		||||
                                <ion-icon *ngIf="event.deleted" name="fas-trash"
 | 
			
		||||
                                    [attr.aria-label]="'core.deletedoffline' | translate"></ion-icon>
 | 
			
		||||
                                <span class="addon-calendar-event-time">
 | 
			
		||||
                                    {{ event.timestart * 1000 | coreFormatDate: timeFormat }}
 | 
			
		||||
                                </span>
 | 
			
		||||
                                <img *ngIf="event.moduleIcon" src="{{event.moduleIcon}}" alt="" role="presentation"
 | 
			
		||||
                                    class="core-module-icon">
 | 
			
		||||
                                <!-- Add the icon title so accessibility tools read it. -->
 | 
			
		||||
                                <span class="sr-only">
 | 
			
		||||
                                    {{ 'addon.calendar.type' + event.formattedType | translate }}
 | 
			
		||||
                                    <span class="sr-only" *ngIf="event.moduleIcon && event.iconTitle">{{ event.iconTitle }}</span>
 | 
			
		||||
                                </span>
 | 
			
		||||
                                <span class="addon-calendar-event-name">{{event.name}}</span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </ng-container>
 | 
			
		||||
                        <p *ngIf="day.filteredEvents.length > 4" class="addon-calendar-day-more">
 | 
			
		||||
                            <b>{{ 'core.nummore' | translate:{$a: day.filteredEvents.length - 3} }}</b>
 | 
			
		||||
                        </p>
 | 
			
		||||
        <core-swipe-slides [manager]="manager">
 | 
			
		||||
            <ng-template let-month="item">
 | 
			
		||||
                <!-- Calendar view. -->
 | 
			
		||||
                <ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname">
 | 
			
		||||
                    <div role="rowgroup">
 | 
			
		||||
                        <!-- List of days. -->
 | 
			
		||||
                        <ion-row role="row">
 | 
			
		||||
                            <ion-col class="ion-text-center addon-calendar-weekday" *ngFor="let day of month.weekDays" role="columnheader">
 | 
			
		||||
                                <span class="sr-only">{{ day.fullname | translate }}</span>
 | 
			
		||||
                                <span class="ion-hide-md-up" aria-hidden="true">{{ day.shortname | translate }}</span>
 | 
			
		||||
                                <span class="ion-hide-md-down" aria-hidden="true">{{ day.fullname | translate }}</span>
 | 
			
		||||
                            </ion-col>
 | 
			
		||||
                        </ion-row>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
                <!-- Empty slots (last week). -->
 | 
			
		||||
                <ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell"></ion-col>
 | 
			
		||||
            </ion-row>
 | 
			
		||||
        </div>
 | 
			
		||||
    </ion-grid>
 | 
			
		||||
                    <div role="rowgroup">
 | 
			
		||||
                        <!-- Weeks. -->
 | 
			
		||||
                        <ion-row *ngFor="let week of month.weeks" class="addon-calendar-week" role="row">
 | 
			
		||||
                            <!-- Empty slots (first week). -->
 | 
			
		||||
                            <ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day" role="cell">
 | 
			
		||||
                            </ion-col>
 | 
			
		||||
                            <ion-col *ngFor="let day of week.days" class="addon-calendar-day ion-text-center" [ngClass]='{
 | 
			
		||||
                                    "hasevents": day.hasevents,
 | 
			
		||||
                                    "today": month.isCurrentMonth && day.istoday,
 | 
			
		||||
                                    "weekend": day.isweekend,
 | 
			
		||||
                                    "duration_finish": day.haslastdayofevent
 | 
			
		||||
                                }' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" tabindex="0"
 | 
			
		||||
                                (ariaButtonClick)="dayClicked(day.mday)">
 | 
			
		||||
                                <p class="addon-calendar-day-number" role="button">
 | 
			
		||||
                                    <span aria-hidden="true">{{ day.mday }}</span>
 | 
			
		||||
                                    <span class="sr-only">{{ day.periodName | translate }}</span>
 | 
			
		||||
                                </p>
 | 
			
		||||
 | 
			
		||||
                                <!-- In phone, display some dots to indicate the type of events. -->
 | 
			
		||||
                                <p class="ion-hide-md-up addon-calendar-dot-types"><span *ngFor="let type of day.calendareventtypes"
 | 
			
		||||
                                        class="calendar_event_type calendar_event_{{type}}"></span></p>
 | 
			
		||||
 | 
			
		||||
                                <!-- In tablet, display list of events. -->
 | 
			
		||||
                                <div class="ion-hide-md-down addon-calendar-day-events" *ngIf="day.filteredEvents">
 | 
			
		||||
                                    <ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index">
 | 
			
		||||
                                        <div *ngIf="index < 3 || day.filteredEvents.length == 4" class="addon-calendar-event"
 | 
			
		||||
                                            [class.addon-calendar-event-past]="event.ispast" role="button" tabindex="0"
 | 
			
		||||
                                            (ariaButtonClick)="eventClicked(event, $event)">
 | 
			
		||||
                                            <span class="calendar_event_type calendar_event_{{event.formattedType}}"></span>
 | 
			
		||||
                                            <ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock"
 | 
			
		||||
                                                [attr.aria-label]="'core.notsent' | translate"></ion-icon>
 | 
			
		||||
                                            <ion-icon *ngIf="event.deleted" name="fas-trash"
 | 
			
		||||
                                                [attr.aria-label]="'core.deletedoffline' | translate"></ion-icon>
 | 
			
		||||
                                            <span class="addon-calendar-event-time">
 | 
			
		||||
                                                {{ event.timestart * 1000 | coreFormatDate: timeFormat }}
 | 
			
		||||
                                            </span>
 | 
			
		||||
                                            <!-- Add the icon title so accessibility tools read it. -->
 | 
			
		||||
                                            <span class="sr-only">
 | 
			
		||||
                                                {{ 'addon.calendar.type' + event.formattedType | translate }}
 | 
			
		||||
                                                <span class="sr-only" *ngIf="event.iconTitle">
 | 
			
		||||
                                                    {{ event.iconTitle }}
 | 
			
		||||
                                                </span>
 | 
			
		||||
                                            </span>
 | 
			
		||||
                                            <span class="addon-calendar-event-name">{{event.name}}</span>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    </ng-container>
 | 
			
		||||
                                    <p *ngIf="day.filteredEvents.length > 4" class="addon-calendar-day-more">
 | 
			
		||||
                                        <b>{{ 'core.nummore' | translate:{$a: day.filteredEvents.length - 3} }}</b>
 | 
			
		||||
                                    </p>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </ion-col>
 | 
			
		||||
                            <!-- Empty slots (last week). -->
 | 
			
		||||
                            <ion-col *ngFor="let value of week.postpadding" class="dayblank addon-calendar-day" role="cell">
 | 
			
		||||
                            </ion-col>
 | 
			
		||||
                        </ion-row>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </ion-grid>
 | 
			
		||||
            </ng-template>
 | 
			
		||||
        </core-swipe-slides>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
</core-loading>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,12 @@
 | 
			
		||||
@import "~theme/globals";
 | 
			
		||||
 | 
			
		||||
:host {
 | 
			
		||||
    --addon-calendar-blank-day-background-color: var(--gray-lighter);
 | 
			
		||||
    --addon-calendar-blank-day-background-color: var(--light);
 | 
			
		||||
 | 
			
		||||
    .core-swipe-slides-container ion-grid {
 | 
			
		||||
        flex: none;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .addon-calendar-navigation {
 | 
			
		||||
        padding-top: 5px;
 | 
			
		||||
@ -10,22 +17,22 @@
 | 
			
		||||
    .addon-calendar-months {
 | 
			
		||||
        background-color: var(--contrast-background);
 | 
			
		||||
        padding: 0;
 | 
			
		||||
        font-size: 14px;
 | 
			
		||||
        font-size: var(--text-size);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .addon-calendar-day {
 | 
			
		||||
        border-bottom: 1px solid var(--addon-calendar-border-color);
 | 
			
		||||
        border-right: 1px solid var(--addon-calendar-border-color);
 | 
			
		||||
        @include border-end(1px, solid var(--addon-calendar-border-color));
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        min-height: 60px;
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
 | 
			
		||||
        &:first-child {
 | 
			
		||||
            padding-left: 10px;
 | 
			
		||||
            @include padding-horizontal(10px, null);
 | 
			
		||||
        }
 | 
			
		||||
        &:last-child {
 | 
			
		||||
            border-right: 0;
 | 
			
		||||
            padding-left: 8px;
 | 
			
		||||
            @include border-end(0);
 | 
			
		||||
            @include padding-horizontal(8px, null);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &.addon-calendar-event-past-day > .addon-calendar-dot-types,
 | 
			
		||||
@ -48,7 +55,7 @@
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @media (min-width: 768px) {
 | 
			
		||||
        @include media-breakpoint-up(md) {
 | 
			
		||||
            .addon-calendar-day-number {
 | 
			
		||||
                text-align: start;
 | 
			
		||||
            }
 | 
			
		||||
@ -56,7 +63,7 @@
 | 
			
		||||
 | 
			
		||||
        &.today .addon-calendar-day-number span {
 | 
			
		||||
            border: 2px solid var(--addon-calendar-today-border-color);
 | 
			
		||||
            line-height: 20px;;
 | 
			
		||||
            line-height: 20px;
 | 
			
		||||
            border-radius: 50%;
 | 
			
		||||
        }
 | 
			
		||||
        &.dayblank {
 | 
			
		||||
@ -82,9 +89,7 @@
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .addon-calendar-day-more {
 | 
			
		||||
            margin-top: 0.6em;
 | 
			
		||||
            margin-bottom: 0.6em;
 | 
			
		||||
            margin-right: 4px;
 | 
			
		||||
            @include margin(0.6em, null, 0.6em, 4px);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .addon-calendar-dot-types {
 | 
			
		||||
@ -98,6 +103,10 @@
 | 
			
		||||
            margin-top: 10px;
 | 
			
		||||
            font-size: 1.2rem;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .addon-calendar-loading-month {
 | 
			
		||||
            height: 20px;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .addon-calendar-weekday {
 | 
			
		||||
@ -106,10 +115,10 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .addon-calendar-day-events {
 | 
			
		||||
        text-align: left;
 | 
			
		||||
        text-align: start;
 | 
			
		||||
 | 
			
		||||
        ion-icon {
 | 
			
		||||
            margin-right: 2px;
 | 
			
		||||
            @include margin-horizontal(null, 2px);
 | 
			
		||||
            font-size: 1em;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -127,66 +136,22 @@
 | 
			
		||||
        margin-right: 1px;
 | 
			
		||||
        margin-left: 1px;
 | 
			
		||||
 | 
			
		||||
        &.calendar_event_category {
 | 
			
		||||
            background-color: var(--addon-calendar-event-category-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.calendar_event_course {
 | 
			
		||||
            background-color: var(--addon-calendar-event-course-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.calendar_event_group {
 | 
			
		||||
            background-color: var(--addon-calendar-event-group-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.calendar_event_user {
 | 
			
		||||
            background-color: var(--addon-calendar-event-user-color);
 | 
			
		||||
        }
 | 
			
		||||
        &.calendar_event_site {
 | 
			
		||||
            background-color: var(--addon-calendar-event-site-color);
 | 
			
		||||
        @each $category, $value in $calendar-event-category-colors {
 | 
			
		||||
            &.calendar_event_#{$category} {
 | 
			
		||||
                background-color: $value;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .core-module-icon {
 | 
			
		||||
        margin-right: 1px;
 | 
			
		||||
        margin-left: 1px;
 | 
			
		||||
        --size: 16px;
 | 
			
		||||
        display: inline-block;
 | 
			
		||||
        vertical-align: bottom;
 | 
			
		||||
    }
 | 
			
		||||
    .core-module-icon[slot="start"] {
 | 
			
		||||
        padding: 6px;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host-context([dir=rtl]) {
 | 
			
		||||
    .addon-calendar-day-events {
 | 
			
		||||
        text-align: right;
 | 
			
		||||
 | 
			
		||||
        ion-icon {
 | 
			
		||||
            margin-right: unset;
 | 
			
		||||
            margin-left: 2px;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .addon-calendar-day {
 | 
			
		||||
        border-left: 1px solid var(--addon-calendar-border-color);
 | 
			
		||||
        border-right: unset;
 | 
			
		||||
 | 
			
		||||
        &:first-child {
 | 
			
		||||
            padding-right: 10px;
 | 
			
		||||
            padding-left: unset;
 | 
			
		||||
        }
 | 
			
		||||
        &:last-child {
 | 
			
		||||
            border-left: 0;
 | 
			
		||||
            border-right: unset;
 | 
			
		||||
            padding-right: 8px;
 | 
			
		||||
            padding-left: unset;
 | 
			
		||||
        }
 | 
			
		||||
        .addon-calendar-day-more {
 | 
			
		||||
            margin-left: 4px;
 | 
			
		||||
            margin-right: unset;
 | 
			
		||||
        }
 | 
			
		||||
    ion-slide {
 | 
			
		||||
        display: block;
 | 
			
		||||
        font-size: inherit;
 | 
			
		||||
        justify-content: start;
 | 
			
		||||
        align-items: start;
 | 
			
		||||
        text-align: start;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host-context(body.dark) {
 | 
			
		||||
    --addon-calendar-blank-day-background-color: var(--black);
 | 
			
		||||
    --addon-calendar-blank-day-background-color: var(--gray-900);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,8 @@ import {
 | 
			
		||||
    EventEmitter,
 | 
			
		||||
    KeyValueDiffers,
 | 
			
		||||
    KeyValueDiffer,
 | 
			
		||||
    ViewChild,
 | 
			
		||||
    HostBinding,
 | 
			
		||||
} from '@angular/core';
 | 
			
		||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
@ -40,7 +42,13 @@ import { AddonCalendarFilter, AddonCalendarHelper } from '../../services/calenda
 | 
			
		||||
import { AddonCalendarOffline } from '../../services/calendar-offline';
 | 
			
		||||
import { CoreCategoryData, CoreCourses } from '@features/courses/services/courses';
 | 
			
		||||
import { CoreApp } from '@services/app';
 | 
			
		||||
import { CoreLocalNotifications } from '@services/local-notifications';
 | 
			
		||||
import { CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides';
 | 
			
		||||
import {
 | 
			
		||||
    CoreSwipeSlidesDynamicItem,
 | 
			
		||||
    CoreSwipeSlidesDynamicItemsManagerSource,
 | 
			
		||||
} from '@classes/items-management/swipe-slides-dynamic-items-manager-source';
 | 
			
		||||
import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/swipe-slides-dynamic-items-manager';
 | 
			
		||||
import moment from 'moment';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component that displays a calendar.
 | 
			
		||||
@ -52,54 +60,31 @@ import { CoreLocalNotifications } from '@services/local-notifications';
 | 
			
		||||
})
 | 
			
		||||
export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent<PreloadedMonth>;
 | 
			
		||||
 | 
			
		||||
    @Input() initialYear?: number; // Initial year to load.
 | 
			
		||||
    @Input() initialMonth?: number; // Initial month to load.
 | 
			
		||||
    @Input() filter?: AddonCalendarFilter; // Filter to apply.
 | 
			
		||||
    @Input() hidden?: boolean; // Whether the component is hidden.
 | 
			
		||||
    @Input() canNavigate?: string | boolean; // Whether to include arrows to change the month. Defaults to true.
 | 
			
		||||
    @Input() displayNavButtons?: string | boolean; // Whether to display nav buttons created by this component. Defaults to true.
 | 
			
		||||
    @Output() onEventClicked = new EventEmitter<number>();
 | 
			
		||||
    @Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>();
 | 
			
		||||
 | 
			
		||||
    periodName?: string;
 | 
			
		||||
    weekDays: AddonCalendarWeekDaysTranslationKeys[] = [];
 | 
			
		||||
    weeks: AddonCalendarWeek[] = [];
 | 
			
		||||
    manager?: CoreSwipeSlidesDynamicItemsManager<PreloadedMonth, AddonCalendarMonthSlidesItemsManagerSource>;
 | 
			
		||||
    loaded = false;
 | 
			
		||||
    timeFormat?: string;
 | 
			
		||||
    isCurrentMonth = false;
 | 
			
		||||
    isPastMonth = false;
 | 
			
		||||
 | 
			
		||||
    protected year?: number;
 | 
			
		||||
    protected month?: number;
 | 
			
		||||
    protected categoriesRetrieved = false;
 | 
			
		||||
    protected categories: { [id: number]: CoreCategoryData } = {};
 | 
			
		||||
    protected currentSiteId: string;
 | 
			
		||||
    protected offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } =
 | 
			
		||||
        {}; // Offline events classified in month & day.
 | 
			
		||||
 | 
			
		||||
    protected offlineEditedEventsIds: number[] = []; // IDs of events edited in offline.
 | 
			
		||||
    protected deletedEvents: number[] = []; // Events deleted in offline.
 | 
			
		||||
    protected currentTime?: number;
 | 
			
		||||
    protected differ: KeyValueDiffer<unknown, unknown>; // To detect changes in the data input.
 | 
			
		||||
    // Observers.
 | 
			
		||||
    protected hiddenDiffer?: boolean; // To detect changes in the hidden input.
 | 
			
		||||
    protected filterDiffer: KeyValueDiffer<unknown, unknown>; // To detect changes in the filters input.
 | 
			
		||||
    // Observers and listeners.
 | 
			
		||||
    protected undeleteEventObserver: CoreEventObserver;
 | 
			
		||||
    protected obsDefaultTimeChange?: CoreEventObserver;
 | 
			
		||||
    protected managerUnsubscribe?: () => void;
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        differs: KeyValueDiffers,
 | 
			
		||||
    ) {
 | 
			
		||||
    constructor(differs: KeyValueDiffers) {
 | 
			
		||||
        this.currentSiteId = CoreSites.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        if (CoreLocalNotifications.isAvailable()) {
 | 
			
		||||
            // Re-schedule events if default time changes.
 | 
			
		||||
            this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => {
 | 
			
		||||
                this.weeks.forEach((week) => {
 | 
			
		||||
                    week.days.forEach((day) => {
 | 
			
		||||
                        AddonCalendar.scheduleEventsNotifications(day.eventsFormated!);
 | 
			
		||||
                    });
 | 
			
		||||
                });
 | 
			
		||||
            }, this.currentSiteId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Listen for events "undeleted" (offline).
 | 
			
		||||
        this.undeleteEventObserver = CoreEvents.on(
 | 
			
		||||
            AddonCalendarProvider.UNDELETED_EVENT_EVENT,
 | 
			
		||||
@ -112,27 +97,40 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
                this.undeleteEvent(data.eventId);
 | 
			
		||||
 | 
			
		||||
                // Remove it from the list of deleted events if it's there.
 | 
			
		||||
                const index = this.deletedEvents.indexOf(data.eventId);
 | 
			
		||||
                const index = this.manager?.getSource().deletedEvents.indexOf(data.eventId) ?? -1;
 | 
			
		||||
                if (index != -1) {
 | 
			
		||||
                    this.deletedEvents.splice(index, 1);
 | 
			
		||||
                    this.manager?.getSource().deletedEvents.splice(index, 1);
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            this.currentSiteId,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        this.differ = differs.find([]).create();
 | 
			
		||||
        this.hiddenDiffer = this.hidden;
 | 
			
		||||
        this.filterDiffer = differs.find(this.filter ?? {}).create();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @HostBinding('attr.hidden') get hiddenAttribute(): string | null {
 | 
			
		||||
        return this.hidden ? 'hidden' : null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component loaded.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        const now = new Date();
 | 
			
		||||
        this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate);
 | 
			
		||||
        this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true :
 | 
			
		||||
            CoreUtils.isTrueOrOne(this.displayNavButtons);
 | 
			
		||||
 | 
			
		||||
        this.year = this.initialYear ? this.initialYear : now.getFullYear();
 | 
			
		||||
        this.month = this.initialMonth ? this.initialMonth : now.getMonth() + 1;
 | 
			
		||||
 | 
			
		||||
        this.calculateIsCurrentMonth();
 | 
			
		||||
        const source = new AddonCalendarMonthSlidesItemsManagerSource(this, moment({
 | 
			
		||||
            year: this.initialYear,
 | 
			
		||||
            month: this.initialMonth ? this.initialMonth - 1 : undefined,
 | 
			
		||||
        }));
 | 
			
		||||
        this.manager = new CoreSwipeSlidesDynamicItemsManager(source);
 | 
			
		||||
        this.managerUnsubscribe = this.manager.addListener({
 | 
			
		||||
            onSelectedItemUpdated: (item) => {
 | 
			
		||||
                this.onMonthViewed(item);
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.fetchData();
 | 
			
		||||
    }
 | 
			
		||||
@ -141,17 +139,31 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
     * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays).
 | 
			
		||||
     */
 | 
			
		||||
    ngDoCheck(): void {
 | 
			
		||||
        this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate);
 | 
			
		||||
        this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true :
 | 
			
		||||
            CoreUtils.isTrueOrOne(this.displayNavButtons);
 | 
			
		||||
        const items = this.manager?.getSource().getItems();
 | 
			
		||||
 | 
			
		||||
        if (this.weeks) {
 | 
			
		||||
        if (items?.length) {
 | 
			
		||||
            // Check if there's any change in the filter object.
 | 
			
		||||
            const changes = this.differ.diff(this.filter!);
 | 
			
		||||
            const changes = this.filterDiffer.diff(this.filter ?? {});
 | 
			
		||||
            if (changes) {
 | 
			
		||||
                this.filterEvents();
 | 
			
		||||
                items.forEach((month) => {
 | 
			
		||||
                    if (month.loaded && month.weeks) {
 | 
			
		||||
                        this.manager?.getSource().filterEvents(month.weeks, this.filter);
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.hiddenDiffer !== this.hidden) {
 | 
			
		||||
            this.hiddenDiffer = this.hidden;
 | 
			
		||||
 | 
			
		||||
            if (!this.hidden) {
 | 
			
		||||
                this.slides?.slides?.getSwiper().then(swipper => swipper.update());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get timeFormat(): string {
 | 
			
		||||
        return this.manager?.getSource().timeFormat || 'core.strftimetime';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -160,41 +172,10 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async fetchData(): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
        promises.push(this.loadCategories());
 | 
			
		||||
 | 
			
		||||
        // Get offline events.
 | 
			
		||||
        promises.push(AddonCalendarOffline.getAllEditedEvents().then((events) => {
 | 
			
		||||
            // Classify them by month.
 | 
			
		||||
            this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events);
 | 
			
		||||
 | 
			
		||||
            // Get the IDs of events edited in offline.
 | 
			
		||||
            const filtered = events.filter((event) => event.id! > 0);
 | 
			
		||||
            this.offlineEditedEventsIds = filtered.map((event) => event.id!);
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        // Get events deleted in offline.
 | 
			
		||||
        promises.push(AddonCalendarOffline.getAllDeletedEventsIds().then((ids) => {
 | 
			
		||||
            this.deletedEvents = ids;
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        // Get time format to use.
 | 
			
		||||
        promises.push(AddonCalendar.getCalendarTimeFormat().then((value) => {
 | 
			
		||||
            this.timeFormat = value;
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await Promise.all(promises);
 | 
			
		||||
 | 
			
		||||
            await this.fetchEvents();
 | 
			
		||||
            await this.manager?.getSource().fetchData();
 | 
			
		||||
 | 
			
		||||
            await this.manager?.getSource().load(this.manager?.getSelectedItem());
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
 | 
			
		||||
        }
 | 
			
		||||
@ -203,113 +184,16 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fetch the events for current month.
 | 
			
		||||
     * Update data related to month being viewed.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     * @param month Month being viewed.
 | 
			
		||||
     */
 | 
			
		||||
    async fetchEvents(): Promise<void> {
 | 
			
		||||
        // Don't pass courseId and categoryId, we'll filter them locally.
 | 
			
		||||
        let result: { daynames: Partial<AddonCalendarDayName>[]; weeks: Partial<AddonCalendarWeek>[] };
 | 
			
		||||
        try {
 | 
			
		||||
            result = await AddonCalendar.getMonthlyEvents(this.year!, this.month!);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            if (!CoreApp.isOnline()) {
 | 
			
		||||
                // Allow navigating to non-cached months in offline (behave as if using emergency cache).
 | 
			
		||||
                result = await AddonCalendarHelper.getOfflineMonthWeeks(this.year!, this.month!);
 | 
			
		||||
            } else {
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    onMonthViewed(month: MonthBasicData): void {
 | 
			
		||||
        // Calculate the period name. We don't use the one in result because it's in server's language.
 | 
			
		||||
        this.periodName = CoreTimeUtils.userDate(
 | 
			
		||||
            new Date(this.year!, this.month! - 1).getTime(),
 | 
			
		||||
            month.moment.unix() * 1000,
 | 
			
		||||
            'core.strftimemonthyear',
 | 
			
		||||
        );
 | 
			
		||||
        this.weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno);
 | 
			
		||||
        this.weeks = result.weeks as AddonCalendarWeek[];
 | 
			
		||||
        this.calculateIsCurrentMonth();
 | 
			
		||||
 | 
			
		||||
        this.weeks.forEach((week) => {
 | 
			
		||||
            week.days.forEach((day) => {
 | 
			
		||||
                day.periodName = CoreTimeUtils.userDate(
 | 
			
		||||
                    new Date(this.year!, this.month! - 1, day.mday).getTime(),
 | 
			
		||||
                    'core.strftimedaydate',
 | 
			
		||||
                );
 | 
			
		||||
                day.eventsFormated = day.eventsFormated || [];
 | 
			
		||||
                day.filteredEvents = day.filteredEvents || [];
 | 
			
		||||
                day.events.forEach((event) => {
 | 
			
		||||
                    /// Format online events.
 | 
			
		||||
                    day.eventsFormated!.push(AddonCalendarHelper.formatEventData(event));
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (this.isCurrentMonth) {
 | 
			
		||||
            const currentDay = new Date().getDate();
 | 
			
		||||
            let isPast = true;
 | 
			
		||||
 | 
			
		||||
            this.weeks.forEach((week) => {
 | 
			
		||||
                week.days.forEach((day) => {
 | 
			
		||||
                    day.istoday = day.mday == currentDay;
 | 
			
		||||
                    day.ispast = isPast && !day.istoday;
 | 
			
		||||
                    isPast = day.ispast;
 | 
			
		||||
 | 
			
		||||
                    if (day.istoday) {
 | 
			
		||||
                        day.eventsFormated!.forEach((event) => {
 | 
			
		||||
                            event.ispast = this.isEventPast(event);
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        // Merge the online events with offline data.
 | 
			
		||||
        this.mergeEvents();
 | 
			
		||||
        // Filter events by course.
 | 
			
		||||
        this.filterEvents();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load categories to be able to filter events.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async loadCategories(): Promise<void> {
 | 
			
		||||
        if (this.categoriesRetrieved) {
 | 
			
		||||
            // Already retrieved, stop.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const cats = await CoreCourses.getCategories(0, true);
 | 
			
		||||
            this.categoriesRetrieved = true;
 | 
			
		||||
            this.categories = {};
 | 
			
		||||
 | 
			
		||||
            // Index categories by ID.
 | 
			
		||||
            cats.forEach((category) => {
 | 
			
		||||
                this.categories[category.id] = category;
 | 
			
		||||
            });
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Ignore errors.
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filter events based on the filter popover.
 | 
			
		||||
     */
 | 
			
		||||
    filterEvents(): void {
 | 
			
		||||
        this.weeks.forEach((week) => {
 | 
			
		||||
            week.days.forEach((day) => {
 | 
			
		||||
                day.filteredEvents = AddonCalendarHelper.getFilteredEvents(
 | 
			
		||||
                    day.eventsFormated!,
 | 
			
		||||
                    this.filter!,
 | 
			
		||||
                    this.categories,
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                // Re-calculate some properties.
 | 
			
		||||
                AddonCalendarHelper.calculateDayData(day, day.filteredEvents);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -318,55 +202,30 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
     * @param afterChange Whether the refresh is done after an event has changed or has been synced.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async refreshData(afterChange?: boolean): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
    async refreshData(afterChange = false): Promise<void> {
 | 
			
		||||
        const selectedMonth = this.manager?.getSelectedItem() || null;
 | 
			
		||||
 | 
			
		||||
        // Don't invalidate monthly events after a change, it has already been handled.
 | 
			
		||||
        if (!afterChange) {
 | 
			
		||||
            promises.push(AddonCalendar.invalidateMonthlyEvents(this.year!, this.month!));
 | 
			
		||||
        if (afterChange) {
 | 
			
		||||
            this.manager?.getSource().markAllItemsDirty();
 | 
			
		||||
        }
 | 
			
		||||
        promises.push(CoreCourses.invalidateCategories(0, true));
 | 
			
		||||
        promises.push(AddonCalendar.invalidateTimeFormat());
 | 
			
		||||
 | 
			
		||||
        this.categoriesRetrieved = false; // Get categories again.
 | 
			
		||||
        await this.manager?.getSource().invalidateContent(selectedMonth);
 | 
			
		||||
 | 
			
		||||
        await Promise.all(promises);
 | 
			
		||||
 | 
			
		||||
        this.fetchData();
 | 
			
		||||
        await this.fetchData();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load next month.
 | 
			
		||||
     */
 | 
			
		||||
    async loadNext(): Promise<void> {
 | 
			
		||||
        this.increaseMonth();
 | 
			
		||||
 | 
			
		||||
        this.loaded = false;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.fetchEvents();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
 | 
			
		||||
            this.decreaseMonth();
 | 
			
		||||
        }
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
    loadNext(): void {
 | 
			
		||||
        this.slides?.slideNext();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load previous month.
 | 
			
		||||
     */
 | 
			
		||||
    async loadPrevious(): Promise<void> {
 | 
			
		||||
        this.decreaseMonth();
 | 
			
		||||
 | 
			
		||||
        this.loaded = false;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.fetchEvents();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
 | 
			
		||||
            this.increaseMonth();
 | 
			
		||||
        }
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
    loadPrevious(): void {
 | 
			
		||||
        this.slides?.slidePrev();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -386,106 +245,53 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
     * @param day Day.
 | 
			
		||||
     */
 | 
			
		||||
    dayClicked(day: number): void {
 | 
			
		||||
        this.onDayClicked.emit({ day: day, month: this.month!, year: this.year! });
 | 
			
		||||
    }
 | 
			
		||||
        const selectedMonth = this.manager?.getSelectedItem();
 | 
			
		||||
        if (!selectedMonth) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if user is viewing the current month.
 | 
			
		||||
     */
 | 
			
		||||
    calculateIsCurrentMonth(): void {
 | 
			
		||||
        const now = new Date();
 | 
			
		||||
 | 
			
		||||
        this.currentTime = CoreTimeUtils.timestamp();
 | 
			
		||||
 | 
			
		||||
        this.isCurrentMonth = this.year == now.getFullYear() && this.month == now.getMonth() + 1;
 | 
			
		||||
        this.isPastMonth = this.year! < now.getFullYear() || (this.year == now.getFullYear() && this.month! < now.getMonth() + 1);
 | 
			
		||||
        this.onDayClicked.emit({ day: day, month: selectedMonth.moment.month() + 1, year: selectedMonth.moment.year() });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Go to current month.
 | 
			
		||||
     */
 | 
			
		||||
    async goToCurrentMonth(): Promise<void> {
 | 
			
		||||
        const now = new Date();
 | 
			
		||||
        const initialMonth = this.month;
 | 
			
		||||
        const initialYear = this.year;
 | 
			
		||||
 | 
			
		||||
        this.month = now.getMonth() + 1;
 | 
			
		||||
        this.year = now.getFullYear();
 | 
			
		||||
        const manager = this.manager;
 | 
			
		||||
        const slides = this.slides;
 | 
			
		||||
        if (!manager || !slides) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const currentMonth = {
 | 
			
		||||
            moment: moment(),
 | 
			
		||||
        };
 | 
			
		||||
        this.loaded = false;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.fetchEvents();
 | 
			
		||||
            this.isCurrentMonth = true;
 | 
			
		||||
            // Make sure the day is loaded.
 | 
			
		||||
            await manager.getSource().loadItem(currentMonth);
 | 
			
		||||
 | 
			
		||||
            slides.slideToItem(currentMonth);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true);
 | 
			
		||||
            this.year = initialYear;
 | 
			
		||||
            this.month = initialMonth;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Decrease the current month.
 | 
			
		||||
     */
 | 
			
		||||
    protected decreaseMonth(): void {
 | 
			
		||||
        if (this.month === 1) {
 | 
			
		||||
            this.month = 12;
 | 
			
		||||
            this.year!--;
 | 
			
		||||
        } else {
 | 
			
		||||
            this.month!--;
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.loaded = true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Increase the current month.
 | 
			
		||||
     * Check whether selected month is loaded.
 | 
			
		||||
     */
 | 
			
		||||
    protected increaseMonth(): void {
 | 
			
		||||
        if (this.month === 12) {
 | 
			
		||||
            this.month = 1;
 | 
			
		||||
            this.year!++;
 | 
			
		||||
        } else {
 | 
			
		||||
            this.month!++;
 | 
			
		||||
        }
 | 
			
		||||
    selectedMonthLoaded(): boolean {
 | 
			
		||||
        return !!this.manager?.getSelectedItem()?.loaded;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Merge online events with the offline events of that period.
 | 
			
		||||
     * Check whether selected month is current month.
 | 
			
		||||
     */
 | 
			
		||||
    protected mergeEvents(): void {
 | 
			
		||||
        const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } =
 | 
			
		||||
            this.offlineEvents[AddonCalendarHelper.getMonthId(this.year!, this.month!)];
 | 
			
		||||
 | 
			
		||||
        this.weeks.forEach((week) => {
 | 
			
		||||
            week.days.forEach((day) => {
 | 
			
		||||
 | 
			
		||||
                // Schedule notifications for the events retrieved (only future events will be scheduled).
 | 
			
		||||
                AddonCalendar.scheduleEventsNotifications(day.eventsFormated!);
 | 
			
		||||
 | 
			
		||||
                if (monthOfflineEvents || this.deletedEvents.length) {
 | 
			
		||||
                    // There is offline data, merge it.
 | 
			
		||||
 | 
			
		||||
                    if (this.deletedEvents.length) {
 | 
			
		||||
                        // Mark as deleted the events that were deleted in offline.
 | 
			
		||||
                        day.eventsFormated!.forEach((event) => {
 | 
			
		||||
                            event.deleted = this.deletedEvents.indexOf(event.id) != -1;
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (this.offlineEditedEventsIds.length) {
 | 
			
		||||
                        // Remove the online events that were modified in offline.
 | 
			
		||||
                        day.events = day.events.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (monthOfflineEvents && monthOfflineEvents[day.mday]) {
 | 
			
		||||
                        // Add the offline events (either new or edited).
 | 
			
		||||
                        day.eventsFormated =
 | 
			
		||||
                            AddonCalendarHelper.sortEvents(day.eventsFormated!.concat(monthOfflineEvents[day.mday]));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    selectedMonthIsCurrent(): boolean {
 | 
			
		||||
        return !!this.manager?.getSelectedItem()?.isCurrentMonth;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -493,17 +299,293 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
     *
 | 
			
		||||
     * @param eventId Event ID.
 | 
			
		||||
     */
 | 
			
		||||
    protected undeleteEvent(eventId: number): void {
 | 
			
		||||
        if (!this.weeks) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
    protected async undeleteEvent(eventId: number): Promise<void> {
 | 
			
		||||
        this.manager?.getSource().getItems()?.some((month) => {
 | 
			
		||||
            if (!month.loaded) {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        this.weeks.forEach((week) => {
 | 
			
		||||
            week.days.forEach((day) => {
 | 
			
		||||
                const event = day.eventsFormated!.find((event) => event.id == eventId);
 | 
			
		||||
            return month.weeks?.some((week) => week.days.some((day) => {
 | 
			
		||||
                const event = day.eventsFormated?.find((event) => event.id == eventId);
 | 
			
		||||
 | 
			
		||||
                if (event) {
 | 
			
		||||
                    event.deleted = false;
 | 
			
		||||
 | 
			
		||||
                    return true;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return false;
 | 
			
		||||
            }));
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component destroyed.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.undeleteEventObserver?.off();
 | 
			
		||||
        this.manager?.destroy();
 | 
			
		||||
        this.managerUnsubscribe && this.managerUnsubscribe();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Basic data to identify a month.
 | 
			
		||||
 */
 | 
			
		||||
type MonthBasicData = {
 | 
			
		||||
    moment: moment.Moment;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Preloaded month.
 | 
			
		||||
 */
 | 
			
		||||
type PreloadedMonth = MonthBasicData & CoreSwipeSlidesDynamicItem & {
 | 
			
		||||
    weekDays?: AddonCalendarWeekDaysTranslationKeys[];
 | 
			
		||||
    weeks?: AddonCalendarWeek[];
 | 
			
		||||
    isCurrentMonth?: boolean;
 | 
			
		||||
    isPastMonth?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Helper to manage swiping within months.
 | 
			
		||||
 */
 | 
			
		||||
class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource<PreloadedMonth> {
 | 
			
		||||
 | 
			
		||||
    categories?: { [id: number]: CoreCategoryData };
 | 
			
		||||
    // Offline events classified in month & day.
 | 
			
		||||
    offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = {};
 | 
			
		||||
    offlineEditedEventsIds: number[] = []; // IDs of events edited in offline.
 | 
			
		||||
    deletedEvents: number[] = []; // Events deleted in offline.
 | 
			
		||||
    timeFormat?: string;
 | 
			
		||||
 | 
			
		||||
    protected calendarComponent: AddonCalendarCalendarComponent;
 | 
			
		||||
 | 
			
		||||
    constructor(component: AddonCalendarCalendarComponent, initialMoment: moment.Moment) {
 | 
			
		||||
        super({ moment: initialMoment });
 | 
			
		||||
 | 
			
		||||
        this.calendarComponent = component;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fetch data.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async fetchData(): Promise<void> {
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            this.loadCategories(),
 | 
			
		||||
            this.loadOfflineEvents(),
 | 
			
		||||
            this.loadOfflineDeletedEvents(),
 | 
			
		||||
            this.loadTimeFormat(),
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filter events based on the filter popover.
 | 
			
		||||
     *
 | 
			
		||||
     * @param weeks Weeks with the events to filter.
 | 
			
		||||
     * @param filter Filter to apply.
 | 
			
		||||
     */
 | 
			
		||||
    filterEvents(weeks: AddonCalendarWeek[], filter?: AddonCalendarFilter): void {
 | 
			
		||||
        weeks.forEach((week) => {
 | 
			
		||||
            week.days.forEach((day) => {
 | 
			
		||||
                day.filteredEvents = AddonCalendarHelper.getFilteredEvents(
 | 
			
		||||
                    day.eventsFormated || [],
 | 
			
		||||
                    filter,
 | 
			
		||||
                    this.categories || {},
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                // Re-calculate some properties.
 | 
			
		||||
                AddonCalendarHelper.calculateDayData(day, day.filteredEvents);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load categories to be able to filter events.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async loadCategories(): Promise<void> {
 | 
			
		||||
        if (this.categories) {
 | 
			
		||||
            // Already retrieved, stop.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const categories = await CoreCourses.getCategories(0, true);
 | 
			
		||||
 | 
			
		||||
            // Index categories by ID.
 | 
			
		||||
            this.categories = CoreUtils.arrayToObject(categories, 'id');
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Ignore errors.
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load events created or edited in offline.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async loadOfflineEvents(): Promise<void> {
 | 
			
		||||
        // Get offline events.
 | 
			
		||||
        const events = await AddonCalendarOffline.getAllEditedEvents();
 | 
			
		||||
 | 
			
		||||
        // Classify them by month.
 | 
			
		||||
        this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events);
 | 
			
		||||
 | 
			
		||||
        // Get the IDs of events edited in offline.
 | 
			
		||||
        this.offlineEditedEventsIds = events.filter((event) => event.id > 0).map((event) => event.id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load events deleted in offline.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async loadOfflineDeletedEvents(): Promise<void> {
 | 
			
		||||
        this.deletedEvents = await AddonCalendarOffline.getAllDeletedEventsIds();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load time format.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async loadTimeFormat(): Promise<void> {
 | 
			
		||||
        this.timeFormat = await AddonCalendar.getCalendarTimeFormat();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    getItemId(item: MonthBasicData): string | number {
 | 
			
		||||
        return AddonCalendarHelper.getMonthId(item.moment);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    getPreviousItem(item: MonthBasicData): MonthBasicData | null {
 | 
			
		||||
        return {
 | 
			
		||||
            moment: item.moment.clone().subtract(1, 'month'),
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    getNextItem(item: MonthBasicData): MonthBasicData | null {
 | 
			
		||||
        return {
 | 
			
		||||
            moment: item.moment.clone().add(1, 'month'),
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    async loadItemData(month: MonthBasicData, preload = false): Promise<PreloadedMonth | null> {
 | 
			
		||||
        // Load or preload the weeks.
 | 
			
		||||
        let result: { daynames: Partial<AddonCalendarDayName>[]; weeks: Partial<AddonCalendarWeek>[] };
 | 
			
		||||
        const year = month.moment.year();
 | 
			
		||||
        const monthNumber = month.moment.month() + 1;
 | 
			
		||||
 | 
			
		||||
        if (preload) {
 | 
			
		||||
            result = await AddonCalendarHelper.getOfflineMonthWeeks(year, monthNumber);
 | 
			
		||||
        } else {
 | 
			
		||||
            try {
 | 
			
		||||
                // Don't pass courseId and categoryId, we'll filter them locally.
 | 
			
		||||
                result = await AddonCalendar.getMonthlyEvents(year, monthNumber);
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                if (!CoreApp.isOnline()) {
 | 
			
		||||
                    // Allow navigating to non-cached months in offline (behave as if using emergency cache).
 | 
			
		||||
                    result = await AddonCalendarHelper.getOfflineMonthWeeks(year, monthNumber);
 | 
			
		||||
                } else {
 | 
			
		||||
                    throw error;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const weekDays = AddonCalendar.getWeekDays(result.daynames[0].dayno);
 | 
			
		||||
        const weeks = result.weeks as AddonCalendarWeek[];
 | 
			
		||||
        const currentDay = new Date().getDate();
 | 
			
		||||
        const currentTime = CoreTimeUtils.timestamp();
 | 
			
		||||
 | 
			
		||||
        const preloadedMonth: PreloadedMonth = {
 | 
			
		||||
            ...month,
 | 
			
		||||
            weeks,
 | 
			
		||||
            weekDays,
 | 
			
		||||
            isCurrentMonth: month.moment.isSame(moment(), 'month'),
 | 
			
		||||
            isPastMonth: month.moment.isBefore(moment(), 'month'),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await Promise.all(weeks.map(async (week) => {
 | 
			
		||||
            await Promise.all(week.days.map(async (day) => {
 | 
			
		||||
                day.periodName = CoreTimeUtils.userDate(
 | 
			
		||||
                    month.moment.unix() * 1000,
 | 
			
		||||
                    'core.strftimedaydate',
 | 
			
		||||
                );
 | 
			
		||||
                day.eventsFormated = day.eventsFormated || [];
 | 
			
		||||
                day.filteredEvents = day.filteredEvents || [];
 | 
			
		||||
                // Format online events.
 | 
			
		||||
                const onlineEventsFormatted = await Promise.all(
 | 
			
		||||
                    day.events.map(async (event) => AddonCalendarHelper.formatEventData(event)),
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted);
 | 
			
		||||
 | 
			
		||||
                if (preloadedMonth.isCurrentMonth) {
 | 
			
		||||
                    day.istoday = day.mday == currentDay;
 | 
			
		||||
                    day.ispast = preloadedMonth.isPastMonth || day.mday < currentDay;
 | 
			
		||||
 | 
			
		||||
                    if (day.istoday) {
 | 
			
		||||
                        day.eventsFormated?.forEach((event) => {
 | 
			
		||||
                            event.ispast = this.isEventPast(event, currentTime);
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }));
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        if (!preload) {
 | 
			
		||||
            // Merge the online events with offline data.
 | 
			
		||||
            this.mergeEvents(month, weeks);
 | 
			
		||||
            // Filter events by course.
 | 
			
		||||
            this.filterEvents(weeks, this.calendarComponent.filter);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return preloadedMonth;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Merge online events with the offline events of that period.
 | 
			
		||||
     *
 | 
			
		||||
     * @param month Month.
 | 
			
		||||
     * @param weeks Weeks with the events to filter.
 | 
			
		||||
     */
 | 
			
		||||
    mergeEvents(month: MonthBasicData, weeks: AddonCalendarWeek[]): void {
 | 
			
		||||
        const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } =
 | 
			
		||||
            this.offlineEvents[AddonCalendarHelper.getMonthId(month.moment)];
 | 
			
		||||
 | 
			
		||||
        weeks.forEach((week) => {
 | 
			
		||||
            week.days.forEach((day) => {
 | 
			
		||||
                if (this.deletedEvents.length) {
 | 
			
		||||
                    // Mark as deleted the events that were deleted in offline.
 | 
			
		||||
                    day.eventsFormated?.forEach((event) => {
 | 
			
		||||
                        event.deleted = this.deletedEvents.indexOf(event.id) != -1;
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (this.offlineEditedEventsIds.length) {
 | 
			
		||||
                    // Remove the online events that were modified in offline.
 | 
			
		||||
                    day.events = day.events.filter((event) => this.offlineEditedEventsIds.indexOf(event.id) == -1);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (monthOfflineEvents && monthOfflineEvents[day.mday] && day.eventsFormated) {
 | 
			
		||||
                    // Add the offline events (either new or edited).
 | 
			
		||||
                    day.eventsFormated =
 | 
			
		||||
                        AddonCalendarHelper.sortEvents(day.eventsFormated.concat(monthOfflineEvents[day.mday]));
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
@ -513,18 +595,35 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro
 | 
			
		||||
     * Returns if the event is in the past or not.
 | 
			
		||||
     *
 | 
			
		||||
     * @param event Event object.
 | 
			
		||||
     * @param currentTime Current time.
 | 
			
		||||
     * @return True if it's in the past.
 | 
			
		||||
     */
 | 
			
		||||
    protected isEventPast(event: { timestart: number; timeduration: number}): boolean {
 | 
			
		||||
        return (event.timestart + event.timeduration) < this.currentTime!;
 | 
			
		||||
    isEventPast(event: { timestart: number; timeduration: number}, currentTime: number): boolean {
 | 
			
		||||
        return (event.timestart + event.timeduration) < currentTime;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component destroyed.
 | 
			
		||||
     * Invalidate content.
 | 
			
		||||
     *
 | 
			
		||||
     * @param selectedMonth The current selected month.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        this.undeleteEventObserver?.off();
 | 
			
		||||
        this.obsDefaultTimeChange?.off();
 | 
			
		||||
    async invalidateContent(selectedMonth: PreloadedMonth | null): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
        if (selectedMonth) {
 | 
			
		||||
            promises.push(AddonCalendar.invalidateMonthlyEvents(selectedMonth.moment.year(), selectedMonth.moment.month() + 1));
 | 
			
		||||
        }
 | 
			
		||||
        promises.push(CoreCourses.invalidateCategories(0, true));
 | 
			
		||||
        promises.push(AddonCalendar.invalidateTimeFormat());
 | 
			
		||||
 | 
			
		||||
        this.categories = undefined; // Get categories again.
 | 
			
		||||
 | 
			
		||||
        if (selectedMonth) {
 | 
			
		||||
            selectedMonth.dirty = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await Promise.all(promises);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -18,13 +18,15 @@ import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
 | 
			
		||||
import { AddonCalendarCalendarComponent } from './calendar/calendar';
 | 
			
		||||
import { AddonCalendarUpcomingEventsComponent } from './upcoming-events/upcoming-events';
 | 
			
		||||
import { AddonCalendarFilterPopoverComponent } from './filter/filter';
 | 
			
		||||
import { AddonCalendarFilterComponent } from './filter/filter';
 | 
			
		||||
import { AddonCalendarReminderTimeModalComponent } from './reminder-time-modal/reminder-time-modal';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonCalendarCalendarComponent,
 | 
			
		||||
        AddonCalendarUpcomingEventsComponent,
 | 
			
		||||
        AddonCalendarFilterPopoverComponent,
 | 
			
		||||
        AddonCalendarFilterComponent,
 | 
			
		||||
        AddonCalendarReminderTimeModalComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
@ -34,7 +36,8 @@ import { AddonCalendarFilterPopoverComponent } from './filter/filter';
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonCalendarCalendarComponent,
 | 
			
		||||
        AddonCalendarUpcomingEventsComponent,
 | 
			
		||||
        AddonCalendarFilterPopoverComponent,
 | 
			
		||||
        AddonCalendarFilterComponent,
 | 
			
		||||
        AddonCalendarReminderTimeModalComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonCalendarComponentsModule {}
 | 
			
		||||
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
<ion-list>
 | 
			
		||||
    <ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
 | 
			
		||||
        <ion-icon [name]="typeIcons[type]" slot="start" aria-hidden="true"></ion-icon>
 | 
			
		||||
        <ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
 | 
			
		||||
        <ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
    <core-spacer *ngIf="filter.course || filter.category || filter.group"></core-spacer>
 | 
			
		||||
    <ng-container *ngIf="filter.course || filter.category || filter.group">
 | 
			
		||||
        <ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngFor="let course of courses">
 | 
			
		||||
                <ion-label><core-format-text [text]="course.fullname"></core-format-text></ion-label>
 | 
			
		||||
                <ion-radio slot="end" [value]="course.id"></ion-radio>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-radio-group>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
</ion-list>
 | 
			
		||||
@ -1,21 +0,0 @@
 | 
			
		||||
:host {
 | 
			
		||||
    ion-item {
 | 
			
		||||
        ion-icon, ion-radio {
 | 
			
		||||
            margin-right: 8px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        > ion-icon {
 | 
			
		||||
            padding: 4px;
 | 
			
		||||
            font-size: 20px;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host-context([dir=rtl]) {
 | 
			
		||||
    ion-item {
 | 
			
		||||
        ion-icon, ion-radio {
 | 
			
		||||
            margin-left: 8px;
 | 
			
		||||
            margin-right: unset;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								src/addons/calendar/components/filter/filter.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/addons/calendar/components/filter/filter.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,29 @@
 | 
			
		||||
<ion-header class="no-title">
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
 | 
			
		||||
                <ion-icon name="fas-times" slot="icon-only" aria-hidden=true></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content [fullscreen]="true">
 | 
			
		||||
    <ion-list>
 | 
			
		||||
        <ion-item *ngFor="let type of types" class="addon-calendar-event" [ngClass]="['addon-calendar-eventtype-'+type]">
 | 
			
		||||
            <ion-icon [name]="typeIcons[type]" slot="start" aria-hidden="true"></ion-icon>
 | 
			
		||||
            <ion-label>{{ 'addon.calendar.' + type + 'events' | translate}}</ion-label>
 | 
			
		||||
            <ion-toggle [(ngModel)]="filter[type]" (ionChange)="onChange()" slot="end"></ion-toggle>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
        <core-spacer *ngIf="filter.course || filter.category || filter.group"></core-spacer>
 | 
			
		||||
        <ng-container *ngIf="filter.course || filter.category || filter.group">
 | 
			
		||||
            <ion-radio-group [(ngModel)]="courseId" (ionChange)="onChange()">
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngFor="let course of courses">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <core-format-text [text]="course.fullname"></core-format-text>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                    <ion-radio slot="end" [value]="course.id"></ion-radio>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-radio-group>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
    </ion-list>
 | 
			
		||||
</ion-content>
 | 
			
		||||
							
								
								
									
										14
									
								
								src/addons/calendar/components/filter/filter.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/addons/calendar/components/filter/filter.scss
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
@import "~theme/globals";
 | 
			
		||||
 | 
			
		||||
:host {
 | 
			
		||||
    ion-item {
 | 
			
		||||
        ion-icon, ion-radio {
 | 
			
		||||
            @include margin-horizontal(null, 8px);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        > ion-icon {
 | 
			
		||||
            padding: 4px;
 | 
			
		||||
            font-size: 20px;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -15,6 +15,7 @@
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { CoreEnrolledCourseData } from '@features/courses/services/courses';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { ModalController } from '@singletons';
 | 
			
		||||
import { CoreEvents } from '@singletons/events';
 | 
			
		||||
import { AddonCalendarEventType, AddonCalendarProvider } from '../../services/calendar';
 | 
			
		||||
import { AddonCalendarFilter, AddonCalendarEventIcons } from '../../services/calendar-helper';
 | 
			
		||||
@ -23,11 +24,11 @@ import { AddonCalendarFilter, AddonCalendarEventIcons } from '../../services/cal
 | 
			
		||||
 * Component to display the events filter that includes events types and a list of courses.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-calendar-filter-popover',
 | 
			
		||||
    templateUrl: 'addon-calendar-filter-popover.html',
 | 
			
		||||
    styleUrls: ['../../calendar-common.scss', 'filter-popover.scss'],
 | 
			
		||||
    selector: 'addon-calendar-filter',
 | 
			
		||||
    templateUrl: 'filter.html',
 | 
			
		||||
    styleUrls: ['../../calendar-common.scss', 'filter.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonCalendarFilterPopoverComponent implements OnInit {
 | 
			
		||||
export class AddonCalendarFilterComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @Input() filter: AddonCalendarFilter = {
 | 
			
		||||
        filtered: false,
 | 
			
		||||
@ -56,7 +57,7 @@ export class AddonCalendarFilterPopoverComponent implements OnInit {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Init the component.
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        this.courseId = this.filter.courseId || -1;
 | 
			
		||||
@ -80,4 +81,11 @@ export class AddonCalendarFilterPopoverComponent implements OnInit {
 | 
			
		||||
        CoreEvents.trigger(AddonCalendarProvider.FILTER_CHANGED_EVENT, this.filter);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Close modal.
 | 
			
		||||
     */
 | 
			
		||||
    closeModal(): void {
 | 
			
		||||
        ModalController.dismiss();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,62 @@
 | 
			
		||||
<ion-header>
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <h2>{{ 'addon.calendar.reminders' | translate }}</h2>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
 | 
			
		||||
                <ion-icon slot="icon-only" name="fas-times" aria-hidden="true"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
    <form (ngSubmit)="saveReminder()">
 | 
			
		||||
        <ion-radio-group name="radiovalue" [(ngModel)]="radioValue" class="ion-text-wrap">
 | 
			
		||||
            <!-- Preset options. -->
 | 
			
		||||
            <ion-item *ngIf="allowDisable">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ 'core.settings.disabled' | translate }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
                <ion-radio slot="end" value="disabled"></ion-radio>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item *ngFor="let option of presetOptions">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ option.label }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
                <ion-radio slot="end" [value]="option.radioValue"></ion-radio>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- Custom value. -->
 | 
			
		||||
            <ion-item class="ion-text-wrap">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ 'core.custom' | translate }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
                <ion-radio slot="end" value="custom"></ion-radio>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" (click)="customInputClicked($event)">
 | 
			
		||||
                <ion-label></ion-label>
 | 
			
		||||
 | 
			
		||||
                <div class="flex-row">
 | 
			
		||||
                    <!-- Input to enter the value. -->
 | 
			
		||||
                    <ion-input type="number" name="customvalue" [(ngModel)]="customValue" [disabled]="radioValue != 'custom'"
 | 
			
		||||
                        placeholder="10" (click)="customInputClicked($event)">
 | 
			
		||||
                    </ion-input>
 | 
			
		||||
 | 
			
		||||
                    <!-- Units. -->
 | 
			
		||||
                    <label class="accesshide" for="reminderUnits">{{ 'addon.calendar.units' | translate }}</label>
 | 
			
		||||
                    <ion-select id="reminderUnits" name="customunits" [(ngModel)]="customUnits" interface="action-sheet"
 | 
			
		||||
                        [disabled]="radioValue != 'custom'" slot="end" [interfaceOptions]="{header: 'addon.calendar.units' | translate}">
 | 
			
		||||
                        <ion-select-option *ngFor="let option of customUnitsOptions" [value]="option.value">
 | 
			
		||||
                            {{ option.label | translate }}
 | 
			
		||||
                        </ion-select-option>
 | 
			
		||||
                    </ion-select>
 | 
			
		||||
                </div>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-radio-group>
 | 
			
		||||
 | 
			
		||||
        <ion-button type="submit" class="ion-margin" expand="block" [disabled]="radioValue == 'custom' && !customValue">
 | 
			
		||||
            {{ 'core.done' | translate }}
 | 
			
		||||
        </ion-button>
 | 
			
		||||
    </form>
 | 
			
		||||
</ion-content>
 | 
			
		||||
@ -0,0 +1,174 @@
 | 
			
		||||
// (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 { AddonCalendar, AddonCalendarReminderUnits, AddonCalendarValueAndUnit } from '@addons/calendar/services/calendar';
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { ModalController } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Modal to choose a reminder time.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-calendar-new-reminder-modal',
 | 
			
		||||
    templateUrl: 'reminder-time-modal.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonCalendarReminderTimeModalComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @Input() initialValue?: AddonCalendarValueAndUnit;
 | 
			
		||||
    @Input() allowDisable?: boolean;
 | 
			
		||||
 | 
			
		||||
    radioValue = '5m';
 | 
			
		||||
    customValue = '10';
 | 
			
		||||
    customUnits = AddonCalendarReminderUnits.MINUTE;
 | 
			
		||||
 | 
			
		||||
    presetOptions = [
 | 
			
		||||
        {
 | 
			
		||||
            radioValue: '5m',
 | 
			
		||||
            value: 5,
 | 
			
		||||
            unit: AddonCalendarReminderUnits.MINUTE,
 | 
			
		||||
            label: '',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            radioValue: '10m',
 | 
			
		||||
            value: 10,
 | 
			
		||||
            unit: AddonCalendarReminderUnits.MINUTE,
 | 
			
		||||
            label: '',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            radioValue: '30m',
 | 
			
		||||
            value: 30,
 | 
			
		||||
            unit: AddonCalendarReminderUnits.MINUTE,
 | 
			
		||||
            label: '',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            radioValue: '1h',
 | 
			
		||||
            value: 1,
 | 
			
		||||
            unit: AddonCalendarReminderUnits.HOUR,
 | 
			
		||||
            label: '',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            radioValue: '12h',
 | 
			
		||||
            value: 12,
 | 
			
		||||
            unit: AddonCalendarReminderUnits.HOUR,
 | 
			
		||||
            label: '',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            radioValue: '1d',
 | 
			
		||||
            value: 1,
 | 
			
		||||
            unit: AddonCalendarReminderUnits.DAY,
 | 
			
		||||
            label: '',
 | 
			
		||||
        },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    customUnitsOptions = [
 | 
			
		||||
        {
 | 
			
		||||
            value: AddonCalendarReminderUnits.MINUTE,
 | 
			
		||||
            label: 'core.minutes',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            value: AddonCalendarReminderUnits.HOUR,
 | 
			
		||||
            label: 'core.hours',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            value: AddonCalendarReminderUnits.DAY,
 | 
			
		||||
            label: 'core.days',
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            value: AddonCalendarReminderUnits.WEEK,
 | 
			
		||||
            label: 'core.weeks',
 | 
			
		||||
        },
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritdoc
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        this.presetOptions.forEach((option) => {
 | 
			
		||||
            option.label = AddonCalendar.getUnitValueLabel(option.value, option.unit);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (!this.initialValue) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.initialValue.value === 0) {
 | 
			
		||||
            this.radioValue = 'disabled';
 | 
			
		||||
        } else {
 | 
			
		||||
            // Search if it's one of the preset options.
 | 
			
		||||
            const option = this.presetOptions.find(option =>
 | 
			
		||||
                option.value === this.initialValue?.value && option.unit === this.initialValue.unit);
 | 
			
		||||
 | 
			
		||||
            if (option) {
 | 
			
		||||
                this.radioValue = option.radioValue;
 | 
			
		||||
            } else {
 | 
			
		||||
                // It's a custom value.
 | 
			
		||||
                this.radioValue = 'custom';
 | 
			
		||||
                this.customValue = String(this.initialValue.value);
 | 
			
		||||
                this.customUnits = this.initialValue.unit;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Close the modal.
 | 
			
		||||
     */
 | 
			
		||||
    closeModal(): void {
 | 
			
		||||
        ModalController.dismiss();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Save the reminder.
 | 
			
		||||
     */
 | 
			
		||||
    saveReminder(): void {
 | 
			
		||||
        if (this.radioValue === 'disabled') {
 | 
			
		||||
            ModalController.dismiss(0);
 | 
			
		||||
        } else if (this.radioValue === 'custom') {
 | 
			
		||||
            const value = parseInt(this.customValue, 10);
 | 
			
		||||
            if (!value) {
 | 
			
		||||
                CoreDomUtils.showErrorModal('core.errorinvalidform', true);
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ModalController.dismiss(Math.abs(value) * this.customUnits);
 | 
			
		||||
        } else {
 | 
			
		||||
            const option = this.presetOptions.find(option => option.radioValue === this.radioValue);
 | 
			
		||||
            if (!option) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ModalController.dismiss(option.unit * option.value);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Custom value input clicked.
 | 
			
		||||
     *
 | 
			
		||||
     * @param ev Click event.
 | 
			
		||||
     */
 | 
			
		||||
    async customInputClicked(ev: Event): Promise<void> {
 | 
			
		||||
        if (this.radioValue === 'custom') {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.radioValue = 'custom';
 | 
			
		||||
 | 
			
		||||
        const target = <HTMLInputElement | HTMLElement | null> ev.target;
 | 
			
		||||
        if (target) {
 | 
			
		||||
            CoreDomUtils.focusElement(target);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user